diff --git a/crates/df-ai-core/src/provider.rs b/crates/df-ai-core/src/provider.rs index 26beef4..2d2bd33 100644 --- a/crates/df-ai-core/src/provider.rs +++ b/crates/df-ai-core/src/provider.rs @@ -36,6 +36,9 @@ pub struct CompletionRequest { /// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"} #[serde(skip_serializing_if = "Option::is_none")] pub tool_choice: Option, + /// DeepSeek thinking 模式:需把上一轮 response 的 reasoning_content 回传 + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, } /// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。 @@ -115,29 +118,32 @@ pub struct ChatMessage { /// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。 #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, + /// DeepSeek thinking 模式的推理内容(多轮需回传) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, } impl ChatMessage { pub fn system(content: impl Into) -> Self { - Self { role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None } + Self { role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None } } pub fn user(content: impl Into) -> Self { - Self { role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None } + Self { role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None } } pub fn assistant(content: impl Into) -> Self { - Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None } + Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None } } pub fn assistant_with_tools(content: impl Into, tool_calls: Vec) -> Self { - Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None } + Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None } } pub fn tool_result(call_id: impl Into, content: impl Into) -> Self { - Self { role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None } + Self { role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None } } /// 多模态 user 消息:content 文本 + parts(含 Image 片)。 /// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。 pub fn user_parts(content: impl Into, parts: Vec) -> Self { - Self { role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None } + Self { role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None } } /// 是否含图片片(供 provider 判定走多模态分支)。 @@ -249,6 +255,9 @@ pub struct CompletionResponse { /// AI 发起的工具调用(如有) #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, + /// DeepSeek thinking 模式响应中的推理内容(需回传到下一轮请求) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, } /// Token 用量 @@ -276,6 +285,9 @@ pub struct StreamChunk { /// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。 #[serde(skip)] pub error: Option, + /// DeepSeek thinking 模式推理内容增量 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, } /// 工具调用增量(流式中的片段) @@ -449,8 +461,98 @@ mod tests { tool_calls: None, model: None, status: None, + reasoning_content: None, }; assert_eq!(m.content, "字面量构造"); assert!(m.parts.is_none()); } + + // ---------- DeepSeek reasoning_content ---------- + + /// 有 reasoning_content 时应序列化出来;None 时不出现 + #[test] + fn completion_request_reasoning_content_serialization() { + let req = CompletionRequest { + model: "deepseek-r1".to_string(), + messages: vec![ChatMessage::user("hello")], + temperature: None, + max_tokens: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_content: Some("let me think...".to_string()), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("reasoning_content"), "reasoning_content 应出现在 JSON 中, got: {}", json); + + // None 时不应出现(skip_serializing_if) + let req_none = CompletionRequest { reasoning_content: None, ..req }; + let json_none = serde_json::to_string(&req_none).unwrap(); + assert!(!json_none.contains("reasoning_content"), "None 的 reasoning_content 不应序列化, got: {}", json_none); + } + + /// 所有便捷构造函数默认 reasoning_content 为 None + #[test] + fn chat_message_reasoning_content_constructors() { + assert!(ChatMessage::system("sys").reasoning_content.is_none()); + assert!(ChatMessage::user("hi").reasoning_content.is_none()); + assert!(ChatMessage::assistant("resp").reasoning_content.is_none()); + assert!(ChatMessage::tool_result("call_1", "result").reasoning_content.is_none()); + } + + /// ChatMessage reasoning_content 序列化 round-trip + #[test] + fn chat_message_reasoning_content_roundtrip() { + let m = ChatMessage { + role: MessageRole::Assistant, + content: "answer".to_string(), + parts: None, + tool_call_id: None, + tool_calls: None, + model: None, + status: None, + reasoning_content: Some("thinking process".to_string()), + }; + let json = serde_json::to_string(&m).unwrap(); + assert!(json.contains("reasoning_content")); + + let deserialized: ChatMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string())); + } + + /// StreamChunk reasoning_content 序列化 + #[test] + fn stream_chunk_reasoning_content() { + let chunk = StreamChunk { + delta: "text".to_string(), + finished: false, + tool_calls: None, + usage: None, + error: None, + reasoning_content: Some("thought".to_string()), + }; + let json = serde_json::to_string(&chunk).unwrap(); + assert!(json.contains("reasoning_content")); + + let chunk_none = StreamChunk { reasoning_content: None, ..chunk }; + let json_none = serde_json::to_string(&chunk_none).unwrap(); + assert!(!json_none.contains("reasoning_content")); + } + + /// CompletionResponse reasoning_content 序列化 + #[test] + fn completion_response_reasoning_content() { + let resp = CompletionResponse { + text: "ok".to_string(), + model: "r1".to_string(), + usage: TokenUsage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + tool_calls: None, + reasoning_content: Some("r1 thought".to_string()), + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("reasoning_content")); + + let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string())); + } } diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 90e91ea..02de838 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -109,7 +109,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option v, Err(_) => { - return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None } + return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } } }; let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); @@ -128,7 +128,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { @@ -138,14 +138,14 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { if let Some(delta) = v.get("delta") { if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") { let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string(); - return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None }; + return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }; } // 工具入参增量 if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") { @@ -162,10 +162,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { @@ -194,10 +195,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option StreamChunk { @@ -206,16 +208,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string(); error!(%msg, "Anthropic 流式错误事件"); - StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg) } + StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg), reasoning_content: None } } // content_block_stop / ping 等不产出 chunk - _ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }, + _ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None }, } } @@ -541,6 +544,7 @@ impl LlmProvider for AnthropicCompatProvider { model: resp.model, usage, tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) }, + reasoning_content: None, }) } }) @@ -808,6 +812,7 @@ mod tests { stream: false, tools: None, tool_choice: None, + reasoning_content: None, }; let body = provider.convert_request(req); // user 消息 content 应为数组形态 @@ -839,6 +844,7 @@ mod tests { stream: false, tools: None, tool_choice: None, + reasoning_content: None, }; let body = provider.convert_request(req); let user_msg = body diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index c94b8e7..cb73276 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -44,6 +44,9 @@ struct OpenAiRequest { /// 流式时请求末 chunk 携带 usage(OpenAI 官方 + DeepSeek/GLM 兼容) #[serde(skip_serializing_if = "Option::is_none")] stream_options: Option, + /// DeepSeek thinking 模式 + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, } /// OpenAI 消息格式 @@ -58,6 +61,9 @@ struct OpenAiMessage { tool_call_id: Option, #[serde(skip_serializing_if = "Option::is_none")] tool_calls: Option>, + /// DeepSeek thinking 模式推理内容 + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, } /// OpenAI 同步响应 @@ -78,6 +84,9 @@ struct OpenAiChoice { struct OpenAiMessageResp { content: Option, tool_calls: Option>, + /// DeepSeek thinking 模式响应中的推理内容 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, } #[derive(Debug, Deserialize)] @@ -120,6 +129,9 @@ struct OpenAiStreamChoice { struct OpenAiStreamDelta { content: Option, tool_calls: Option>, + /// DeepSeek thinking 模式流式推理内容 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, } #[derive(Debug, Deserialize)] @@ -158,6 +170,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) tool_calls: None, usage: usage_accum.take(), error: None, + reasoning_content: None, }; } @@ -195,6 +208,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) tool_calls, usage: None, error: None, + reasoning_content: choice.delta.reasoning_content, } } else { // choices 为空 = usage-only chunk,不输出文本(usage 已累积) @@ -204,6 +218,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) tool_calls: None, usage: None, error: None, + reasoning_content: None, } } } @@ -215,6 +230,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) tool_calls: None, usage: None, error: None, + reasoning_content: None, } } } @@ -371,6 +387,7 @@ impl OpenAICompatProvider { content, tool_call_id: m.tool_call_id, tool_calls, + reasoning_content: m.reasoning_content, } }) .collect(); @@ -389,6 +406,7 @@ impl OpenAICompatProvider { stream: req.stream, tools, tool_choice: req.tool_choice, + reasoning_content: req.reasoning_content, // 流式请求末 chunk 带 usage(同步调用 complete 不需要) stream_options: if req.stream { Some(serde_json::json!({ "include_usage": true })) @@ -509,6 +527,7 @@ impl LlmProvider for OpenAICompatProvider { model: body.model, usage, tool_calls, + reasoning_content: choice.message.reasoning_content, }) } }) @@ -755,6 +774,7 @@ mod tests { stream: false, tools: None, tool_choice: None, + reasoning_content: None, }; let out = provider.convert_request(req); let msg = &out.messages[0]; @@ -783,6 +803,7 @@ mod tests { stream: false, tools: None, tool_choice: None, + reasoning_content: None, }; let out = provider.convert_request(req); let msg = &out.messages[0]; diff --git a/crates/df-nodes/src/ai_node.rs b/crates/df-nodes/src/ai_node.rs index 618ce67..0d93fce 100644 --- a/crates/df-nodes/src/ai_node.rs +++ b/crates/df-nodes/src/ai_node.rs @@ -283,6 +283,7 @@ impl Node for AiNode { stream: false, tools: None, tool_choice: None, + reasoning_content: None, }; tracing::info!( @@ -513,6 +514,7 @@ impl Node for AiSelfReviewNode { stream: false, tools: None, tool_choice: None, + reasoning_content: None, }; tracing::info!( @@ -924,6 +926,7 @@ mod tests { stream: false, tools: None, tool_choice: None, + reasoning_content: None, }; let response = provider.complete(request).await.expect("GLM 调用失败"); assert!(!response.text.is_empty(), "GLM 返回空文本"); diff --git a/docs/todo.md b/docs/todo.md index f1e3fd9..f8d22a8 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -115,6 +115,33 @@ **已审文件清单(本轮)**:crates/df-ai-core/src/provider.rs · crates/df-ai/src/anthropic_compat.rs · crates/df-ai/src/openai_compat.rs · src-tauri/src/commands/ai/conversation.rs · src-tauri/src/commands/ai/commands.rs · src/api/ai.ts · src/api/types.ts · src/composables/ai/useAiSend.ts · src/composables/ai/useAiConversations.ts · src/stores/ai.ts · src/components/AiChat.vue(图片构建+渲染) · src/components/settings/ProviderPanel.vue **新登记 todo**:B-260617-07 · B-260617-08 +### 🔧 2026-06-17 Tasks.vue i18n 缺失(仅分析·未实施) + +> Tasks.vue:72 报 `Not found 'completed' key in 'zh' locale messages`。session-role-diagnose-only,仅分析+记todo。 + +**现象**:Tasks.vue:72 `$t(statusLabel(task.status))` → `taskStatusLabel()` → `TASK_STATUS_LABELS[status]` 返回 `'tasks.status.done'` → `$t('tasks.status.done')` 在 en/zh-CN 均未定义 → intlify fallback 到 en 仍缺失。 + +**根因**:`src/constants/project.ts:61-68` 定义了 `TASK_STATUS_LABELS`(值如 `'tasks.status.done'`/`'tasks.status.todo'` 等 7 个 status + `'tasks.statusFilter.done'` 等 5 个 filter + priority 相关),但 **en/zh-CN 的 tasks 相关 i18n 文件完全不存在**。6b67214 大量新增功能时未同步补 tasks i18n key。 + +**影响范围**(需补全的 key): +| 组件 | 用到的 key 前缀 | 缺失的 key | +|---|---|---| +| Tasks.vue:72 | `tasks.status.*` (7个) | 全部 | +| Tasks.vue:148-155 | `tasks.statusFilter.*` (5个) | 全部 | +| Tasks.vue:96-103 | `tasks.priority.*` / `P0`/`P1`/`P2`/`P3` (5+4=9) | 全部 | + +- [ ] B-260617-10 [P2] — **Tasks.vue i18n 缺失**。`$t('tasks.status.done')` 等 **21 个翻译键**在 en/zh-CN 均未定义(intlify fallback 也找不到),根因:6b67214 新增大量功能时未同步补 tasks i18n key(`TASK_STATUS_LABELS` 定义了值但无对应 `$t()` 条目)。**影响**:Tasks.vue 状态标签/筛选器/优先级标签全部显示原始 key 而非翻译文本。—— src/views/Tasks.vue(:72) · src/constants/project.ts(:61-82 TASK_STATUS_LABELS/TASK_STATUS_CLASS/PRIORITY_LABELS/PRIORITY_CLASSES) + +> **2026-06-17 进展注记(1cd7652)**:提交 1cd7652 已做**数据层映射兜底**(project.ts 新增 `LEGACY_STATUS_MAP`:completed→done / review_ready→in_review / merged→done / abandoned→cancelled),`taskStatusLabel/taskStatusClass` 经映射后老 DB 脏态不再直接走 `$t()` 报 not found。**但 i18n key 补全(显示层)仍未做**——映射后 `$t('tasks.status.done')` 仍 not found(tasks.ts 不存在),用户从看到 "completed" 变成看到 "tasks.status.done" 原始 key。**B-260617-10 主问题(补全 21 个 i18n key)仍在**,1cd7652 是其前置的数据归一,待补 key 后老态自动正确显示。映射目标态(done/in_review/cancelled)均已核验在 TASK_STATUS_LABELS 7 态内 ✓。 + +### 🔧 2026-06-17 走查·tauri 打包目标收窄 + 状态映射 DRY(仅分析·未实施) + +> 本轮 git diff 核验工作区未提交改动 + 最新提交 1cd7652。session-role-diagnose-only。 + +- [ ] B-260617-11 [P2] — **tauri.conf.json 打包目标收窄未提交,若误入库锁死非 Windows 构建**。工作区改动 `bundle.targets: "all" → ["nsis"]`(src-tauri/tauri.conf.json:28),收窄到仅 Windows NSIS 安装包。若意图为本地只打 Windows 包,合理;**但若随其他改动一并提交**,macOS(dmg/app)、Linux(deb/appimage)构建将不可用,影响其他开发者/CI。**确认点**:该改动是临时本地构建还是有意入库?临时则建议提交前 revert 此行;有意则建议改为按平台条件配置而非硬编码单一 target。—— src-tauri/tauri.conf.json(:28) + +- [ ] B-260617-12 [P3·可选] — **LEGACY_STATUS_MAP 映射 DRY 重复**。1cd7652 中 `taskStatusLabel`(:96) 与 `taskStatusClass`(:101) 各写一遍 `const mapped = LEGACY_STATUS_MAP[status] ?? status`。两处一行重复,可抽 `mapLegacyStatus(status)` 收敛。极简优先可不动(仅 2 处 + 单行),登记备查。—— src/constants/project.ts(:96,:101) + ### 🔧 2026-06-17 aichat 消息全量重叠(5角度深入分析·未实施) > 用户报 aichat 对话「大量重叠」「不单间距问题」。session-role-diagnose-only,5 角度并行论证后记录。 diff --git a/docs/待决策.md b/docs/待决策.md index 56fdd15..e024f9b 100644 --- a/docs/待决策.md +++ b/docs/待决策.md @@ -79,6 +79,42 @@ - **待用户操作**:部分场景运行时实测(A 路线场景 2/3)。 - **状态**:🟡 待用户实测 +### 🟡 待设计/架构演进(有决策点,需方案设计或长期规划) + +#### ARC-260615-07 架构重构批(排期/优先级决策) +- **背景**:src-tauri IPC 编排层 5711 行成事实业务层,7 项架构债。df-core→df-types 已完成(CR-61),剩 6 项独立大改。 +- **决策点**:6 项重构何时做/优先级/是否做(每项 ROI 与风险权衡,无法纯技术论证——做不做是资源/产品取舍) +- **选项(剩余 6 项)**: + - a: df-app 抽取(IPC 编排层 5711 行独立 crate) + - b: AI agent loop 从 IPC 下沉 df-ai(逻辑与 IO 分离) + - c: 类型契约 ts-rs 代码生成(替代手写 types.ts) + - d: AiSession 多会话(B 路线前置,关联 S-260614-01) + - e: AppState 分组(5711 行 state 拆分) + - f: IPC 命名统一 +- **推荐**:**⏸️ 缓做**(重构低 ROI,当前功能优先;待稳定后按 d→b→a 顺序,d 是 B 路线前置可先) +- **关联**:todo ARC-260615-07 / memory aichat-arch-extensibility +- **状态**:🟡 待排期决策 + +#### UX-260617-01 aichat 消息全量重叠(待 DevTools 定位根因) +- **背景**:用户实测消息大量重叠堆叠。5 角度分析排除 CSS/scoped/DOM,核心嫌疑虚拟滚动 IO 半激活态不一致 + height=0 未设 minHeight → slot 塌 0 → 重叠。 +- **决策点**:根因确切触发路径(需 DevTools 实测,静态分析已到极限) +- **选项**: + - a: 虚拟滚动 IO/RO 时序竞态(主嫌疑,unload 分支 minHeight 兜底修复) + - b: 其他(DOM 结构问题,需 DevTools 实查) +- **推荐**:**待用户 DevTools 实测**(shouldRenderMsg 卸载时检查 slot 实际高度 + IO unobserve/observe 时序),定位后修法明确(unload 分支 minHeight fallback) +- **关联**:todo UX-260617-01 / docs/05-代码审查/AI聊天组件极端数据场景分析-2026-06-17.md +- **状态**:🟡 待 DevTools 验证根因 + +#### UX-260617-28 双监听器同通道(长期架构演进) +- **背景**:useAiEvents + useAiContext 各自 listen('ai-chat-event'),人工 AiCompressing flag 协调防双重处理,非架构保证。未来新增事件处理可能触发双重 bug。 +- **决策点**:是否升级单一分发器模式(架构保证) +- **选项**: + - a: 保持现状(AiCompressing flag 协调够用) + - b: 单一分发器(架构保证,但大改) +- **推荐**:**⏸️ 暂缓**(当前 flag 协调有效,未来新增事件处理触发双重 bug 时再升级) +- **关联**:todo UX-260617-28 [INFO] +- **状态**:⏸️ 长期(INFO,当前够用) + --- ## 已决归档 diff --git a/src-tauri/src/commands/ai/agentic.rs b/src-tauri/src/commands/ai/agentic.rs index b4ac4d7..67754ba 100644 --- a/src-tauri/src/commands/ai/agentic.rs +++ b/src-tauri/src/commands/ai/agentic.rs @@ -131,6 +131,8 @@ enum StreamOutcome { usage: df_ai::provider::TokenUsage, incomplete: bool, resolved_model: String, + /// DeepSeek thinking 模式推理内容(需回传到下一轮请求) + reasoning_content: Option, }, InitFailedExhausted, Fatal, @@ -164,6 +166,7 @@ async fn stream_one_provider( retry_deadline: tokio::time::Instant, model_override: &Option, agentic_req: &TaskRequirements, + last_reasoning_content: &Option, ) -> StreamOutcome { // resolve→ensure→build 三步(复用 secret::build_provider_for,DRY)。 // key 缺失/损坏 → Err → 归 Fatal(stream_llm Fatal 也是 key 类错,语义一致)。 @@ -209,17 +212,19 @@ async fn stream_one_provider( stream: true, tools: if tool_defs.is_empty() { None } else { Some(tool_defs.to_vec()) }, tool_choice: None, + reasoning_content: last_reasoning_content.clone(), }; match stream_llm(&*provider, retry_request, app_handle, stop_flag, notify, conv_id).await { - StreamResult::Complete { text, tool_calls, usage } => { + StreamResult::Complete { text, tool_calls, usage, reasoning_content } => { return StreamOutcome::Success { text, tool_calls, usage, incomplete: false, resolved_model, + reasoning_content, }; } - StreamResult::Partial { text, tool_calls, usage } => { + StreamResult::Partial { text, tool_calls, usage, reasoning_content } => { // MidStream 保文不重试(决策 a1)。携带 incomplete=true 交调用方走保文路径。 tracing::warn!( conv_id = %conv_id, @@ -231,6 +236,7 @@ async fn stream_one_provider( text, tool_calls, usage, incomplete: true, resolved_model, + reasoning_content, }; } StreamResult::InitFailed { retryable } => { @@ -435,6 +441,9 @@ pub(crate) async fn run_agentic_loop( // 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常 let mut converged = false; + // BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传 + let mut last_reasoning_content: Option = None; + // F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变), // 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。 let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); @@ -656,9 +665,9 @@ pub(crate) async fn run_agentic_loop( let mut candidate_chain: Vec = Vec::with_capacity(1 + candidates.len()); candidate_chain.push(provider_config.clone()); candidate_chain.extend(candidates.iter().cloned()); - let (full_text, tool_calls_acc, round_usage, incomplete) = { + let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content) = { // outcome 累积成功结果(Complete/Partial),InitFailed 不写入。 - let mut outcome: Option<(String, std::collections::HashMap, df_ai::provider::TokenUsage, bool)> = None; + let mut outcome: Option<(String, std::collections::HashMap, df_ai::provider::TokenUsage, bool, Option)> = None; 'candidate: for candidate in &candidate_chain { // per-provider permit(可选):set_provider_caps 未配置时返回 None(单 provider 零变化); @@ -678,14 +687,16 @@ pub(crate) async fn run_agentic_loop( retry_deadline, &model_override, &agentic_req, + &last_reasoning_content, ).await { - StreamOutcome::Success { text, tool_calls, usage, incomplete, resolved_model: m } => { + StreamOutcome::Success { text, tool_calls, usage, incomplete, resolved_model: m, reasoning_content: rc } => { // 成功:更新迭代级 resolved_model + provider_config(供后续 push/save/标题)。 // mut 解构(已在顶部声明 mut):本轮后续 save/push 用「实际成功所用 provider」 // 而非主 candidate。compress 已在本轮 stream 之前用过 primary,不受影响。 resolved_model = m; provider_config = candidate.clone(); - outcome = Some((text, tool_calls, usage, incomplete)); + last_reasoning_content = rc; + outcome = Some((text, tool_calls, usage, incomplete, last_reasoning_content.clone())); break 'candidate; } StreamOutcome::InitFailedExhausted => { @@ -750,6 +761,8 @@ pub(crate) async fn run_agentic_loop( if !full_text.is_empty() { let mut msg = ChatMessage::assistant(&full_text); msg.model = Some(resolved_model.clone()); + // BUG-260617-12: MidStream 保文也回填 reasoning_content + msg.reasoning_content = round_reasoning_content.clone(); session.messages.push(msg); // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制) let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。"); @@ -807,10 +820,13 @@ pub(crate) async fn run_agentic_loop( .collect(); let mut msg = ChatMessage::assistant_with_tools(&full_text, ai_tool_calls); msg.model = Some(resolved_model.clone()); + // BUG-260617-12: 回填 reasoning_content 供下一轮请求透传 + msg.reasoning_content = last_reasoning_content.clone(); session.messages.push(msg); } else if !full_text.is_empty() { let mut msg = ChatMessage::assistant(&full_text); msg.model = Some(resolved_model.clone()); + msg.reasoning_content = last_reasoning_content.clone(); session.messages.push(msg); } } diff --git a/src-tauri/src/commands/ai/compress.rs b/src-tauri/src/commands/ai/compress.rs index 67ba2fb..fa73c1b 100644 --- a/src-tauri/src/commands/ai/compress.rs +++ b/src-tauri/src/commands/ai/compress.rs @@ -77,6 +77,7 @@ pub(crate) async fn compress_via_llm( stream: false, tools: None, tool_choice: None, + reasoning_content: None, }; // LLM 并发限流(压缩属独立调用,纳入双层 Semaphore) diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 311fe72..b00a201 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -357,6 +357,7 @@ async fn extract_knowledge_from_conversation( stream: false, tools: None, tool_choice: None, + reasoning_content: None, }; let provider: Box = match super::secret::build_provider_for(provider_config) { diff --git a/src-tauri/src/commands/ai/stream_recv.rs b/src-tauri/src/commands/ai/stream_recv.rs index 91de8d1..5751e8f 100644 --- a/src-tauri/src/commands/ai/stream_recv.rs +++ b/src-tauri/src/commands/ai/stream_recv.rs @@ -101,6 +101,8 @@ pub(crate) enum StreamResult { text: String, tool_calls: HashMap, usage: df_ai::provider::TokenUsage, + /// DeepSeek thinking 模式推理内容(需回传到下一轮请求) + reasoning_content: Option, }, /// 流中途断(MidStream chunk Err / idle timeout / provider stream-error / 有 partial_text /// 但流尽未收到 finished)。已 emit 过 AiTextDelta(前端 currentText 已累积), @@ -110,6 +112,8 @@ pub(crate) enum StreamResult { text: String, tool_calls: HashMap, usage: df_ai::provider::TokenUsage, + /// DeepSeek thinking 模式推理内容(部分累积) + reasoning_content: Option, }, /// Init 失败(provider.stream() Err:建连/鉴权/HTTP non-2xx)。已 emit AiError。 /// `retryable`: 据状态码分类(retry::is_status_retryable 镜像):true=5xx/429/timeout/connect @@ -154,6 +158,8 @@ pub(crate) async fn stream_llm( let mut finished_received = false; let mut stopped = false; let mut final_usage: Option = None; + // BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传) + let mut reasoning_content_acc: Option = None; // B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器 // (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。 @@ -208,6 +214,7 @@ pub(crate) async fn stream_llm( text: full_text, tool_calls: tool_calls_acc, usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, }; } Ok(None) => break, // 流正常结束 @@ -231,6 +238,10 @@ pub(crate) async fn stream_llm( if let Some(u) = &chunk.usage { final_usage = Some(u.clone()); } + // BUG-260617-12: DeepSeek thinking 模式推理内容累积 + if let Some(ref rc) = chunk.reasoning_content { + reasoning_content_acc.get_or_insert_with(String::new).push_str(rc); + } // provider 流式错误事件(Anthropic SSE `type=="error"` 等): // UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial), // 不重试。空文本无文可保,emit AiError + InitFailed(保守 retryable=true, @@ -260,6 +271,7 @@ pub(crate) async fn stream_llm( text: full_text, tool_calls: tool_calls_acc, usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, }; } if chunk.finished { @@ -301,6 +313,7 @@ pub(crate) async fn stream_llm( text: full_text, tool_calls: tool_calls_acc, usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, }; } } @@ -333,6 +346,7 @@ pub(crate) async fn stream_llm( text: full_text, tool_calls: tool_calls_acc, usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, }; } @@ -358,6 +372,7 @@ pub(crate) async fn stream_llm( text: full_text, tool_calls: tool_calls_acc, usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, }; } @@ -366,6 +381,7 @@ pub(crate) async fn stream_llm( text: full_text, tool_calls: tool_calls_acc, usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, } } Err(e) => { diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index 109c039..72fc86b 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -55,6 +55,7 @@ pub(crate) async fn ensure_conversation_title( tool_calls: None, model: None, status: None, + reasoning_content: m.reasoning_content.clone(), }) .collect(); (summary, session.messages.all_messages_clone()) @@ -134,6 +135,7 @@ async fn generate_title_via_llm( stream: false, tools: None, tool_choice: None, + reasoning_content: None, }; // LLM 并发限流(标题生成属独立调用,纳入双层 Semaphore) let _global_permit = llm_concurrency.acquire_global().await;