From b79044d631f5f6b2d639a5199fe4c08dd9c9f628 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 04:33:55 +0800 Subject: [PATCH] feat(aibox): add DeepSeek reasoning mode support with tool calling on deepseek-v4-flash - Add api.reasoning_enabled config (default false) to enable thinking mode - Uses deepseek-v4-flash native thinking capability (thinking param), supports tool calls while reasoning - Real-time reasoning content streaming via reasoning_start/reasoning/reasoning_end WS messages - Frontend shows reasoning in gray italic area, auto-folds long reasoning (>2000 chars) - Dynamic reasoning detection: shows reasoning even if model returns it without explicit config - Increase default API timeout to 120s for longer reasoning responses - Remove duplicate _add_assistant_message_with_reasoning method - Update docs: config example, message types table, v2.5 changelog --- BotOps/README.md | 6 ++-- BotOps/app.py | 27 +++++++++++---- BotOps/widgets/chat_view.py | 48 +++++++++++++++++++++++++-- aibox/README.md | 23 ++++++++++--- aibox/aibox.py | 66 ++++++++++++++++++++++++------------- 5 files changed, 131 insertions(+), 39 deletions(-) diff --git a/BotOps/README.md b/BotOps/README.md index 9ca5ec1..bfe8e2a 100644 --- a/BotOps/README.md +++ b/BotOps/README.md @@ -60,7 +60,9 @@ BotOps/ - 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}` - 接收: - `chunk`(**增量片段**):服务端 `chunk_buffer` 发送后即清零,前端直接 `append_ai` 拼接,为内容权威来源 + - `reasoning_start`(**思考开始**):创建灰色斜体思考区域,实时展示 AI 思考过程 + - `reasoning`(**思考流**):思考内容增量片段,追加到思考区域(超长自动折叠显示最后 2000 字符) + - `reasoning_end`(**思考结束**):思考结束,切换到正式回答 - `complete`(**收尾信号**):chunk 流式已完整渲染,complete 仅用于收尾;仅在完全丢帧(未收到任何 chunk)时用 full_text 补建消息,**不再做全文替换对齐**(避免服务端递归调用工具后 complete 只含最后一轮内容导致重复渲染) - - `line`(过程行):🔧 工具调用提示、工具输出、💬 助手前缀,路由到任务侧栏;助手前缀/思考提示在聊天区已由 AI 消息框承载,不重复展示 - - `reasoning`(思考流):仅服务端 debug_level≥3 发送,记入任务侧栏 + - `line`(过程行):🔧 工具调用提示、工具输出,路由到任务侧栏;助手前缀/思考提示在聊天区已由对应区域承载,不重复展示 - `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` / `pong` diff --git a/BotOps/app.py b/BotOps/app.py index 645528d..d6348f4 100644 --- a/BotOps/app.py +++ b/BotOps/app.py @@ -58,6 +58,13 @@ class BotOpsApp(App): color: yellow; padding: 0 1; } + .msg-reasoning { + color: $text-muted; + background: $surface; + padding: 0 1; + margin: 0 1; + text-style: italic; + } #status { height: 1; } @@ -163,21 +170,27 @@ class BotOpsApp(App): if t == "chunk": # 服务端 chunk 为增量片段(发送后即清零),直接拼接 await self.chat.append_ai(content) + elif t == "reasoning_start": + # 开始深度思考,创建思考区域 + await self.chat.start_reasoning(content or "🤔 深度思考中...") + elif t == "reasoning": + # 思考内容增量 + stripped = content + if stripped: + await self.chat.append_reasoning(stripped) + elif t == "reasoning_end": + # 思考结束 + await self.chat.end_reasoning() elif t == "line": # 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀 stripped = content.strip() if not stripped: pass - elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking")): - pass # 助手前缀 / 思考提示:聊天区已有 AI 消息框,无需展示 + elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking", "🤔 深度思考中")): + pass # 助手前缀 / 思考提示:聊天区已有对应区域,无需展示 else: # 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏 self.tasks.write(stripped) - elif t == "reasoning": - # 思考流(服务端 debug_level>=3 才发送),记入任务侧栏 - stripped = content.strip() - if stripped: - self.tasks.write(f"💭 {stripped[-200:]}") elif t == "complete": # complete 携带完整全文,用于对齐已显示内容 await self.chat.finish_ai(content) diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py index 445b295..788f4da 100644 --- a/BotOps/widgets/chat_view.py +++ b/BotOps/widgets/chat_view.py @@ -1,9 +1,12 @@ """聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。 服务端协议(aibox/aibox.py output_callback 原样透传 msg_type): - - chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append - - complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾; - 仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建 + - chunk 增量片段(正式回答内容),直接 append 到 AI 消息 + - reasoning_start 思考开始信号,创建思考区域 + - reasoning 思考内容增量片段,追加到思考区域 + - reasoning_end 思考结束信号 + - complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾; + 仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建 """ from textual.containers import VerticalScroll from textual.widgets import Markdown, Static @@ -20,7 +23,9 @@ class ChatView(VerticalScroll): def __init__(self, **kwargs): super().__init__(**kwargs) self._ai_widget: Markdown | None = None + self._reasoning_widget: Static | None = None self._ai_text = "" + self._reasoning_text = "" self._ai_finalized = True # 初始无活跃 AI 消息 @property @@ -49,6 +54,39 @@ class ChatView(VerticalScroll): await self.mount(self._ai_widget) self._anchor_bottom() + async def start_reasoning(self, hint: str = "🤔 思考中..."): + """开始展示思考过程区域。""" + if self._reasoning_widget is not None: + return + self._reasoning_text = "" + self._reasoning_widget = Static(hint + "\n", classes="msg-reasoning", markup=False) + await self.mount(self._reasoning_widget) + self._anchor_bottom() + + async def append_reasoning(self, delta: str): + """追加思考内容增量。""" + if not delta: + return + if self._reasoning_widget is None: + await self.start_reasoning() + self._reasoning_text += delta + # 限制思考内容显示长度(最多显示最后 2000 字符,避免思考过长占满屏幕) + display = self._reasoning_text + if len(display) > 2000: + display = "...(前略)\n" + display[-2000:] + self._reasoning_widget.update(f"💭 思考中...\n{display}") + self.call_after_refresh(self.scroll_end, animate=False) + + async def end_reasoning(self): + """结束思考区域,更新提示文字。""" + if self._reasoning_widget is not None and self._reasoning_text: + # 思考结束后保持展示,但更新标签 + display = self._reasoning_text + if len(display) > 2000: + display = "...(前略)\n" + display[-2000:] + self._reasoning_widget.update(f"💭 思考过程({len(self._reasoning_text)}字符)\n{display}\n{'─' * 30}") + # 思考区域保留显示,不删除 + async def append_ai(self, delta: str): """追加增量片段(服务端 chunk 为增量语义,直接拼接)。""" if not delta: @@ -97,12 +135,16 @@ class ChatView(VerticalScroll): self._anchor_bottom() self._ai_text = "" + self._reasoning_widget = None + self._reasoning_text = "" self._ai_finalized = True self.call_after_refresh(self.scroll_end, animate=False) async def reset(self): """清空聊天区(/clear)。""" self._ai_widget = None + self._reasoning_widget = None self._ai_text = "" + self._reasoning_text = "" self._ai_finalized = True await self.remove_children() diff --git a/aibox/README.md b/aibox/README.md index 3848f32..a1105a8 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -215,9 +215,11 @@ const ws = new WebSocket('ws://localhost:8765'); | 类型 | 说明 | |-----|------| | `chunk` | AI 响应的**增量片段**(流式输出,chunk_buffer 发送后即清零,为内容权威来源) | +| `reasoning_start` | AI 开始思考信号(思考模式,创建思考区域展示) | +| `reasoning` | AI 思考过程增量片段(流式输出,灰色/斜体样式) | +| `reasoning_end` | AI 思考结束信号(切换到正式回答) | | `complete` | AI 响应**收尾信号**(chunk 流式是内容权威来源,complete 仅用于收尾;完全丢帧时可用 full_text 补建) | -| `line` | 过程行(🔧 工具调用提示、工具输出、💬 助手前缀、思考提示等,供任务侧栏/终端显示) | -| `reasoning` | AI 思考过程(思考模式,仅 debug_level≥3 发送) | +| `line` | 过程行(🔧 工具调用提示、工具输出、💬 助手前缀等,供任务侧栏/终端显示) | | `error` | 错误信息(API 请求失败、解析失败等;服务端自动回滚本轮消息防止对话历史被污染) | | `command_result` | 命令执行结果 | | `task_status` | 任务状态更新(executing/complete/failed) | @@ -427,8 +429,9 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i "api": { "deepseek_api_url": "https://api.deepseek.com/chat/completions", "deepseek_model": "deepseek-v4-flash", + "reasoning_enabled": false, "api_key_env": "DEEPSEEK_API_KEY", - "timeout": 30, + "timeout": 120, "temperature": 0.7 }, "context": { @@ -492,7 +495,9 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i } ``` -> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。 +> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。 +> +> API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。 ## 🛠️ 命令行参数 @@ -701,6 +706,16 @@ elif command == "/mycommand": ## 📄 更新日志 +### v2.5 +- ✅ **新增 DeepSeek 思考模式支持**(Reasoning Mode): + - 新增 `api.reasoning_enabled` 配置项(默认 `false`),启用后在请求中添加 `thinking: {type: "enabled"}` 参数 + - 使用 `deepseek-v4-flash` 模型原生思考能力,**思考模式下完整支持工具调用**(文件操作、命令执行、记忆搜索等均可正常使用) + - 实时展示 AI 思考过程:新增 `reasoning_start`/`reasoning`/`reasoning_end` WebSocket 消息类型 + - 思考内容在前端以灰色斜体区域独立展示,超长思考自动折叠显示最后 2000 字符 + - 即使未启用思考模式,若模型返回 `reasoning_content` 也会自动实时展示 + - API 超时默认从 30s 提高到 120s,适配思考模式更长的响应时间 +- ✅ 删除重复定义的 `_add_assistant_message_with_reasoning` 方法 + ### v2.4 - ✅ **新增专用工作目录规范**: - AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/`,避免污染系统其他目录 diff --git a/aibox/aibox.py b/aibox/aibox.py index aaace7b..2ee8098 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -1168,8 +1168,9 @@ class ConfigManager: "api": { "deepseek_api_url": "https://api.deepseek.com/chat/completions", "deepseek_model": "deepseek-v4-flash", + "reasoning_enabled": false, "api_key_env": "DEEPSEEK_API_KEY", - "timeout": 30, + "timeout": 120, "temperature": 0.7 }, "context": { @@ -1356,7 +1357,11 @@ class Constants: @property def DEEPSEEK_MODEL(cls): - return cls._get_config().get("api.deepseek_model") + return cls._get_config().get("api.deepseek_model", "deepseek-v4-flash") + + @property + def REASONING_ENABLED(cls): + return cls._get_config().get("api.reasoning_enabled", False) @property def DEFAULT_TIMEOUT(cls): @@ -7169,11 +7174,13 @@ class DeepSeekChat: "stream": True } - # 可选:添加 thinking 模式配置(如果有) - payload["thinking"] = { - "type": "enabled" - } + # 思考模式:启用 thinking 参数 + if constants.REASONING_ENABLED: + payload["thinking"] = { + "type": "enabled" + } + # 工具调用(v4-flash 支持思考+工具调用) tools = self.tool_registry.get_tools_schemas() if tools: payload["tools"] = tools @@ -7182,10 +7189,16 @@ class DeepSeekChat: current_time = datetime.now().strftime("%H:%M:%S") if output_callback: output_callback("line", f"\n{i18n.get('thinking', current_time)}\n") - output_callback("line", i18n.get('assistant_prefix')) + if constants.REASONING_ENABLED: + output_callback("reasoning_start", "🤔 思考中...") + else: + # 普通模式先显示助手前缀 + output_callback("line", i18n.get('assistant_prefix')) full_response = "" full_reasoning = "" # 存储思考内容 + reasoning_started = False + reasoning_ended = False tool_calls_buffer = [] current_tool_calls = {} @@ -7236,10 +7249,25 @@ class DeepSeekChat: if 'reasoning_content' in delta and delta['reasoning_content']: reasoning = delta['reasoning_content'] full_reasoning += reasoning - # 可选:将思考内容也发送给前端(可以单独处理) - if output_callback and self.debug_level >= 3: + + # 发送思考开始信号(仅第一次,且未提前发送过) + if not reasoning_started and output_callback: + if not constants.REASONING_ENABLED: + # 非显式思考模式但模型返回了思考内容,动态发送开始信号 + output_callback("reasoning_start", "🤔 思考中...") + reasoning_started = True + + # 实时发送思考内容给前端(无需debug_level限制) + if output_callback: output_callback("reasoning", reasoning) + # 思考结束:当开始收到content且之前有reasoning时,发送结束信号 + if 'content' in delta and delta['content'] and reasoning_started and not reasoning_ended: + reasoning_ended = True + if output_callback: + output_callback("reasoning_end", "") + output_callback("line", i18n.get('assistant_prefix')) + # 处理正常内容 if 'content' in delta and delta['content']: content = delta['content'] @@ -7290,7 +7318,12 @@ class DeepSeekChat: # 处理工具调用 if current_tool_calls: tool_calls_buffer = list(current_tool_calls.values()) - if output_callback: + + # 如果之前在思考,先结束思考状态 + if reasoning_started and not reasoning_ended and output_callback: + output_callback("reasoning_end", "") + output_callback("line", i18n.get('calling_tools') + "\n") + elif output_callback: output_callback("line", i18n.get('calling_tools') + "\n") # 【关键修复1】先添加助手消息,包含 tool_calls @@ -7388,19 +7421,6 @@ class DeepSeekChat: except Exception as e: self.logger.error(f"消息回滚失败: {e}") - - def _add_assistant_message_with_reasoning(self, content: str, reasoning_content: str = None, tool_calls: List = None): - """添加助手消息,包含 reasoning_content""" - msg = {"role": "assistant"} - if content: - msg["content"] = content - if reasoning_content: - msg["reasoning_content"] = reasoning_content - if tool_calls: - msg["tool_calls"] = tool_calls - msg["content"] = None - self.messages.append(msg) - def reset_conversation(self, reload_prompts: bool = True): """ 重置对话状态 - 清除所有对话记忆,可选择是否重新加载系统提示词