Compare commits

..
4 Commits
Author SHA1 Message Date
qing f319686571 feat(static): aibox Web客户端重写为移动端专版
- 移除桌面端媒体查询与限宽居中,任务面板固定为抽屉形态
- 新增 enterkeyhint=send、overscroll-behavior、tap-highlight 等移动端触控优化
- 配合已提交的中断机制改动,协议与 BotOps 保持兼容
2026-08-01 14:17:36 +08:00
qing 08f737ba2c fix(static): aibox Web客户端适配中断机制(Ctrl+C可靠停止)
问题(与BotOps v1.3修复一致):
- interrupt_ack只显示系统消息,没有立即收尾前端状态
- 思考区域一直停留在"💭 思考中..."状态(服务端中断不发reasoning_end)
- AI状态未重置,残留reasoning片段可能重新创建思考区域

修复:
- 新增abortReasoning()函数:中断时更新思考区域标签为"🛑 思考已中断"(保留已展示内容)
- appendReasoning()增加aiActive===false检查,中断后丢弃残留片段
- interrupt_ack处理改为:先abortReasoning()更新思考区域,再finishAi()收尾AI状态,最后显示通知
- 中断后残留chunk/reasoning/complete均被安全忽略(appendAi已有aiActive检查,finishAi幂等)
2026-08-01 14:14:53 +08:00
qing c7ef7e715c fix(botops): v1.3 - 适配服务端中断机制,Ctrl+C立即可靠停止
问题:收到interrupt_ack时仅显示系统通知,没有立即收尾前端状态:
- 思考区域一直停留在"💭 思考中..."状态,不会更新
- AI回复状态未重置,后续残留chunk可能继续追加
- 服务端不发送reasoning_end,思考区域无法正常结束

修复:
- ChatView新增abort_reasoning()方法:中断时将思考区域标签更新为"🛑 思考已中断"(保留已展示内容)
- append_reasoning()增加_ai_finalized检查,中断后丢弃残留思考片段,防止重新创建思考区域
- app.py收到interrupt_ack时:先abort_reasoning()更新思考区域,再finish_ai()收尾AI状态,最后显示系统通知
- 中断后服务端发来的残留chunk/reasoning/complete均被安全忽略(append_ai已有_finalized检查,finish_ai幂等)
- 更新README v1.3日志
2026-08-01 14:12:27 +08:00
qing 9d737897df fix(aibox): v2.6 - 修复Ctrl+C中断无法停止服务器的问题
问题根因:
1. WebSocketHandler.handle_message 完全没有处理 interrupt 消息类型(前端Ctrl+C发送的消息被丢弃)
2. DeepSeekChat 缺少取消机制,阻塞的 API 请求(iter_lines)和子进程无法被中断

修复方案 - 三层取消机制:
1. threading.Event 取消标志:流式响应循环每轮检查 _is_cancelled(),工具调用前检查
2. HTTP连接关闭:保存 _current_response 引用,cancel() 时调用 response.close() 中断 iter_lines()
3. 子进程终止:SubprocessCommandExecutor 添加 cancel() 方法,保存 _current_process,cancel时kill

修改内容:
- DeepSeekChat: 添加 _cancel_event/_current_response/_command_executor 字段,cancel()/_is_cancelled()/_check_cancelled() 方法
- SubprocessCommandExecutor: 添加 __init__/_process_lock/cancel(),execute()/execute_streaming() 中设置和清除进程引用
- WebSocketHandler: 添加 handle_interrupt() 处理 interrupt 消息,回复 interrupt_ack 后调用 ai_manager.cancel()
- EnhancedAIManager: 添加 cancel() 统一入口,同时取消AI和命令执行器
- think_and_respond: 首轮清除cancel标志,流式循环检查取消,API/JSON/未知异常中均检查取消标志
- _handle_tool_calls: 每个工具调用前检查取消信号
- 更新README v2.6日志和API文档
2026-08-01 14:09:24 +08:00
8 changed files with 256 additions and 69 deletions

No files matched your search

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