Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f319686571 | ||
|
|
08f737ba2c | ||
|
|
c7ef7e715c | ||
|
|
9d737897df |
No files matched your search
@@ -71,6 +71,13 @@ BotOps/
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.3
|
||||
- ✅ **适配服务端中断机制(Ctrl+C可靠停止)**:
|
||||
- 收到 `interrupt_ack` 时立即收尾前端状态:调用 `abort_reasoning()` 将思考区域更新为"🛑 思考已中断"标签,调用 `finish_ai()` 结束AI回复状态
|
||||
- 新增 `ChatView.abort_reasoning()` 方法:中断时更新思考区域标签(保留已展示的思考内容)
|
||||
- `append_reasoning()` 增加 `_ai_finalized` 检查,中断后丢弃残留的思考片段防止重新创建思考区域
|
||||
- 中断后服务端发来的残留 `chunk`/`reasoning`/`complete` 消息均被安全忽略,不会污染UI
|
||||
|
||||
### v1.2
|
||||
- ✅ **彻底修复思考区域顺序问题**:移除预创建空 AI widget 的逻辑,改为第一次收到 `chunk` 时才创建 widget。按消息到达顺序自然挂载(`reasoning_start`→`reasoning`→`chunk`),从根本上保证思考区域始终在正式回答之前
|
||||
- ✅ 简化 `complete` 处理逻辑:仅作为收尾重置状态信号,不再进行补建逻辑
|
||||
|
||||
@@ -226,6 +226,9 @@ class BotOpsApp(App):
|
||||
f"❌ [{data.get('task_id', '?')}] {data.get('task_title', '?')}: "
|
||||
f"{data.get('error', '未知错误')}")
|
||||
elif t == "interrupt_ack":
|
||||
# 服务端确认中断:立即收尾前端状态(更新思考区域为已中断、结束AI回复)
|
||||
await self.chat.abort_reasoning()
|
||||
await self.chat.finish_ai("")
|
||||
await self.notify_system(f"🛑 {content}")
|
||||
elif t == "error":
|
||||
await self.notify_system(f"❌ {content}", level="error")
|
||||
|
||||
@@ -75,6 +75,9 @@ class ChatView(VerticalScroll):
|
||||
"""追加思考内容增量。"""
|
||||
if not delta:
|
||||
return
|
||||
if self._ai_finalized:
|
||||
# 已收尾(中断/完成),丢弃残留的思考片段
|
||||
return
|
||||
if self._reasoning_widget is None:
|
||||
await self.start_reasoning()
|
||||
self._reasoning_text += delta
|
||||
@@ -95,6 +98,17 @@ class ChatView(VerticalScroll):
|
||||
self._reasoning_widget.update(f"💭 思考过程({len(self._reasoning_text)}字符)\n{display}\n{'─' * 30}")
|
||||
# 思考区域保留显示,不删除
|
||||
|
||||
async def abort_reasoning(self):
|
||||
"""中断思考区域(用户Ctrl+C),更新提示文字为已中断。"""
|
||||
if self._reasoning_widget is not None:
|
||||
if self._reasoning_text:
|
||||
display = self._reasoning_text
|
||||
if len(display) > 3000:
|
||||
display = "...(前略)\n" + display[-3000:]
|
||||
self._reasoning_widget.update(f"🛑 思考已中断({len(self._reasoning_text)}字符)\n{display}")
|
||||
else:
|
||||
self._reasoning_widget.update("🛑 思考已中断")
|
||||
|
||||
async def _ensure_ai_widget(self):
|
||||
"""确保AI widget存在(第一次chunk到来时创建)。"""
|
||||
if self._ai_widget is None:
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
- 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量
|
||||
- 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM
|
||||
- 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换)
|
||||
- 🛑 **可靠中断机制**:支持 Ctrl+C 实时中断,通过 `threading.Event` + HTTP 连接关闭 + 子进程 kill 三层取消,流式响应和长时间运行命令均可立即终止
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -192,6 +193,15 @@ const ws = new WebSocket('ws://localhost:8765');
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 中断(Ctrl+C)
|
||||
```json
|
||||
{
|
||||
"type": "interrupt",
|
||||
"content": "用户按下了 Ctrl+C 中断当前操作"
|
||||
}
|
||||
```
|
||||
服务端收到后立即回复 `interrupt_ack`,并通过三层取消机制(Event标志 + HTTP连接关闭 + 子进程kill)终止正在进行的AI思考和工具执行。
|
||||
|
||||
### 支持的命令
|
||||
|
||||
> 注意:记忆搜索通过 `memory` 工具(在对话中由 AI 调用)完成,本系统**不存在** `/search` 斜杠命令。
|
||||
@@ -706,6 +716,19 @@ elif command == "/mycommand":
|
||||
|
||||
## 📄 更新日志
|
||||
|
||||
### v2.6
|
||||
- ✅ **修复 Ctrl+C 中断无法停止服务器的问题**:
|
||||
- 新增 `interrupt` WebSocket 消息类型处理(BotOps 前端 Ctrl+C 发送此消息),立即回复 `interrupt_ack` 确认
|
||||
- `DeepSeekChat` 新增 `cancel()` 方法和 `_cancel_event`(threading.Event),线程安全可跨线程调用
|
||||
- **三层取消机制**:
|
||||
1. 设置取消标志,流式响应循环每轮检查,发现取消立即 break
|
||||
2. 关闭当前 HTTP response 对象,中断 `iter_lines()` 阻塞读取
|
||||
3. 终止正在运行的子进程(`SubprocessCommandExecutor.cancel()` → `process.kill()`)
|
||||
- 工具调用循环中每个工具执行前检查取消信号,中断后不再继续后续工具
|
||||
- API 请求异常/JSON解析异常/未知异常中均检查取消标志,若因取消导致异常则按中断处理而非报错
|
||||
- 中断后自动回滚本轮消息,防止污染对话历史
|
||||
- 新增 `EnhancedAIManager.cancel()` 统一入口,同时终止 AI 和命令执行器
|
||||
|
||||
### v2.5
|
||||
- ✅ **新增 DeepSeek 思考模式支持**(Reasoning Mode):
|
||||
- 新增 `api.reasoning_enabled` 配置项(默认 `false`),启用后在请求中添加 `thinking: {type: "enabled"}` 参数
|
||||
|
||||
+139
-1
@@ -3532,11 +3532,28 @@ class CommandExecutor(ABC):
|
||||
|
||||
|
||||
class SubprocessCommandExecutor(CommandExecutor):
|
||||
"""基于subprocess的命令执行器 - 支持超时、工作目录和输出大小限制"""
|
||||
"""基于subprocess的命令执行器 - 支持超时、工作目录、输出大小限制和取消"""
|
||||
|
||||
# 命令输出最大字节数(1MB),超出截断
|
||||
MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
||||
|
||||
def __init__(self, logger=None):
|
||||
super().__init__(logger)
|
||||
self._current_process = None
|
||||
self._process_lock = threading.Lock()
|
||||
|
||||
def cancel(self):
|
||||
"""取消当前正在运行的命令(kill子进程)。线程安全。"""
|
||||
with self._process_lock:
|
||||
proc = self._current_process
|
||||
if proc is not None:
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
self.logger.info("已终止正在运行的子进程")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]:
|
||||
"""从进程读取输出,限制总大小和超时。
|
||||
返回 (output_str, timeout_occurred, truncated)
|
||||
@@ -3612,6 +3629,9 @@ class SubprocessCommandExecutor(CommandExecutor):
|
||||
env=exec_env,
|
||||
cwd=os.path.expanduser(work_dir)
|
||||
)
|
||||
# 保存进程引用以便cancel()可以kill它
|
||||
with self._process_lock:
|
||||
self._current_process = process
|
||||
|
||||
start_time = time.time()
|
||||
output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time)
|
||||
@@ -3654,6 +3674,9 @@ class SubprocessCommandExecutor(CommandExecutor):
|
||||
finally:
|
||||
if process and process.poll() is None:
|
||||
process.kill()
|
||||
# 清除当前进程引用
|
||||
with self._process_lock:
|
||||
self._current_process = None
|
||||
|
||||
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30,
|
||||
work_dir: str = None, env: Dict = None, **kwargs):
|
||||
@@ -3694,6 +3717,9 @@ class SubprocessCommandExecutor(CommandExecutor):
|
||||
cwd=os.path.expanduser(work_dir)
|
||||
)
|
||||
self.process = process
|
||||
# 保存进程引用以便cancel()可以kill它
|
||||
with self._process_lock:
|
||||
self._current_process = process
|
||||
|
||||
start_time = time.time()
|
||||
output_lines = []
|
||||
@@ -3788,6 +3814,8 @@ class SubprocessCommandExecutor(CommandExecutor):
|
||||
except:
|
||||
process.kill()
|
||||
self.process = None
|
||||
with self._process_lock:
|
||||
self._current_process = None
|
||||
|
||||
# ==================== 工具基类 ====================
|
||||
|
||||
@@ -6750,11 +6778,42 @@ class DeepSeekChat:
|
||||
self.preserve_system = constants.PRESERVE_SYSTEM_PROMPTS
|
||||
self.stats = {"api_calls": 0, "tool_calls": 0, "errors": []}
|
||||
|
||||
# 取消机制:threading.Event 用于跨线程信号,_current_response 用于中断HTTP请求
|
||||
self._cancel_event = threading.Event()
|
||||
self._current_response = None
|
||||
self._command_executor = command_executor # 保存命令执行器引用,用于取消时kill子进程
|
||||
|
||||
self._init_system_prompts(system_prompt)
|
||||
|
||||
self.tool_registry = ToolRegistry(self.logger)
|
||||
self._register_tools(exit_callback, command_executor)
|
||||
|
||||
def cancel(self):
|
||||
"""中断当前正在进行的AI思考/工具执行。线程安全,可从任意线程调用。"""
|
||||
self.logger.info("收到中断信号,正在取消当前操作...")
|
||||
self._cancel_event.set()
|
||||
# 关闭当前HTTP响应,中断 iter_lines() 阻塞
|
||||
if self._current_response is not None:
|
||||
try:
|
||||
self._current_response.close()
|
||||
except Exception:
|
||||
pass
|
||||
# 终止正在运行的子进程
|
||||
if self._command_executor is not None and hasattr(self._command_executor, 'cancel'):
|
||||
try:
|
||||
self._command_executor.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_cancelled(self) -> bool:
|
||||
"""检查是否已被取消。"""
|
||||
return self._cancel_event.is_set()
|
||||
|
||||
def _check_cancelled(self):
|
||||
"""如果已取消,抛出异常中断当前流程。"""
|
||||
if self._cancel_event.is_set():
|
||||
raise InterruptedError(i18n.get('user_interrupted', '用户中断'))
|
||||
|
||||
def _init_system_prompts(self, system_prompt: str = None):
|
||||
"""初始化系统提示词 - 从 PromptManager 加载(按 identity → 技能 → soul → status 顺序)"""
|
||||
if system_prompt:
|
||||
@@ -6965,6 +7024,11 @@ class DeepSeekChat:
|
||||
MAX_RESULT_LENGTH = constants.TOOL_MAX_RESULT_LENGTH
|
||||
|
||||
for tool_call in tool_calls:
|
||||
# 每个工具调用前检查取消信号
|
||||
if self._is_cancelled():
|
||||
self.logger.info("工具调用被用户中断")
|
||||
break
|
||||
|
||||
function = tool_call.get("function", {})
|
||||
tool_name = function.get("name", "unknown")
|
||||
arguments = function.get("arguments", "{}")
|
||||
@@ -7076,6 +7140,13 @@ class DeepSeekChat:
|
||||
is_continuation: 是否为工具调用后的递归继续;为 True 时不注入冗长的工作流指令,
|
||||
因为 system prompt 中已有完整行为规范,继续时只需传递工具结果
|
||||
"""
|
||||
# 首轮开始时清除取消标志
|
||||
if not is_continuation:
|
||||
self._cancel_event.clear()
|
||||
|
||||
# 立即检查是否已被取消
|
||||
self._check_cancelled()
|
||||
|
||||
if not self.conversation_active:
|
||||
self.logger.info("对话已结束,重置状态")
|
||||
self.conversation_active = True
|
||||
@@ -7217,6 +7288,7 @@ class DeepSeekChat:
|
||||
stream=True,
|
||||
proxies=proxies # 动态代理
|
||||
)
|
||||
self._current_response = response # 保存引用以便cancel()关闭连接
|
||||
|
||||
if response.status_code != 200:
|
||||
error_msg = f"\n错误状态码: {response.status_code}\n错误响应: {response.text}"
|
||||
@@ -7225,6 +7297,9 @@ class DeepSeekChat:
|
||||
response.raise_for_status()
|
||||
|
||||
for line in response.iter_lines():
|
||||
# 流式循环中检查取消信号
|
||||
if self._is_cancelled():
|
||||
break
|
||||
if line:
|
||||
line = line.decode('utf-8')
|
||||
if line.startswith('data: '):
|
||||
@@ -7315,6 +7390,16 @@ class DeepSeekChat:
|
||||
self.logger.error(f"JSON解析错误: {e}, 数据: {data}")
|
||||
continue
|
||||
|
||||
# 流式循环结束,检查是否被取消
|
||||
if self._is_cancelled():
|
||||
self._rollback_messages(msgs_before_turn)
|
||||
interrupt_msg = i18n.get('user_interrupted', '用户中断')
|
||||
if output_callback:
|
||||
output_callback("line", "\n" + interrupt_msg + "\n")
|
||||
output_callback("complete", interrupt_msg)
|
||||
self.logger.info("AI响应被用户中断")
|
||||
return interrupt_msg
|
||||
|
||||
# 处理工具调用
|
||||
if current_tool_calls:
|
||||
tool_calls_buffer = list(current_tool_calls.values())
|
||||
@@ -7382,7 +7467,24 @@ class DeepSeekChat:
|
||||
|
||||
return full_response if full_response else ""
|
||||
|
||||
except InterruptedError:
|
||||
# 用户主动中断:回滚消息,通知前端
|
||||
self._rollback_messages(msgs_before_turn)
|
||||
interrupt_msg = i18n.get('user_interrupted', '用户中断')
|
||||
if output_callback:
|
||||
output_callback("line", "\n" + interrupt_msg + "\n")
|
||||
output_callback("complete", interrupt_msg)
|
||||
self.logger.info("AI响应被用户中断")
|
||||
return interrupt_msg
|
||||
except requests.exceptions.RequestException as e:
|
||||
# 如果是因为取消导致的连接关闭,按中断处理
|
||||
if self._is_cancelled():
|
||||
self._rollback_messages(msgs_before_turn)
|
||||
interrupt_msg = i18n.get('user_interrupted', '用户中断')
|
||||
if output_callback:
|
||||
output_callback("line", "\n" + interrupt_msg + "\n")
|
||||
output_callback("complete", interrupt_msg)
|
||||
return interrupt_msg
|
||||
# 网络/HTTP 错误:回滚本轮添加的消息(user 消息 + 可能的 assistant/tool 消息),
|
||||
# 防止格式错误的消息污染历史导致后续请求持续失败
|
||||
self._rollback_messages(msgs_before_turn)
|
||||
@@ -7402,6 +7504,14 @@ class DeepSeekChat:
|
||||
self.logger.error(f"API响应解析失败,已回滚消息到位置 {msgs_before_turn}: {e}")
|
||||
return error_msg
|
||||
except Exception as e:
|
||||
# 如果是因为取消导致的异常,按中断处理
|
||||
if self._is_cancelled():
|
||||
self._rollback_messages(msgs_before_turn)
|
||||
interrupt_msg = i18n.get('user_interrupted', '用户中断')
|
||||
if output_callback:
|
||||
output_callback("line", "\n" + interrupt_msg + "\n")
|
||||
output_callback("complete", interrupt_msg)
|
||||
return interrupt_msg
|
||||
# 未知错误:回滚消息,防止对话状态被破坏
|
||||
self._rollback_messages(msgs_before_turn)
|
||||
error_msg = f"\n{i18n.get('error_prefix')}{str(e)}"
|
||||
@@ -7410,6 +7520,9 @@ class DeepSeekChat:
|
||||
self.stats["errors"].append(str(e))
|
||||
self.logger.error(f"未知错误,已回滚消息到位置 {msgs_before_turn}: {e}")
|
||||
return error_msg
|
||||
finally:
|
||||
# 清除当前响应引用
|
||||
self._current_response = None
|
||||
|
||||
def _rollback_messages(self, to_length: int):
|
||||
"""回滚 self.messages 到指定长度,用于 API 失败时恢复对话状态。"""
|
||||
@@ -7623,6 +7736,8 @@ class WebSocketHandler:
|
||||
await self.handle_command(websocket, data)
|
||||
elif msg_type == "message":
|
||||
await self.handle_user_message(websocket, data)
|
||||
elif msg_type == "interrupt":
|
||||
await self.handle_interrupt(websocket, data)
|
||||
elif msg_type == "task":
|
||||
await self.handle_task_message(websocket, data)
|
||||
elif msg_type == "ping":
|
||||
@@ -7648,6 +7763,18 @@ class WebSocketHandler:
|
||||
"content": f"处理失败: {str(e)}"
|
||||
})
|
||||
|
||||
async def handle_interrupt(self, websocket, data):
|
||||
"""处理中断信号:取消正在进行的AI思考和工具执行"""
|
||||
self.logger.info("收到客户端中断信号")
|
||||
# 先发送确认,让前端立即得到反馈
|
||||
await self.send_message(websocket, {
|
||||
"type": "interrupt_ack",
|
||||
"content": "已收到中断信号,正在停止..."
|
||||
})
|
||||
# 在事件循环线程外执行cancel(cancel是线程安全的,会设置Event并关闭response/kill进程)
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self.ai_manager.cancel)
|
||||
|
||||
async def handle_task_message(self, websocket, data):
|
||||
"""处理任务相关消息 - 简化版,所有任务都是对话任务"""
|
||||
action = data.get("action", "").strip()
|
||||
@@ -8109,6 +8236,17 @@ class EnhancedAIManager:
|
||||
self.logger.info("AI已初始化")
|
||||
return ai
|
||||
|
||||
def cancel(self):
|
||||
"""中断当前AI操作(线程安全,可从任意线程调用)"""
|
||||
if self.ai:
|
||||
self.ai.cancel()
|
||||
# 同时确保命令执行器中的进程也被终止
|
||||
if self.command_executor and hasattr(self.command_executor, 'cancel'):
|
||||
try:
|
||||
self.command_executor.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def show_welcome_message(self):
|
||||
"""显示欢迎信息"""
|
||||
print("\n" + "="*70)
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
<main id="chat"></main>
|
||||
<div id="input-bar">
|
||||
<button id="interrupt-btn" type="button" style="display:none" title="中断当前生成">⏸</button>
|
||||
<textarea id="input" rows="1" placeholder="输入消息,/help 查看命令"></textarea>
|
||||
<!-- enterkeyhint=send:移动端软键盘回车键显示"发送" -->
|
||||
<textarea id="input" rows="1" enterkeyhint="send" placeholder="输入消息,/help 查看命令"></textarea>
|
||||
<button id="send-btn" type="button">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 任务面板:始终为抽屉式覆盖层(移动端唯一形态) -->
|
||||
<aside id="task-panel">
|
||||
<header>任务面板<button id="task-close" type="button" aria-label="关闭">×</button></header>
|
||||
<div id="task-list"></div>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
/* ===== 全屏应用布局(覆盖 theme.css 的居中卡片页默认) ===== */
|
||||
/* ===== 移动端全屏应用布局(覆盖 theme.css 的居中卡片页默认) ===== */
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
display: block;
|
||||
padding: 0;
|
||||
height: 100dvh; /* 移动端动态视口高度,规避地址栏遮挡 */
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none; /* 禁止下拉刷新/回弹手势干扰聊天滚动 */
|
||||
}
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: var(--bg);
|
||||
transition: margin-right 0.25s;
|
||||
}
|
||||
/* 移动端触控优化:去点击高亮、去双击缩放延迟 */
|
||||
button, textarea { -webkit-tap-highlight-color: transparent; }
|
||||
button { touch-action: manipulation; }
|
||||
|
||||
/* ===== 顶栏 ===== */
|
||||
#topbar {
|
||||
@@ -67,6 +68,7 @@ body {
|
||||
overflow-y: auto;
|
||||
padding: 14px 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain; /* 滚动到底不连带拉动 body */
|
||||
}
|
||||
.msg { margin-bottom: 12px; overflow-wrap: break-word; }
|
||||
.msg-user {
|
||||
@@ -157,7 +159,7 @@ body {
|
||||
#interrupt-btn { flex: none; min-height: 44px; min-width: 44px; padding: 0.45rem 0.7rem; background: var(--error-bg); color: var(--error); }
|
||||
#send-btn:disabled, #interrupt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ===== 任务面板:移动端为抽屉式覆盖层 ===== */
|
||||
/* ===== 任务面板:抽屉式覆盖层 ===== */
|
||||
#task-panel {
|
||||
position: fixed;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
@@ -204,13 +206,3 @@ body {
|
||||
z-index: 99;
|
||||
}
|
||||
#backdrop.show { opacity: 1; pointer-events: auto; }
|
||||
|
||||
/* ===== 桌面端:任务面板固化为右侧栏 ===== */
|
||||
@media (min-width: 900px) {
|
||||
#app { margin-right: 340px; max-width: none; }
|
||||
#task-panel { transform: none; box-shadow: none; }
|
||||
#task-toggle, #task-close, #backdrop { display: none; }
|
||||
|
||||
/* 桌面端所有对话一律左对齐(移动端保留用户右/AI 左的对话式布局) */
|
||||
.msg-user, .msg-ai, .msg-reasoning { margin-left: 0; margin-right: 0; }
|
||||
}
|
||||
+60
-52
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
/* ============================================================
|
||||
* aibox Web 客户端(经 gateway WS 反向代理,仅管理员可用)
|
||||
* aibox Web 客户端 · 移动端版(经 gateway WS 反向代理,仅管理员可用)
|
||||
* 协议与 BotOps/aibox client.py 完全兼容:
|
||||
* 发送:{"type":"message","content":...} / {"type":"command","command":"/..."} / {"type":"interrupt",...}
|
||||
* 接收:chunk(增量片段) / reasoning_start / reasoning / reasoning_end / complete /
|
||||
@@ -12,19 +12,20 @@
|
||||
/* gateway [WsProxy] routes 中配置的路径(HttpOnly q4_token Cookie 随握手自动携带,JS 不经手 Token)。
|
||||
* 需与 gateway.ini 的 [WsProxy] routes 条目一致,如:routes = /ws/aibox=ws://127.0.0.1:8765; */
|
||||
const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/aibox';
|
||||
const RECONNECT_MIN = 2000; /* 指数退避起点(与 BotOps 一致:2s→30s 封顶) */
|
||||
const RECONNECT_MIN = 2000; /* 指数退避起点(2s→30s 封顶) */
|
||||
const RECONNECT_MAX = 30000;
|
||||
const REASONING_TAIL = 3000; /* 思考区域超长时仅显示末尾字符数 */
|
||||
|
||||
/* ---------- Markdown 渲染(链接一律新标签打开且不带 referrer) ---------- */
|
||||
const md = window.markdownit({ html: false, linkify: true, breaks: true });
|
||||
/* 链接一律新标签打开且不带 referrer(CSP connect-src 'self' 允许 WS 同源) */
|
||||
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); };
|
||||
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
|
||||
const defaultLinkOpen = md.renderer.rules.link_open || ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options));
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
tokens[idx].attrSet('target', '_blank');
|
||||
tokens[idx].attrSet('rel', 'noopener noreferrer');
|
||||
return defaultLinkOpen(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
/* ---------- DOM ---------- */
|
||||
const chatEl = document.getElementById('chat');
|
||||
const inputEl = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
@@ -38,26 +39,26 @@ const taskToggle = document.getElementById('task-toggle');
|
||||
const taskBadge = document.getElementById('task-badge');
|
||||
const backdrop = document.getElementById('backdrop');
|
||||
|
||||
/* ---------- 状态 ---------- */
|
||||
let ws = null;
|
||||
let manualClose = false; /* 手动断开则抑制自动重连 */
|
||||
let reconnectDelay = RECONNECT_MIN;
|
||||
let reconnectTimer = null;
|
||||
|
||||
/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前,与 BotOps 同策略) */
|
||||
/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前) */
|
||||
let aiBubble = null, aiText = '', aiActive = false;
|
||||
let reasoningBox = null, reasoningBody = null, reasoningHead = null, reasoningText = '';
|
||||
let streaming = false;
|
||||
let unreadTasks = 0;
|
||||
|
||||
/* ---------- 工具 ---------- */
|
||||
/* ---------- 滚动(距底 80px 内才自动跟随,用户上翻阅读不打断) ---------- */
|
||||
function nearBottom() {
|
||||
return chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 80;
|
||||
}
|
||||
function scrollToBottom(force) {
|
||||
if (force || nearBottom()) chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}
|
||||
function escapeText(s) { return String(s == null ? '' : s); }
|
||||
|
||||
/* ---------- 系统消息 / 任务面板行 ---------- */
|
||||
function addSystem(text, isError) {
|
||||
const div = document.createElement('div');
|
||||
div.className = isError ? 'msg msg-error' : 'msg msg-system';
|
||||
@@ -130,7 +131,7 @@ function startReasoning(hint) {
|
||||
reasoningHead.textContent = '💭 ' + (hint || '思考中...');
|
||||
reasoningBody = document.createElement('div');
|
||||
reasoningBody.className = 'reasoning-body';
|
||||
reasoningHead.addEventListener('click', function() { reasoningBody.classList.toggle('folded'); });
|
||||
reasoningHead.addEventListener('click', () => reasoningBody.classList.toggle('folded'));
|
||||
reasoningBox.appendChild(reasoningHead);
|
||||
reasoningBox.appendChild(reasoningBody);
|
||||
chatEl.appendChild(reasoningBox);
|
||||
@@ -138,12 +139,12 @@ function startReasoning(hint) {
|
||||
}
|
||||
function appendReasoning(delta) {
|
||||
if (!delta) return;
|
||||
if (aiActive === false) return; /* 已收尾(中断/完成),丢弃残留片段 */
|
||||
if (!reasoningBox) startReasoning();
|
||||
reasoningText += delta;
|
||||
const display = reasoningText.length > REASONING_TAIL
|
||||
reasoningBody.textContent = reasoningText.length > REASONING_TAIL
|
||||
? '...(前略)\n' + reasoningText.slice(-REASONING_TAIL)
|
||||
: reasoningText;
|
||||
reasoningBody.textContent = display;
|
||||
scrollToBottom();
|
||||
}
|
||||
function endReasoning() {
|
||||
@@ -152,6 +153,16 @@ function endReasoning() {
|
||||
reasoningBody.classList.add('folded'); /* 结束后默认折叠,避免占屏 */
|
||||
}
|
||||
}
|
||||
function abortReasoning() {
|
||||
/* 用户中断:更新思考区域标签为已中断,保留已展示内容 */
|
||||
if (reasoningHead) {
|
||||
if (reasoningText) {
|
||||
reasoningHead.textContent = '🛑 思考已中断(' + reasoningText.length + ' 字符)';
|
||||
} else {
|
||||
reasoningHead.textContent = '🛑 思考已中断';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 连接状态 ---------- */
|
||||
function setStatus(cls, text) {
|
||||
@@ -159,7 +170,6 @@ function setStatus(cls, text) {
|
||||
statusText.textContent = text;
|
||||
}
|
||||
function setStreaming(on) {
|
||||
streaming = on;
|
||||
interruptBtn.style.display = on ? '' : 'none';
|
||||
sendBtn.disabled = false; /* 流式期间仍可发送(服务端排队),仅中断按钮显隐 */
|
||||
}
|
||||
@@ -177,19 +187,19 @@ function connect() {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws.onopen = function() {
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = RECONNECT_MIN;
|
||||
setStatus('connected', '已连接');
|
||||
connBtn.textContent = '断开';
|
||||
connBtn.classList.add('on');
|
||||
};
|
||||
ws.onmessage = function(ev) {
|
||||
ws.onmessage = (ev) => {
|
||||
let data;
|
||||
try { data = JSON.parse(ev.data); }
|
||||
catch (e) { data = { type: 'chunk', content: ev.data }; }
|
||||
routeMessage(data);
|
||||
};
|
||||
ws.onclose = function() {
|
||||
ws.onclose = () => {
|
||||
setStatus('disconnected', '已断开');
|
||||
connBtn.textContent = '连接';
|
||||
connBtn.classList.remove('on');
|
||||
@@ -197,14 +207,14 @@ function connect() {
|
||||
ws = null;
|
||||
if (!manualClose) scheduleReconnect();
|
||||
};
|
||||
ws.onerror = function() { /* onclose 随后触发,统一在 onclose 处理 */ };
|
||||
ws.onerror = () => { /* onclose 随后触发,统一在 onclose 处理 */ };
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (manualClose) return;
|
||||
setStatus('connecting', Math.round(reconnectDelay / 1000) + 's 后重连');
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(function() {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
@@ -219,7 +229,7 @@ function disconnect(manual) {
|
||||
connBtn.classList.remove('on');
|
||||
}
|
||||
|
||||
/* ---------- aibox 协议消息路由(对应 BotOps app.py 的 _on_ws_message) ---------- */
|
||||
/* ---------- aibox 协议消息路由 ---------- */
|
||||
function routeMessage(data) {
|
||||
const t = data.type || '';
|
||||
const content = typeof data.content === 'string' ? data.content : (data.content == null ? '' : String(data.content));
|
||||
@@ -264,6 +274,9 @@ function routeMessage(data) {
|
||||
} else if (t === 'task_error') {
|
||||
addTaskLine('❌ [' + (data.task_id || '?') + '] ' + (data.task_title || '?') + ': ' + (data.error || '未知错误'));
|
||||
} else if (t === 'interrupt_ack') {
|
||||
/* 服务端确认中断:立即收尾前端状态 */
|
||||
abortReasoning();
|
||||
finishAi('');
|
||||
addSystem('🛑 ' + (content || '已中断'));
|
||||
} else if (t === 'error') {
|
||||
addSystem('❌ ' + content, true);
|
||||
@@ -291,38 +304,31 @@ function wsSend(obj) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const LOCAL_COMMANDS = ['/clear', '/help', '/connect', '/disconnect', '/interrupt'];
|
||||
|
||||
function handleLocalCommand(cmd) {
|
||||
switch (cmd) {
|
||||
case '/clear':
|
||||
/* 本地命令表:命中则本地处理,其余 / 命令透传服务器 */
|
||||
const LOCAL_COMMANDS = {
|
||||
'/clear'() {
|
||||
chatEl.replaceChildren();
|
||||
finishAi('');
|
||||
return true;
|
||||
case '/help':
|
||||
},
|
||||
'/help'() {
|
||||
addSystem('本地命令:/clear 清屏 · /connect 连接 · /disconnect 断开 · /interrupt 中断 · 其余 / 命令透传服务器');
|
||||
return true;
|
||||
case '/connect':
|
||||
connect();
|
||||
return true;
|
||||
case '/disconnect':
|
||||
disconnect(true);
|
||||
return true;
|
||||
case '/interrupt':
|
||||
sendInterrupt();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
'/connect'() { connect(); },
|
||||
'/disconnect'() { disconnect(true); },
|
||||
'/interrupt'() { sendInterrupt(); },
|
||||
};
|
||||
|
||||
function sendCurrentInput() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
if (text.startsWith('/') && handleLocalCommand(text.split(/\s/)[0])) {
|
||||
inputEl.value = '';
|
||||
autoResize();
|
||||
return;
|
||||
if (text.startsWith('/')) {
|
||||
const local = LOCAL_COMMANDS[text.split(/\s/)[0]];
|
||||
if (local) {
|
||||
local();
|
||||
inputEl.value = '';
|
||||
autoResize();
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* 与 BotOps 一致:/ 开头按 command 发送,否则 message */
|
||||
const data = text.startsWith('/')
|
||||
@@ -347,18 +353,20 @@ function autoResize() {
|
||||
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
||||
}
|
||||
inputEl.addEventListener('input', autoResize);
|
||||
inputEl.addEventListener('keydown', function(e) {
|
||||
/* Enter 发送,Shift+Enter 换行(移动端依赖发送按钮,不影响) */
|
||||
inputEl.addEventListener('keydown', (e) => {
|
||||
/* Enter 发送,Shift+Enter 换行;isComposing 防中文输入法选词误发 */
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
sendCurrentInput();
|
||||
}
|
||||
});
|
||||
/* 软键盘弹出动画结束后保持聊天区停留底部 */
|
||||
inputEl.addEventListener('focus', () => setTimeout(() => scrollToBottom(true), 300));
|
||||
sendBtn.addEventListener('click', sendCurrentInput);
|
||||
interruptBtn.addEventListener('click', sendInterrupt);
|
||||
|
||||
/* ---------- 连接按钮与任务面板 ---------- */
|
||||
connBtn.addEventListener('click', function() {
|
||||
/* ---------- 连接按钮与任务面板(抽屉开关) ---------- */
|
||||
connBtn.addEventListener('click', () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) disconnect(true);
|
||||
else connect();
|
||||
});
|
||||
@@ -371,12 +379,12 @@ function openTaskPanel(open) {
|
||||
taskBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
taskToggle.addEventListener('click', function() { openTaskPanel(!taskPanel.classList.contains('open')); });
|
||||
document.getElementById('task-close').addEventListener('click', function() { openTaskPanel(false); });
|
||||
backdrop.addEventListener('click', function() { openTaskPanel(false); });
|
||||
taskToggle.addEventListener('click', () => openTaskPanel(!taskPanel.classList.contains('open')));
|
||||
document.getElementById('task-close').addEventListener('click', () => openTaskPanel(false));
|
||||
backdrop.addEventListener('click', () => openTaskPanel(false));
|
||||
|
||||
/* ---------- 启动:管理员预检(WS 握手由 gateway 强制 is_admin,此处仅提示) ---------- */
|
||||
(async function init() {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/whoami', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
const data = res.ok ? await res.json() : null;
|
||||
|
||||
Reference in new issue
Block a user