修复: 流式重试N+1气泡根治(UX-260618-15第一批方案A)

- stream_recv: InitFailed加error字段,5处重试路径移除AiError emit(retryable交agentic统一emit)
- agentic: 重试loop只emit AiStreamRetry,Fatal/全candidate耗尽emit最终AiError(单气泡聚合)
- useAiEvents: AiStreamRetry改首次创建+后续更新(N+1气泡消除)
- 注释残留修正(stream_recv :251/:299 旧"emit AiError"→"返回InitFailed")
N+1独立气泡降为「1重试气泡+1最终错误气泡」
This commit is contained in:
2026-06-19 01:07:50 +08:00
parent 1dc8b12ec4
commit 0142654be3
5 changed files with 144 additions and 70 deletions

View File

@@ -343,7 +343,7 @@
- [x] ✅(workflow wwchro468 general-purpose agent 实施 6 文件 + 主代兜底核验·前端 ToolCard.vue advance_task 渲染取 r.title||r.id(:474/:891)+TASK_ID_TOOL_ARG 映射(:799)+displayArgValue task_id→title(:834)+i18n zh/en aiTool.ts;后端 audit.rs resolve_task_label(查 tasks 表 :151 三臂对齐 resolve_project_label)+build_approval_reason advance_task 分支(:173)+tool_registry.rs 删 tool_display_hint advance_task 项(:1575 让 :1549 模板生效)·主代 cargo check -p devflow EXIT 0 + vue-tsc EXIT 0 + grep 印证) **UX-260618-14 [P1]****advance_task 审批卡/结果渲染缺任务名 + 显 UUID/空白**。①审批卡 `ToolCard.vue:800` `PROJECT_ID_TOOL_ARG` 未登记 advance_task → id 裸显 UUID;②审批 reason `audit.rs:163` tool_display_hint 短路致 `tool_registry.rs:1546` 模板死代码 + `:172` 任务 id 误查 projects 表;③结果 `ToolCard.vue:477` 模板取 r.id(UUID)非 r.title(TaskRecord 有 title `models.rs:57`),"空白"=UUID 不可读非字段缺失。**修法 B(最快)**:ToolCard:477 取 r.title fallback r.id + i18n 改 `任务「{title}」已推进至 {status}`;**A1** 登记 TASK_ID_TOOL_ARG(advance_task/run_workflow)id→title 回显;**C** 删 `tool_registry.rs:1572` tool_display_hint 让 :1546 模板生效 + audit.rs 加 resolve_task_label。附带:`:1546/:1547` 模板均死代码 + audit.rs:172 任务 id 误查隐患。— `src/components/ToolCard.vue`(:474-478,800-825) + `src-tauri/src/commands/ai/{tool_registry.rs:1546/1572,audit.rs:163/172}` + `src/i18n/{zh-CN,en}/aiTool.ts` - [x] ✅(workflow wwchro468 general-purpose agent 实施 6 文件 + 主代兜底核验·前端 ToolCard.vue advance_task 渲染取 r.title||r.id(:474/:891)+TASK_ID_TOOL_ARG 映射(:799)+displayArgValue task_id→title(:834)+i18n zh/en aiTool.ts;后端 audit.rs resolve_task_label(查 tasks 表 :151 三臂对齐 resolve_project_label)+build_approval_reason advance_task 分支(:173)+tool_registry.rs 删 tool_display_hint advance_task 项(:1575 让 :1549 模板生效)·主代 cargo check -p devflow EXIT 0 + vue-tsc EXIT 0 + grep 印证) **UX-260618-14 [P1]****advance_task 审批卡/结果渲染缺任务名 + 显 UUID/空白**。①审批卡 `ToolCard.vue:800` `PROJECT_ID_TOOL_ARG` 未登记 advance_task → id 裸显 UUID;②审批 reason `audit.rs:163` tool_display_hint 短路致 `tool_registry.rs:1546` 模板死代码 + `:172` 任务 id 误查 projects 表;③结果 `ToolCard.vue:477` 模板取 r.id(UUID)非 r.title(TaskRecord 有 title `models.rs:57`),"空白"=UUID 不可读非字段缺失。**修法 B(最快)**:ToolCard:477 取 r.title fallback r.id + i18n 改 `任务「{title}」已推进至 {status}`;**A1** 登记 TASK_ID_TOOL_ARG(advance_task/run_workflow)id→title 回显;**C** 删 `tool_registry.rs:1572` tool_display_hint 让 :1546 模板生效 + audit.rs 加 resolve_task_label。附带:`:1546/:1547` 模板均死代码 + audit.rs:172 任务 id 误查隐患。— `src/components/ToolCard.vue`(:474-478,800-825) + `src-tauri/src/commands/ai/{tool_registry.rs:1546/1572,audit.rs:163/172}` + `src/i18n/{zh-CN,en}/aiTool.ts`
- [ ] **UX-260618-15 [P1]****流式失败重试(1/4 2/4 3/4)每轮独立气泡**。后端每轮重试 emit AiError(`stream_recv.rs:200/258/298/358/398` 5 处 InitFailed 必 emit),前端 `useAiEvents.ts:326` AiError case 每次 push 新气泡无去重;`AiStreamRetry`(:177)本意更新末条合并但被新 AiError 破坏 → N+1 独立气泡。`agentic.rs:262` 注释误以为 stream_llm 只 emit 一次(实多次)。**用户诉求升级**:不只是修 bug,要**聚合显示设计**(重试 1→4 轮过程聚合单气泡 + 最终结果,勿一排错误吓人)。详见 review 会话重试聚合设计方案(3 角度论证:可行性/前后端一致性/架构整合)。**修法 A(推荐)**:`stream_recv.rs` 5 处 InitFailed 移除 AiError emit(改纯返 retryable,emit 权交调用方)+ `agentic.rs` 重试 loop 只 emit AiStreamRetry、耗尽/Fatal 才 emit 最终 AiError。影响仅流前失败重试路径(Fatal/MidStream 保文/正常错误/压缩失败不受影响)。— `src-tauri/src/commands/ai/{stream_recv.rs:200/258/298/358/398,agentic.rs:207-300}` + `src/composables/ai/useAiEvents.ts:177/326` - [x] ✅(agent ux15-batch1 方案A实施 3 文件 + 主代兜底核验·stream_recv 5处重试路径移除AiError emit + agentic Fatal/耗尽单emit AiError + useAiEvents AiStreamRetry首次创建+后续更新·主代 cargo check EXIT 0 + cargo test df-ai 119 passed + vue-tsc EXIT 0 + grep核验5处emit判定 + 修正注释残留 :251/:299) **UX-260618-15 [P1] 第一批(方案A根治N+1)****流式失败重试(1/4 2/4 3/4)每轮独立气泡**N+1 降为「1 重试气泡 + 1 最终错误气泡」。**第二批(方案C 聚合气泡显示错误原因)可选增强待定**。后端每轮重试 emit AiError(`stream_recv.rs:200/258/298/358/398` 5 处 InitFailed 必 emit),前端 `useAiEvents.ts:326` AiError case 每次 push 新气泡无去重;`AiStreamRetry`(:177)本意更新末条合并但被新 AiError 破坏 → N+1 独立气泡。`agentic.rs:262` 注释误以为 stream_llm 只 emit 一次(实多次)。**用户诉求升级**:不只是修 bug,要**聚合显示设计**(重试 1→4 轮过程聚合单气泡 + 最终结果,勿一排错误吓人)。详见 review 会话重试聚合设计方案(3 角度论证:可行性/前后端一致性/架构整合)。**修法 A(推荐)**:`stream_recv.rs` 5 处 InitFailed 移除 AiError emit(改纯返 retryable,emit 权交调用方)+ `agentic.rs` 重试 loop 只 emit AiStreamRetry、耗尽/Fatal 才 emit 最终 AiError。影响仅流前失败重试路径(Fatal/MidStream 保文/正常错误/压缩失败不受影响)。— `src-tauri/src/commands/ai/{stream_recv.rs:200/258/298/358/398,agentic.rs:207-300}` + `src/composables/ai/useAiEvents.ts:177/326`
- [x] ✅(主代串行·doc-discovery 源码核验 `time.ts:44``(i18n as any).global.locale.value === 'en'`·CR-260618-24 修复闭环 + CR-260618-25 A-time 复审 ✅ 双印证·漏销账补登) **UX-260618-16 [P1🔴]****`time.ts:44` formatDate 漏 `.value` 致 en locale i18n 失效**(CR-260618-24 批2 审查发现·Agent C 铁证)。`legacy:false` composition 模式 `i18n.global.locale` 是 ref,`(i18n as any).global.locale === 'en'` 比对象===字符串**恒 false** → en locale 下 formatDate 永走 zh-CN 分支,i18n 化失效(对齐 CR-260615-08 未完成回归)。铁证:`App.vue:227`/`GeneralPanel.vue:259` 均用 `i18n.global.locale.value`,唯 time.ts:44 漏。**修法**:补 `.value``(i18n as any).global.locale.value === 'en'`(locale 取值 'en' 非 'en-US',App.vue:227 印证)。— `src/utils/time.ts:44` - [x] ✅(主代串行·doc-discovery 源码核验 `time.ts:44``(i18n as any).global.locale.value === 'en'`·CR-260618-24 修复闭环 + CR-260618-25 A-time 复审 ✅ 双印证·漏销账补登) **UX-260618-16 [P1🔴]****`time.ts:44` formatDate 漏 `.value` 致 en locale i18n 失效**(CR-260618-24 批2 审查发现·Agent C 铁证)。`legacy:false` composition 模式 `i18n.global.locale` 是 ref,`(i18n as any).global.locale === 'en'` 比对象===字符串**恒 false** → en locale 下 formatDate 永走 zh-CN 分支,i18n 化失效(对齐 CR-260615-08 未完成回归)。铁证:`App.vue:227`/`GeneralPanel.vue:259` 均用 `i18n.global.locale.value`,唯 time.ts:44 漏。**修法**:补 `.value``(i18n as any).global.locale.value === 'en'`(locale 取值 'en' 非 'en-US',App.vue:227 印证)。— `src/utils/time.ts:44`

View File

@@ -625,7 +625,7 @@
1. `Knowledge.vue submitCreate`(:438)/ `ProjectDetail.vue handleApproval`(:417) 缺显式 `if(submitting) return` 入口 guard(其余 5 处有)。实际防并发由模板 `:disabled="submitting"` 兜住(按钮点不动·无重入),IPC 路径单调用,风险极低。可选补 guard 对齐范式一致性。 1. `Knowledge.vue submitCreate`(:438)/ `ProjectDetail.vue handleApproval`(:417) 缺显式 `if(submitting) return` 入口 guard(其余 5 处有)。实际防并发由模板 `:disabled="submitting"` 兜住(按钮点不动·无重入),IPC 路径单调用,风险极低。可选补 guard 对齐范式一致性。
2. `ConnectionPanel.vue saveConn`(:152) 是同步函数(无 await),submitting try/finally 同步置位即复位,此处 submitting 实际无防双击语义(纯占位/视觉)。无功能影响,可选移除或改 async。 2. `ConnectionPanel.vue saveConn`(:152) 是同步函数(无 await),submitting try/finally 同步置位即复位,此处 submitting 实际无防双击语义(纯占位/视觉)。无功能影响,可选移除或改 async。
### CR-260619-01 UX-14 advance_task渲染缺任务名 + UX-16 time.ts销账 + UX-17 handleApprovalMulti防双击(workflow wwchro468 UX-14实施 + 主代串行 UX-16/17·主代兜底核验) — 🟡 待审 ### CR-260619-01 UX-14 advance_task渲染缺任务名 + UX-16 time.ts销账 + UX-17 handleApprovalMulti防双击(workflow wwchro468 UX-14实施 + 主代串行 UX-16/17·主代兜底核验) — ✅ 已审(PASS·巡检 2026-06-19 独立 grep/read 核验)
- **范围**: - **范围**:
- **UX-260618-14 advance_task 渲染缺任务名**(workflow wwchro468 general-purpose agent 实施 6 文件):前端 `ToolCard.vue` advance_task 渲染取 `r.title||r.id`(:474 formatToolResult / :891 toolResultSummary)+ 新增 `TASK_ID_TOOL_ARG` 映射(:799 advance_task→id / run_workflow→task_id)+ `displayArgValue` 接入 task_id→title(:834)+ i18n zh/en `aiTool.ts`(advancedTask/advancedTaskWithRounds 改 {title} + taskLabel/taskIdNotFound);后端 `audit.rs` 新增 `resolve_task_label`(查 tasks 表 :151 三臂 Ok(Some)/Ok(None)/Err 对齐 resolve_project_label)+ `build_approval_reason` advance_task 分支(:173 id 走 task 解析)+ `tool_registry.rs``tool_display_hint` 的 advance_task 项(:1575 让 :1549 模板「推进任务状态:{} → {}」生效)。 - **UX-260618-14 advance_task 渲染缺任务名**(workflow wwchro468 general-purpose agent 实施 6 文件):前端 `ToolCard.vue` advance_task 渲染取 `r.title||r.id`(:474 formatToolResult / :891 toolResultSummary)+ 新增 `TASK_ID_TOOL_ARG` 映射(:799 advance_task→id / run_workflow→task_id)+ `displayArgValue` 接入 task_id→title(:834)+ i18n zh/en `aiTool.ts`(advancedTask/advancedTaskWithRounds 改 {title} + taskLabel/taskIdNotFound);后端 `audit.rs` 新增 `resolve_task_label`(查 tasks 表 :151 三臂 Ok(Some)/Ok(None)/Err 对齐 resolve_project_label)+ `build_approval_reason` advance_task 分支(:173 id 走 task 解析)+ `tool_registry.rs``tool_display_hint` 的 advance_task 项(:1575 让 :1549 模板「推进任务状态:{} → {}」生效)。
@@ -635,6 +635,42 @@
- **审查要点(供审查 agent)**:① UX-14 行为变更——审批 reason 文案(advance_task 从静态串「推进任务状态」→ 含任务标题+target_status)+ audit resolve 查 tasks 表(原误查 projects 恒报「项目已不存在」);② resolve_task_label 三臂语义对齐 resolve_project_label(空 id/Ok(None)/Err 分流);③ tool_display_hint 删 advance_task 后 run_workflow 保留轻量命中(模板 task_id 走通用裸显,本批不动);④ 跨项目任务 taskNameById 查 projectStore.tasks 查不到降级「任务不在当前列表」(非硬错);⑤ UX-17 handleApprovalMulti submitting 对齐 handleApproval 防双击。 - **审查要点(供审查 agent)**:① UX-14 行为变更——审批 reason 文案(advance_task 从静态串「推进任务状态」→ 含任务标题+target_status)+ audit resolve 查 tasks 表(原误查 projects 恒报「项目已不存在」);② resolve_task_label 三臂语义对齐 resolve_project_label(空 id/Ok(None)/Err 分流);③ tool_display_hint 删 advance_task 后 run_workflow 保留轻量命中(模板 task_id 走通用裸显,本批不动);④ 跨项目任务 taskNameById 查 projectStore.tasks 查不到降级「任务不在当前列表」(非硬错);⑤ UX-17 handleApprovalMulti submitting 对齐 handleApproval 防双击。
- **关联**:todo UX-260618-14/16/17 销账。 - **关联**:todo UX-260618-14/16/17 销账。
**复审结论(2026-06-19·巡检独立 grep/read 核验当前源码形态·commit 1dc8b12 已落地·不信声明)**: ✅ **PASS** — 🔴0 🟡0 ⚪1
**逐项核验表(file:line 佐证 + 判定)**:
| 项 | 核验点 | 佐证 | 判定 |
|---|---|---|---|
| A-audit | resolve_task_label 三臂对齐 resolve_project_label | `audit.rs:151-163`:空 id→空串 / Ok(Some)→「标题」(id=x) / Ok(None)→任务已不存在 / Err→warn+裸 id。与 `resolve_project_label:131-144` 同构 | ✅ |
| A-build_reason | advance_task 的 id 特化走 task 表 | `audit.rs:194` `"id" if tool_name=="advance_task" => resolve_task_label(db,&val)`。其余 `"id"\|"project_id"` 仍走 resolve_project_label(:195) | ✅ |
| A-tool_registry | 删 tool_display_hint advance_task 轻量命中 | `tool_registry.rs:1575-1576` 注释「advance_task 不在此轻量映射」;`:1549` display_hint_for_tool 模板「推进任务状态:{id}→{target_status}」生效 | ✅ |
| A-TaskRecord | 返回值含 title(非假修复) | `df-storage/models.rs:54-57` TaskRecord `pub title:String`;`task_advance_node.rs:43-47` advance_task_atomic→Result<TaskRecord>;serde 默认序列化 title,前端 r.title 可读 | ✅ |
| B-formatToolResult | advance_task 优先显 title 回退 id | `ToolCard.vue:474-478` formatToolResult `r.title\|\|r.id`;review_rounds>0 走 advancedTaskWithRounds,=0 走 advancedTask | ✅ |
| B-toolResultSummary | header 摘要与 body 对齐 | `ToolCard.vue:921-924` 同 formatToolResult 双路径覆盖 | ✅ |
| B-displayArgValue | task_id→title 无 project 冲突 | `ToolCard.vue:824` TASK_ID_TOOL_ARG{advance_task:'id',run_workflow:'task_id'};`:811-815` PROJECT_ID_TOOL_ARG **不含 advance_task** → :835 projectArgKey 为 undefined 跳过 → :845 taskArgKey 命中;`:806` taskNameById 查 projectStore.tasks 降级 taskIdNotFound | ✅ |
| B-i18n | 中英对称 + message 无特殊字符 | `zh-CN/aiTool.ts:63-104` + `en/aiTool.ts:62-102` advancedTask/advancedTaskWithRounds/taskLabel/taskIdNotFound/workflowTriggered 全对称;message 仅 `{title}{status}{n}{id}{exec}` 标准插值,无 `@/`/裸 `{}`(prod runtime 盲点安全) | ✅ |
| C-time | formatDate .value(CR-24/25 闭环) | `time.ts:44` `(i18n as any).global.locale.value === 'en'`。composition 模式 ref 读 .value,en locale 现生效 | ✅ |
| D-ProjectDetail | handleApprovalMulti submitting 防双击 | `ProjectDetail.vue:431-440` async + try/finally(submitting=true :433 / finally=false :440);模板 `:198 :disabled="submitting\|\|multiDecisions.length===0"` 联动;对齐单选 handleApproval `:417-425` | ✅ |
**对抗核验印证**:
- **run_workflow 审批 reason 不含任务名?** ✅ 非遗漏:`tool_display_hint:1577` run_workflow 轻量命中「触发工作流」(动词级,在 `build_approval_reason:184` 优先于 display_hint_for_tool 模板返回),根本不进模板分支,故 task_id 无裸显问题。review 要点③已声明「run_workflow 本批不动」,advance_task 是 UX-14 唯一范围。
- **advance_task(Medium risk)是否走审批触发 build_approval_reason?** Medium 既有审批阈值逻辑非本批改动;无论审批卡触发与否,前端 formatToolResult/toolResultSummary(执行结果渲染)双路径均消费 title,UX-14 修复在审批卡 + 结果卡两处均生效。
- **i18n message prod 编译盲点?** ✅ memory [[i18n-message-compile-blindspot]] 教训落实:grep message 值全部标准 `{var}` 插值,无字面 `@/`/`|`/裸 `{}`
- **PROJECT_ID_TOOL_ARG / advance_task key 冲突?** ✅ advance_task 不在 PROJECT_ID_TOOL_ARG(:811-815 仅 4 个 *_project),displayArgValue 项目分支短路跳过,无 advance_task.id 被误判为 project_id 风险。
- **⚪ WATCH-1**: run_workflow 审批 reason 走轻量「触发工作流」动词(不含 task 名/target_status),UX-14 范围外;若后续需 run_workflow reason 显任务名,需同时从 tool_display_hint :1577 移除 run_workflow(让 display_hint_for_tool :1550 模板生效)+ build_approval_reason task_id key 特化走 resolve_task_label。当前接受动词级。
- **待修项回流 todo**: **无** 🔴/🟡 项
### CR-260619-02 UX-260618-15 第一批 流式重试N+1气泡根治·方案A(agent ux15-batch1 实施 + 主代兜底核验 + 注释修正) — 🟡 待审
- **范围**(3 文件 + 主代注释修正):
- **stream_recv.rs**:`InitFailed` 变体加 `error: String` 字段(:125);5 处重试路径 AiError emit 全移除(idle timeout :203 / stream-error :262 / chunk Err :303 / 流尽无 finished :362 / provider.stream() Err :402),retryable=true 重试路径 emit 权交 agentic,retryable=false Fatal 也交 agentic 统一 emit。**主代修正注释残留** :251/:299(旧"emit AiError"→"返回 InitFailed")。
- **agentic.rs**:重试 loop 解构 `InitFailed{retryable,error}`;Fatal 分支(:726)+ 全 candidate 耗尽(:748)新增 emit AiError(单气泡聚合,Network 分类);保留 ensure_resolved_key Auth(:401)/压缩失败(:618)/try_continue provider 缺失(:1058)emit。
- **useAiEvents.ts**:`AiStreamRetry`(:177)改首次创建+后续更新(末条非 isError 创建新错误气泡 / 是 isError 更新 content);AiError push 保留(耗尽/Fatal 最终)。
- **验证(主代兜底,不信 agent 自报)**:cargo check -p devflow EXIT 0 + cargo test -p df-ai **119 passed** + vue-tsc EXIT 0 + grep 核验 5 处 emit 移除判定 + 注释修正。
- **审查要点(供审查 agent)**:① N+1 根治——重试过程不再每轮 push AiError,改 AiStreamRetry 单气泡(首次创建+更新),耗尽/Fatal emit 1 最终 AiError(N+1→1重试+1最终);② stream_recv 5 处移除判定(retryable=true 重试路径移除 / Fatal 交 agentic);③ agentic Fatal/耗尽 emit 单气泡(Network 分类);④ **riskNote error_type 统一 Network**——401 鉴权 Fatal 原本 error_type=None(前端 canOpenSettings 只认 {auth,provider_config} 本不显「去设置」),现 Network 也不显,**不恶化既有行为**(记 low WATCH:理想 401 显 auth 按钮需单独做);⑤ InitFailed 枚举加 error 字段 pub(crate) 内部无破坏。
- **关联**:todo UX-260618-15 第一批销账;第二批(方案C 聚合气泡显示错误原因)可选增强待定。
--- ---
## 已审归档 ## 已审归档

View File

@@ -119,12 +119,14 @@ impl Drop for GeneratingGuard {
/// - `InitFailedExhausted`:流前 `InitFailed{retryable=true}` 在本 provider 上重试 /// - `InitFailedExhausted`:流前 `InitFailed{retryable=true}` 在本 provider 上重试
/// 耗尽(或预算 30s 耗尽)。retryable=true 即「瞬态错误,换 provider 可能成功」→ /// 耗尽(或预算 30s 耗尽)。retryable=true 即「瞬态错误,换 provider 可能成功」→
/// 外层 `continue` 切下一 candidate(重建 build_provider + resolved_model 重算)。 /// 外层 `continue` 切下一 candidate(重建 build_provider + resolved_model 重算)。
/// 携带 `error`(最后一条诊断文本),供外层「全 candidate 耗尽」时 emit 最终 AiError。
/// - `Fatal`:流前 `InitFailed{retryable=false}`(4xx 非429/鉴权/参数错)。立即放弃 /// - `Fatal`:流前 `InitFailed{retryable=false}`(4xx 非429/鉴权/参数错)。立即放弃
/// 整个 fallback(对齐 retry.rs Fatal 分类 + provider_pool 文档「不可重试错误 /// 整个 fallback(对齐 retry.rs Fatal 分类 + provider_pool 文档「不可重试错误
/// 立即放弃不浪费备用 provider」)。stream_llm 已 emit AiError,调用方仅做 /// 立即放弃不浪费备用 provider」)。携带 `error`,stream_one_provider 内 Fatal 分支
/// guard.reset + return。空 key(build_provider_for Err)同样归 Fatal 语义—— /// emit AiError(终态不切候选,无残留气泡风险),调用方仅做 guard.reset + return。
/// key 缺失/钥匙串损坏非瞬态,换 provider 无意义(但实际场景:外层已对 primary /// 空 key(build_provider_for Err)同样归 Fatal 语义——key 缺失/钥匙串损坏非瞬态,
/// 做了启动 Auth 早失败,故此分支多见于 secondary 配置不一致,保守 Fatal 收敛)。 /// 换 provider 无意义(但实际场景:外层已对 primary 做了启动 Auth 早失败,故此分支
/// 多见于 secondary 配置不一致,保守 Fatal 收敛)。
enum StreamOutcome { enum StreamOutcome {
Success { Success {
text: String, text: String,
@@ -135,8 +137,10 @@ enum StreamOutcome {
/// DeepSeek thinking 模式推理内容(需回传到下一轮请求) /// DeepSeek thinking 模式推理内容(需回传到下一轮请求)
reasoning_content: Option<String>, reasoning_content: Option<String>,
}, },
InitFailedExhausted, /// UX-260618-15: error 字段供外层全 candidate 耗尽时 emit 最终 AiError(单气泡聚合)。
Fatal, InitFailedExhausted { error: String },
/// UX-260618-15: error 字段供 stream_one_provider 内 Fatal 分支 emit AiError。
Fatal { error: String },
} }
/// 单 provider 流式调用 + 重试(F-260614-04b)。 /// 单 provider 流式调用 + 重试(F-260614-04b)。
@@ -177,13 +181,9 @@ async fn stream_one_provider(
let provider: Box<dyn LlmProvider> = match super::secret::build_provider_for(candidate) { let provider: Box<dyn LlmProvider> = match super::secret::build_provider_for(candidate) {
Ok(p) => p, Ok(p) => p,
Err(msg) => { Err(msg) => {
// 不复用 stream_llm 的 AiError emit(那是 HTTP 诊断),此处补一条 Auth 分类错误 // UX-260618-15: Fatal 分支 emit 移到外层(stream_one_provider 调用方 Fatal 分支统一 emit)
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { // 此处仅返回 error 文本,避免 stream_one_provider 内 emit 与外层 emit 重复(Fatal 终态单 emit)。
error: msg, return StreamOutcome::Fatal { error: msg };
error_type: Some(ErrorType::Auth),
conversation_id: Some(conv_id.to_string()),
});
return StreamOutcome::Fatal;
} }
}; };
@@ -240,7 +240,7 @@ async fn stream_one_provider(
reasoning_content, reasoning_content,
}; };
} }
StreamResult::InitFailed { retryable } => { StreamResult::InitFailed { retryable, error } => {
// Fatal(4xx 非429/鉴权/参数错)立即放弃整个 fallback。 // Fatal(4xx 非429/鉴权/参数错)立即放弃整个 fallback。
if !retryable { if !retryable {
tracing::warn!( tracing::warn!(
@@ -250,7 +250,8 @@ async fn stream_one_provider(
"[ai] 候选 {} 流前失败 Fatal(4xx/鉴权),立即放弃 fallback", "[ai] 候选 {} 流前失败 Fatal(4xx/鉴权),立即放弃 fallback",
candidate.name, candidate.name,
); );
return StreamOutcome::Fatal; // UX-260618-15: 携带 error 文本,外层 Fatal 分支 emit AiError(终态单气泡)。
return StreamOutcome::Fatal { error };
} }
let is_last = retry_attempt >= max_retries; let is_last = retry_attempt >= max_retries;
@@ -259,7 +260,9 @@ async fn stream_one_provider(
if is_last || budget_exhausted { if is_last || budget_exhausted {
// 本 candidate 重试耗尽 / 预算耗尽 → 交外层切下一 candidate。 // 本 candidate 重试耗尽 / 预算耗尽 → 交外层切下一 candidate。
// stream_llm emit AiError(每次 InitFailed 均发),故不再重复 emit // UX-260618-15: stream_llm 不再 emit AiError(retryable 重试过程 emit 权归此处)
// 此处不 emit(可能切下一 candidate 成功,emit 会留残留气泡);
// 携带 error 交外层「全 candidate 耗尽」时 emit 最后一条 AiError。
tracing::warn!( tracing::warn!(
conv_id = %conv_id, conv_id = %conv_id,
provider = %candidate.name, provider = %candidate.name,
@@ -270,7 +273,7 @@ async fn stream_one_provider(
if budget_exhausted { "预算耗尽" } else { "耗尽" }, if budget_exhausted { "预算耗尽" } else { "耗尽" },
max_retries + 1, max_retries + 1,
); );
return StreamOutcome::InitFailedExhausted; return StreamOutcome::InitFailedExhausted { error };
} }
// 退避复用 retry::backoff_delay(retry_attempt+1)(1s→2s→4s ±20% jitter)。 // 退避复用 retry::backoff_delay(retry_attempt+1)(1s→2s→4s ±20% jitter)。
@@ -301,7 +304,9 @@ async fn stream_one_provider(
// 仅 max_retries=0(无重试配置)且首试 InitFailed retryable=true 时到达此处: // 仅 max_retries=0(无重试配置)且首试 InitFailed retryable=true 时到达此处:
// 0..=0 循环首趟即 is_last → 已在循环内 return InitFailedExhausted。此为防御兜底。 // 0..=0 循环首趟即 is_last → 已在循环内 return InitFailedExhausted。此为防御兜底。
StreamOutcome::InitFailedExhausted StreamOutcome::InitFailedExhausted {
error: "AI 调用失败:重试预算耗尽(防御兜底)".to_string(),
}
} }
/// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环 /// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环
@@ -677,6 +682,10 @@ pub(crate) async fn run_agentic_loop(
let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content) = { let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content) = {
// outcome 累积成功结果(Complete/Partial),InitFailed 不写入。 // outcome 累积成功结果(Complete/Partial),InitFailed 不写入。
let mut outcome: Option<(String, std::collections::HashMap<u32, super::ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>)> = None; let mut outcome: Option<(String, std::collections::HashMap<u32, super::ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>)> = None;
// UX-260618-15: 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。
// Fatal 分支即时 emit(终态不切候选),Exhausted 分支仅记录 error 不 emit(可能切下一 candidate 成功,
// emit 会留残留气泡)。
let mut last_exhausted_error: Option<String> = None;
'candidate: for candidate in &candidate_chain { 'candidate: for candidate in &candidate_chain {
// per-provider permit(可选):set_provider_caps 未配置时返回 None(单 provider 零变化); // per-provider permit(可选):set_provider_caps 未配置时返回 None(单 provider 零变化);
@@ -708,22 +717,30 @@ pub(crate) async fn run_agentic_loop(
outcome = Some((text, tool_calls, usage, incomplete, last_reasoning_content.clone())); outcome = Some((text, tool_calls, usage, incomplete, last_reasoning_content.clone()));
break 'candidate; break 'candidate;
} }
StreamOutcome::InitFailedExhausted => { StreamOutcome::InitFailedExhausted { error } => {
// 本 candidate 重试耗尽(retryable)。切下一 candidate 继续尝试。 // 本 candidate 重试耗尽(retryable)。切下一 candidate 继续尝试。
// candidates 空(单 provider)时此即「耗尽 return」语义—— // candidates 空(单 provider)时此即「耗尽 return」语义——
// 跳出循环后 outcome 仍 None,落入下方「全耗尽 return」兜底。 // 跳出循环后 outcome 仍 None,落入下方「全耗尽 return」兜底。
// UX-260618-15: 不在此 emit AiError(可能切下一 candidate 成功留残留气泡),
// 仅记录 error 供全耗尽兜底 emit 最后一条。
tracing::warn!( tracing::warn!(
conv_id = %conv_id, conv_id = %conv_id,
provider = %candidate.name, provider = %candidate.name,
"[ai] 候选 {} 流前失败重试耗尽,F-04b 切换下一 provider", "[ai] 候选 {} 流前失败重试耗尽,F-04b 切换下一 provider",
candidate.name, candidate.name,
); );
last_exhausted_error = Some(error);
continue 'candidate; continue 'candidate;
} }
StreamOutcome::Fatal => { StreamOutcome::Fatal { error } => {
// Fatal(4xx 非429/鉴权/参数错):立即放弃整轮 fallback。 // Fatal(4xx 非429/鉴权/参数错):立即放弃整轮 fallback。
// stream_llm emit AiError,仅做 guard.reset + return // UX-260618-15: stream_llm 不再 emit AiError,此处统一 emit 最终错误气泡(单气泡聚合)
guard.reset().await; guard.reset().await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error,
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.clone()),
});
return; return;
} }
} }
@@ -733,13 +750,20 @@ pub(crate) async fn run_agentic_loop(
Some(r) => r, Some(r) => r,
None => { None => {
// 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。 // 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。
// 各 candidate 的 stream_llm emit 最后一条 AiError,此处仅复位退出。 // UX-260618-15: stream_llm 不再 emit AiError,此处 emit 最后一个 Exhausted candidate 的
// 诊断文本作为最终错误气泡(单气泡聚合,替代原各 candidate emit 多气泡)。
tracing::warn!( tracing::warn!(
conv_id = %conv_id, conv_id = %conv_id,
candidates_tried = candidate_chain.len(), candidates_tried = candidate_chain.len(),
"[ai] 全 provider 候选流前失败重试耗尽,放弃本轮", "[ai] 全 provider 候选流前失败重试耗尽,放弃本轮",
); );
guard.reset().await; guard.reset().await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: last_exhausted_error
.unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.clone()),
});
return; return;
} }
} }

View File

@@ -10,7 +10,7 @@ use tracing::warn;
use df_ai::provider::{CompletionRequest, LlmProvider}; use df_ai::provider::{CompletionRequest, LlmProvider};
use super::{AiChatEvent, ErrorType, ToolCallDraft}; use super::{AiChatEvent, ToolCallDraft};
/// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。 /// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。
/// ///
@@ -115,22 +115,27 @@ pub(crate) enum StreamResult {
/// DeepSeek thinking 模式推理内容(部分累积) /// DeepSeek thinking 模式推理内容(部分累积)
reasoning_content: Option<String>, reasoning_content: Option<String>,
}, },
/// Init 失败provider.stream() Err建连/鉴权/HTTP non-2xx)。已 emit AiError /// Init 失败provider.stream() Err建连/鉴权/HTTP non-2xx,或流建立后空文本各类中断)
/// UX-260618-15: **不再 emit AiError**——重试可恢复路径的 emit 权交调用方(agentic.rs),
/// 由 agentic 在重试耗尽/Fatal 时统一 emit 最终 AiError(单气泡),避免 N 次重试 push N+1 气泡。
/// `retryable`: 据状态码分类(retry::is_status_retryable 镜像)true=5xx/429/timeout/connect /// `retryable`: 据状态码分类(retry::is_status_retryable 镜像)true=5xx/429/timeout/connect
/// 可重试false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。 /// 可重试false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。
InitFailed { retryable: bool }, /// `error`: 已格式化的错误文本(fmt_diag 拼接 provider 标识 + 上下文 + 状态 + 原文),
/// 供 agentic 重试耗尽/Fatal 时 emit AiError 用,保留原诊断信息不丢失。
InitFailed { retryable: bool, error: String },
} }
/// 流式接收 LLM 响应。 /// 流式接收 LLM 响应。
/// ///
/// 三类异常处理(返回 StreamResult 显式区分出口): /// 三类异常处理(返回 StreamResult 显式区分出口):
/// - Init 失败provider.stream() Erremit AiError + 返回 InitFailed{retryable}。 /// - Init 失败provider.stream() ErrUX-260618-15 **不 emit AiError**,返回
/// retryable 据状态码分类(retry::is_status_retryable 镜像)5xx/429/timeout/connect=true /// InitFailed{retryable, error}。retryable 据状态码分类(retry::is_status_retryable 镜像)
/// 4xx(非429)/鉴权/参数错=false Fatal。调用方走流前重试(仅 retryable=true,≤max_retries 次, /// 5xx/429/timeout/connect=true4xx(非429)/鉴权/参数错=false Fatal。调用方走流前重试(仅
/// 复用 retry::backoff_delay 退避 + 30s 总预算) /// retryable=true,≤max_retries 次, 复用 retry::backoff_delay 退避 + 30s 总预算),重试耗尽或
/// Fatal 时由调用方 emit 最终 AiError(error 字段携带诊断文本,单气泡聚合)。
/// - MidStream 失败(流已建立后断/timeout/err 事件/有 partial_text 但流尽未 finished /// - MidStream 失败(流已建立后断/timeout/err 事件/有 partial_text 但流尽未 finished
/// **不 emit AiError**,返回 Partial{...} 保文。调用方不重试,入库 + AiCompleted(incomplete) /// **不 emit AiError**,返回 Partial{...} 保文。调用方不重试,入库 + AiCompleted(incomplete)
/// + 系统提示网络中断。空文本仍 emit AiError + InitFailed{retryable=true}(无文可保,交重试)。 /// + 系统提示网络中断。空文本仍返回 InitFailed{retryable=true}(无文可保,交重试,emit 权归调用方)。
/// - 用户停止stop_flag返回 Completeincomplete 语义非异常中断)。 /// - 用户停止stop_flag返回 Completeincomplete 语义非异常中断)。
/// ///
/// 心跳与停止响应B-260615-02 / B-260615-04单 `tokio::select!` 三分支 /// 心跳与停止响应B-260615-02 / B-260615-04单 `tokio::select!` 三分支
@@ -180,7 +185,7 @@ pub(crate) async fn stream_llm(
} }
// 三分支 select!(B-260615-02 + B-260615-04 合并): // 三分支 select!(B-260615-02 + B-260615-04 合并):
// 1) stream.next():正常 chunk(idle timeout 120s 包裹,真断仍 emit AiError) // 1) stream.next():正常 chunk(idle timeout 120s 包裹,真断返回 InitFailed 交调用方处理)
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog // 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
// 3) stop_notify.notified():用户停止即时打断 // 3) stop_notify.notified():用户停止即时打断
// //
@@ -195,14 +200,13 @@ pub(crate) async fn stream_llm(
Err(_elapsed) => { Err(_elapsed) => {
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1:
// idle timeout 属 MidStream 类失败——已有文本则保文(Partial),不重试。 // idle timeout 属 MidStream 类失败——已有文本则保文(Partial),不重试。
// 空文本无文可保,emit AiError + InitFailed{retryable=true}(timeout 瞬态可重试)。 // 空文本无文可保,UX-260618-15: 不再 emit AiError(retryable=true 重试路径,
// emit 权交 agentic 重试耗尽时统一发,避免 N 次重试 N+1 气泡)。
if full_text.is_empty() && tool_calls_acc.is_empty() { if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { return StreamResult::InitFailed {
retryable: true,
error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(), error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(),
error_type: Some(ErrorType::Timeout), };
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
} }
warn!( warn!(
provider = %provider.name(), provider = %provider.name(),
@@ -244,7 +248,7 @@ pub(crate) async fn stream_llm(
} }
// provider 流式错误事件Anthropic SSE `type=="error"` 等): // provider 流式错误事件Anthropic SSE `type=="error"` 等):
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial), // UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
// 不重试。空文本无文可保,emit AiError + InitFailed(保守 retryable=true, // 不重试。空文本无文可保,返回 InitFailed(保守 retryable=true,
// err_msg 不可靠解析状态码,默认按可重试交调用方决定)。 // err_msg 不可靠解析状态码,默认按可重试交调用方决定)。
if let Some(err_msg) = &chunk.error { if let Some(err_msg) = &chunk.error {
warn!( warn!(
@@ -254,18 +258,19 @@ pub(crate) async fn stream_llm(
text_len = full_text.len(), text_len = full_text.len(),
"[ai] provider 流式错误事件", "[ai] provider 流式错误事件",
); );
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
// 不重试。空文本无文可保,UX-260618-15: 不再 emit AiError(保守 retryable=true,
// emit 权交 agentic 重试耗尽时统一发,避免 N 次重试 N+1 气泡)。
if full_text.is_empty() && tool_calls_acc.is_empty() { if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { return StreamResult::InitFailed {
retryable: true,
error: fmt_diag( error: fmt_diag(
provider.name(), provider.name(),
DiagKind::MidStream, DiagKind::MidStream,
"stream-error", "stream-error",
err_msg, err_msg,
), ),
error_type: Some(ErrorType::Unknown), };
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
} }
return StreamResult::Partial { return StreamResult::Partial {
text: full_text, text: full_text,
@@ -291,22 +296,21 @@ pub(crate) async fn stream_llm(
"[ai] 流式接收中途错误", "[ai] 流式接收中途错误",
); );
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: MidStream chunk Err—— // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: MidStream chunk Err——
// 已有文本则保文(Partial),不重试。空文本无文可保,emit AiError + // 已有文本则保文(Partial),不重试。空文本无文可保,返回
// InitFailed{retryable=classify_status_or_class(status_or_class)} // InitFailed{retryable=classify_status_or_class(status_or_class)}
// (4xx Fatal 立即放弃,5xx/429/timeout/connect 可重试)。 // (4xx Fatal 立即放弃,5xx/429/timeout/connect 可重试)。
if full_text.is_empty() && tool_calls_acc.is_empty() { if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { // UX-260618-15: 不再 emit AiError。retryable=true 走重试路径,
// retryable=false(4xx Fatal)也交 agentic Fatal 分支统一 emit
// (InitFailed 携带 retryable + error,agentic 据此决定重试或 Fatal emit)。
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
error: fmt_diag( error: fmt_diag(
provider.name(), provider.name(),
DiagKind::MidStream, DiagKind::MidStream,
&status_or_class, &status_or_class,
&raw, &raw,
), ),
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
}; };
} }
return StreamResult::Partial { return StreamResult::Partial {
@@ -355,12 +359,12 @@ pub(crate) async fn stream_llm(
// UX-2025-04:有 partial_text 则保文(Partial),不 emit AiError,不重试。 // UX-2025-04:有 partial_text 则保文(Partial),不 emit AiError,不重试。
if !finished_received { if !finished_received {
if full_text.is_empty() && tool_calls_acc.is_empty() { if full_text.is_empty() && tool_calls_acc.is_empty() {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { // B-260615-05:空内容无 finished。UX-260618-15: 不再 emit AiError(retryable=true
// 重试路径,emit 权交 agentic 重试耗尽时统一发)。
return StreamResult::InitFailed {
retryable: true,
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(), error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
error_type: Some(ErrorType::Network), };
conversation_id: Some(conv_id.to_string()),
});
return StreamResult::InitFailed { retryable: true };
} }
warn!( warn!(
provider = %provider.name(), provider = %provider.name(),
@@ -395,20 +399,19 @@ pub(crate) async fn stream_llm(
error = %raw, error = %raw,
"[ai] LLM 流式调用失败", "[ai] LLM 流式调用失败",
); );
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { // UX-260618-15: 不再 emit AiError(retryable 走重试路径,Fatal 立即放弃,均交 agentic 统一 emit
// 最终错误气泡,避免重试过程 N 次失败 push N+1 气泡)。诊断文本经 fmt_diag 拼好携带出,
// agentic 重试耗尽/Fatal 时 emit AiError 用此文本,诊断信息不丢失。
// CR-30-1: Init 失败分类——retryable 据 status_or_class 镜像 retry::is_status_retryable:
// 5xx/429/timeout/connect=true 可重试;4xx(非429)/鉴权/参数错=false Fatal 立即放弃。
StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
error: fmt_diag( error: fmt_diag(
provider.name(), provider.name(),
DiagKind::Init, DiagKind::Init,
&status_or_class, &status_or_class,
&raw, &raw,
), ),
error_type: None,
conversation_id: Some(conv_id.to_string()),
});
// CR-30-1: Init 失败分类——retryable 据 status_or_class 镜像 retry::is_status_retryable:
// 5xx/429/timeout/connect=true 可重试;4xx(非429)/鉴权/参数错=false Fatal 立即放弃。
StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
} }
} }
} }

View File

@@ -175,12 +175,23 @@ export function handleEvent(event: AiChatEvent) {
break break
case 'AiStreamRetry': { case 'AiStreamRetry': {
// CR-30-2 / F-260616-07(d) / 决策 a1: 流前失败重试中——后端 emit AiError(Init Err) // UX-260618-15: 流前失败重试中——后端 emit AiStreamRetry 携带 attempt/max_attempts。
// 后再 emit 此事件。前端更新错误气泡内容为「重试 n/m」,对齐决策"错误气泡内更新"。 // 方案 A 根治 N+1 气泡:后端重试过程不再先 emit AiError(emit 权交 agentic 重试耗尽/Fatal 时
// 统一发最终错误),故首次 AiStreamRetry 到达时末条不是 isError,需创建新错误气泡;
// 后续 AiStreamRetry 到达时末条已是 isError,仅更新 content(单气泡聚合)。
// 看门狗已在上方 NO_RESET_WATCHDOG 之外(此事件不在集合内)reset,不卡整流超时。 // 看门狗已在上方 NO_RESET_WATCHDOG 之外(此事件不在集合内)reset,不卡整流超时。
const lastErr = state.messages[state.messages.length - 1] const lastMsg = state.messages[state.messages.length - 1]
if (lastErr && lastErr.isError) { const retryText = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts })
lastErr.content = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts }) if (lastMsg && lastMsg.isError) {
lastMsg.content = retryText
} else {
state.messages.push({
id: `err-${nextMsgId()}`,
role: 'assistant',
content: retryText,
isError: true,
timestamp: Date.now(),
} as AiMessage)
} }
break break
} }