From ba7f35552b25727bb3b509ec3ff69d06485a74b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Tue, 16 Jun 2026 19:11:19 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20AI=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E6=96=AD=E7=BA=BF=E4=BF=9D=E6=96=87StreamResult=E4=B8=89?= =?UTF-8?q?=E5=88=86=E6=94=AF+=E9=87=8D=E8=AF=95=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=86=B3=E7=AD=96a1,=E6=8E=A8=E8=BF=9B=E9=93=BE=E8=90=BDdf-nod?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/df-ai/src/lib.rs | 5 +- crates/df-ai/src/retry.rs | 21 +- crates/df-nodes/src/task_advance_node.rs | 39 ++- docs/todo.md | 47 ++- docs/待决策.md | 339 +++++++++++++++++++++ docs/待审查.md | 93 ++++++ src-tauri/src/commands/ai/agentic.rs | 212 +++++++++++-- src-tauri/src/commands/ai/commands.rs | 51 +++- src-tauri/src/commands/ai/mod.rs | 18 +- src-tauri/src/commands/ai/stream_recv.rs | 277 +++++++++++++---- src-tauri/src/commands/ai/tool_registry.rs | 130 +++++--- src-tauri/src/commands/knowledge.rs | 2 +- src-tauri/src/commands/workflow.rs | 11 +- src-tauri/src/lib.rs | 2 + src-tauri/src/state.rs | 8 + src/api/ai.ts | 5 + src/api/types.ts | 7 +- src/components/AiChat.vue | 7 +- src/composables/ai/aiShared.ts | 88 +++++- src/composables/ai/useAiEvents.ts | 62 ++-- src/composables/ai/useAiPanel.ts | 21 +- src/composables/ai/useAiSend.ts | 87 ++---- src/composables/ai/useAiWindow.ts | 7 + src/i18n/en/ai.ts | 4 + src/i18n/en/settings.ts | 3 + src/i18n/zh-CN/ai.ts | 4 + src/i18n/zh-CN/settings.ts | 3 + src/stores/ai.ts | 4 +- src/views/Knowledge.vue | 14 +- src/views/Settings.vue | 37 +++ 30 files changed, 1325 insertions(+), 283 deletions(-) create mode 100644 docs/待决策.md diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index 0e2e2ce..fb4c377 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -6,7 +6,10 @@ pub mod context; pub mod coordinator; pub mod openai_compat; pub mod provider; -mod retry; +// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用, +// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类) +// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。 +pub mod retry; use provider::LlmProvider; diff --git a/crates/df-ai/src/retry.rs b/crates/df-ai/src/retry.rs index 5844c96..edb34e2 100644 --- a/crates/df-ai/src/retry.rs +++ b/crates/df-ai/src/retry.rs @@ -37,8 +37,10 @@ const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30); const JITTER_RATIO: f64 = 0.2; /// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。 +/// +/// CR-30-1: 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。 #[derive(Debug)] -pub(super) enum AttemptOutcome { +pub enum AttemptOutcome { /// 成功,携带结果。 Ok(T), /// 可重试错误(timeout / connect / 5xx / 429)。携带人类可读错误描述。 @@ -50,12 +52,14 @@ pub(super) enum AttemptOutcome { /// 判定 reqwest::Error 是否可重试(仅 timeout / connect 类)。 /// /// body 解析错(reqwest::Error::is_decode)与请求构造错(is_builder)属 Fatal,不重试。 -pub(super) fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool { +pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool { e.is_timeout() || e.is_connect() || (e.is_request() && !e.is_builder()) } /// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。 -pub(super) fn is_status_retryable(status: u16) -> bool { +/// +/// CR-30-1: 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。 +pub fn is_status_retryable(status: u16) -> bool { status == 429 || (500..600).contains(&status) } @@ -64,7 +68,10 @@ pub(super) fn is_status_retryable(status: u16) -> bool { /// /// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。 /// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。 -fn backoff_delay(attempt: u32) -> Duration { +/// +/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1 +/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。 +pub fn backoff_delay(attempt: u32) -> Duration { let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000; // 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例 let nanos = SystemTime::now() @@ -85,7 +92,11 @@ fn backoff_delay(attempt: u32) -> Duration { /// /// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder), /// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。 -pub(super) async fn retry_with_backoff( +/// +/// CR-30-1: 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数 +/// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用 +/// backoff_delay + is_status_retryable)。 +pub(crate) async fn retry_with_backoff( label: &str, attempt_fn: F, ) -> anyhow::Result diff --git a/crates/df-nodes/src/task_advance_node.rs b/crates/df-nodes/src/task_advance_node.rs index 2b4532d..b5d91e6 100644 --- a/crates/df-nodes/src/task_advance_node.rs +++ b/crates/df-nodes/src/task_advance_node.rs @@ -379,10 +379,15 @@ mod tests { // 唯一未覆盖的是「闭包组装 + tracing」胶水代码(非业务逻辑)。 // ============================================================ - /// 镜像 workflow.rs::regression_target(workflow.rs L44-51 私有函数)的失败退回映射。 + /// 镜像 workflow.rs::regression_target(workflow.rs L44-54 私有函数)的失败退回映射。 /// /// 此处是契约镜像(非导入):若 workflow.rs 改映射而忘同步,这里会红,提醒两处一致。 - /// 映射:testing→in_review / in_review→in_progress / in_progress→todo / 其他→None。 + /// 映射:testing→in_review / in_review→in_progress / in_progress→None / 其他→None。 + /// + /// 为什么 in_progress → None? 状态机禁止 in_progress→todo + /// (task_state_machine.rs backward_to_todo_rejected),失败退回 todo 会被 + /// advance_task_atomic 的 InvalidState 拦截;改为 None 则回调跳过推进, + /// 留 in_progress 等人介入(决策 CR-13-O1-b)。 /// /// 为什么不 pub regression_target 复用? 它是 workflow.rs(app crate)的私有函数, /// df-nodes 不依赖 app crate(单向依赖:app → df-nodes)。把退回映射当 df-nodes 的 @@ -391,7 +396,8 @@ mod tests { match target { "testing" => Some("in_review"), "in_review" => Some("in_progress"), - "in_progress" => Some("todo"), + // in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入 + "in_progress" => None, _ => None, } } @@ -414,7 +420,8 @@ mod tests { // 锁定 workflow.rs ②-4 失败退回映射表(逐项)。 assert_eq!(regression_target("testing"), Some("in_review")); assert_eq!(regression_target("in_review"), Some("in_progress")); - assert_eq!(regression_target("in_progress"), Some("todo")); + // CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),留 in_progress 等人介入 + assert_eq!(regression_target("in_progress"), None); // done 是终态前向目标,失败无可退态(workflow.rs 注释:done→None) assert_eq!(regression_target("done"), None); // todo 是起点态无可退;blocked/cancelled 非推进链目标 → None @@ -454,10 +461,8 @@ mod tests { callback_advance_target("failed", "in_review").as_deref(), Some("in_progress") ); - assert_eq!( - callback_advance_target("failed", "in_progress").as_deref(), - Some("todo") - ); + // CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),回调返回 None 跳过推进 + assert_eq!(callback_advance_target("failed", "in_progress"), None); // failed + done:无退回映射 → None(回调跳过,workflow.rs 会 warn 跳过日志) assert_eq!(callback_advance_target("failed", "done"), None); } @@ -526,21 +531,15 @@ mod tests { assert_eq!(r.status, "in_progress"); assert_eq!(r.review_rounds, 1); - // in_progress 模板失败:任务当前 in_progress → 退回 todo(rounds 不累加, - // 因 todo→in_progress 是前向,in_progress→todo 是非法转换 —— 见状态机) - // 关键:regression_target("in_progress")=todo,但状态机不允许 in_progress→todo, - // 故回调 advance_task_atomic 会报 InvalidState。验证这一防御性行为: + // in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b), + // 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。 + // 验证:callback 返回 None,不调 advance_task_atomic。 repo.insert(rec("f3", "in_progress")).await.unwrap(); - let to = callback_advance_target("failed", "in_progress").unwrap(); - assert_eq!(to, "todo"); - let err = advance_task_atomic(&repo, "f3", &to).await.unwrap_err(); - assert!( - matches!(err, df_core::error::Error::InvalidState { .. }), - "in_progress→todo 是非法转换,回调应被状态机拦截(InvalidState),实际: {err:?}" - ); + let to = callback_advance_target("failed", "in_progress"); + assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}"); // 任务状态未被改动(仍是 in_progress) let still = repo.get_by_id("f3").await.unwrap().unwrap(); - assert_eq!(still.status, "in_progress", "非法退回不应改动 status"); + assert_eq!(still.status, "in_progress", "None 退回不应改动 status"); } /// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。 diff --git a/docs/todo.md b/docs/todo.md index ed11088..dbc9979 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -41,6 +41,8 @@ **推进原则**:能并行不串行(独立子任务 8-12 并发);每批重汇总+全优先级重排+销账核对 ✅;C 组阻塞项不代办等用户。**本会话首批**:A 卫生 + B 清死链。 +**③④C类已迁**(2026-06-16):需人工决策项(③产品取舍 7 / ④设计方向 7 / 重投入排期 9 / C用户实测 5)见 [待决策.md](./待决策.md),从本文件抽离职责分离。**batch54 可自主批进展(2026-06-16·workflow wsfz7zl8i + 主代核查)**:复核-新⑪ 看门狗幽灵 ✅ 销账(清主窗口 state 视觉残留;**核验更正**:原 watchdog 幽灵已被 CR-260615-24 stopListener:371 clearStreamWatchdog 兜底);AE-03 diff预览 ⚠️ 降级阻塞(后端 write_file 未返 diff,待后端补,见 line 538);UX-04 断线保文 待 DEC-260616-03 授权(见待决策.md 🟡,本批不含)。 + ### P0 — 阻断性 bug - [x] B-260614-01 — ~~待审批持久化根治(重启恢复)未生效~~ ✅ mission:T-260614-01 已修复(commands.rs:444 clear→retain 保其他对话 pending;ai_approve 两处 if !recovered 守卫移除;cargo check 0 err / 19 test pass)(06-14) @@ -53,7 +55,7 @@ - [x] ✅(第㉖批 2026-06-16·主代理直修) B-260616-09 [P1] — **任务详情点击打开空白(路由漏注册)**。**router/index.ts 补 /tasks/:id → TaskDetail**(L50-54 新增块,6 行,参照 /projects/:id L39-43 + /ideas/:id L27-31 模式)。name 'TaskDetail' 无冲突(现有 name 集无此项),复用 icon-thunder(对齐 /ideas/:id 复用 icon-lightbulb、/projects/:id 复用 icon-apps 一致模式)。TaskDetail.vue 现挂载,Tasks.vue:57 router.push(`/tasks/${task.id}`) 不再跳未注册路由,B-18 联动刷新等功能激活(死代码转活)。改动极简确定(6 行路由表项),主代理直修省 workflow 单 agent 编排开销。vue-tsc exit 0。— src/router/index.ts - [x] ✅(第㉖批 2026-06-16) B-260616-10 [P1] — **provider 配置后 AI 面板仍提示「未配置」**。**Settings.vue loadProviders 同步刷全局 store**。L376 import useAiStore + L383 const aiStore = useAiStore()(跟随 L382 appSettings 风格)+ L420-424 loadProviders 内本地 aiProviders.value 刷新成功后 L424 await aiStore.loadProviders() 刷全局 state.providers(模块级单例 ai.ts:45,L20/L44 注释明确全应用共享同一份,AiChat 共享)。三处调用点(saveProvider L464/deleteProvider L479/setDefaultProvider L490)+ onMounted L731 均经 loadProviders 自动同步全局。无循环(useAiPanel.ts:91 loadProviders 只刷 state.providers 不回调 Settings)。无 i18n 改动(纯数据同步),try/catch + showToast 保留。vue-tsc exit 0。— src/views/Settings.vue -- [ ] B-260616-11 [P2] — **AI 消息 `-`/`+` 列表/diff 无视觉效果**。marked 实测两个出口:①裸 `-`/`+` 混用 → GFM 要求同列表标记一致,marked 拆成**两个独立 `
    `** 且 `-`/`+` 被 eat 当 marker(用户看到的符号消失,两个分离单项列表);②```diff 代码块 → `
    ` **无 hljs 高亮**(ai-md.css 未集成 highlight.js),`-`/`+` 字面纯文本无红绿。根因层 marked 行为合规,缺的是 **diff 高亮集成 + 裸 diff 文本识别**。改动(待定):①集成 hljs 覆盖 ```diff(关联已知待办「aichat交互体验改进方案-2026-06-14」2.2 代码块高亮)②裸 `-`/`+` diff 文本预处理识别。— src/composables/useMarkdown.ts + src/styles/ai-md.css。**②子项已修(第㉖批 2026-06-16·主代理直修)**:useMarkdown.ts L40-41 import diff + L63 registerLanguage('diff', diff)(16→17 语言),```diff 代码块经 highlightCode getLanguage('diff') 命中 → hljs-addition/deletion token class → github-dark.css 红绿。**①子项仍②类待复现**:裸 -/+ 文本预处理识别(需确认 AI 实际输出格式)。vue-tsc exit 0。
    +- [ ] B-260616-11 [P2] — **AI 消息 `-`/`+` 列表/diff 无视觉效果**。marked 实测两个出口:①裸 `-`/`+` 混用 → GFM 要求同列表标记一致,marked 拆成**两个独立 `
      `** 且 `-`/`+` 被 eat 当 marker(用户看到的符号消失,两个分离单项列表);②```diff 代码块 → `
      ` **无 hljs 高亮**(ai-md.css 未集成 highlight.js),`-`/`+` 字面纯文本无红绿。根因层 marked 行为合规,缺的是 **diff 高亮集成 + 裸 diff 文本识别**。改动(待定):①集成 hljs 覆盖 ```diff(关联已知待办「aichat交互体验改进方案-2026-06-14」2.2 代码块高亮)②裸 `-`/`+` diff 文本预处理识别。— src/composables/useMarkdown.ts + src/styles/ai-md.css。**②子项已修(第㉖批 2026-06-16·主代理直修)**:useMarkdown.ts L40-41 import diff + L63 registerLanguage('diff', diff)(16→17 语言),```diff 代码块经 highlightCode getLanguage('diff') 命中 → hljs-addition/deletion token class → github-dark.css 红绿。**①子项仍②类待复现**:裸 -/+ 文本预处理识别(需确认 AI 实际输出格式)。**batch58 可行性评估结论(2026-06-16·workflow w2dw65maz·Agent C 仅评估未改码)**:推荐**方案A**(renderMd/parseBlockNoCache 前 `wrapNakedDiff` 正则检测连续 ≥2 行 ±开头→包裹 ```diff 围栏,复用已就绪 hljs diff 红绿样式零额外 CSS);方案B(CSS ::before 注入)**否决**(marked 已 eat 掉原始 ±符号,li 内文本信息丢失无法区分);方案C(marked custom tokenizer)**否决**(侵入 marked 内部收益不优于 A)。守卫:纯 `-` 列表(普通 ul)不触发+必须含 `+` 行混排。**暂缓实施**:需求前提(AI 实际输出格式)未验证,不同 LLM 输出 diff 格式差异大(围栏/裸行/自然语言描述),盲目做可能解决不存在的问题;确认需求后 0.5 批次(~40-60 行+测试)。vue-tsc exit 0。
       - [x] ✅(第㉗批 2026-06-16·workflow) B-260616-19 [P2] — **知识库详情窄屏标题挤压成竖线(一行一字)**。三因子叠加(Knowledge.vue):①`.kn-layout` grid `grid-template-columns: 360px 1fr`(L567)——左列表死钉 360px、右详情 `1fr` 拿剩余,**无响应式断点**;窗口窄(如总宽 500px,减 padding 40+gap)详情列塌缩到 ~80px 甚至更小。②`.detail-title`(L625)`font-size:18px` + `word-break:break-word` —— 窄列里中文每字宽度 > 列宽,逐字换行 → 竖线化。③`.detail-actions`(L626)`flex-shrink:0` —— 编辑/发布/拒绝 3 按钮不缩,优先占位,把 `.detail-title-row`(`flex:1`) 挤到零头。`.kn-detail-panel`(L570-576) 无 `min-width` 兜底。改动(待定):①grid 列宽改 `minmax(240px,360px) 1fr` + `@media (max-width:760px)` 切单列(`grid-template-columns:1fr`,列表详情上下堆叠);②详情面板加 `min-width:0` 配合标题行 `min-width` 防塌缩;③可选标题字号响应式降级(18px→15px)。— src/views/Knowledge.vue。**低风险纯 CSS**,无逻辑/i18n/数据变动。
       
       ### 💡 2026-06-16 新需求(已分析·待实施)
      @@ -68,7 +70,23 @@
       
       - [x] ✅(batch42·2026-06-16·workflow w95hzp9tw) F-260616-04 [P1] — **run_command 超时→LLM 重试→反复审批循环**。已根治(tool_registry.rs 超时标注「勿盲目重试」+ DEFAULT_RUN_COMMAND_TIMEOUT_SECS 常量,handler map_err 改写)。根因链(探索确认):超时(默认 60s, `tool_registry.rs:491 unwrap_or(60)`)→tool_result 回传 LLM(`commands.rs:168` Err 分支)→LLM 重试同命令→**新 tool_call_id**(`stream_recv.rs:176` 每轮新 UUID)→重新 insert pending(`audit.rs:343`)→重新审批,循环。症状「再过一会又提示 Run Command」即此。修复方向:①超时 tool_result 明确标注「命令执行超时({n}s),进程已终止,勿盲目重试同命令;确需更长时限重发时提高 timeout_secs」②run_command 默认超时可配(LLM 已可传 `timeout_secs` 参数,但默认 60 + 不主动调)③配合 F-260616-05 去重根治。— tool_registry.rs(超时标注) + commands.rs(回传内容)。
       - [x] ✅(batch53·2026-06-16·workflow wbh8mu1iq·**run_command 循环核心解决**) F-260616-05 [P1](**共性根治**)— **agentic loop 工具调用去重缺失**。**batch53 落地 High risk 审批前去重**(audit.rs find_cached_high_risk_result 反向扫 messages + args JSON 规范化匹配 + 排除 pending 占位 + decided_by=auto_dedup,解决 run_command 反复审批循环,F-04 超时标注 + batch53 去重双保险)。**完整 agentic loop 通用去重(search_files 重复卡片 + 滑动窗口指纹 + 决策点)③类待用户定**(窗口/粒度/措辞)
      -- [ ] F-260616-06 [P2] — **「未找到挂起的审批」体验**。根因:后端 `pending_approvals` 内存态(`commands.rs:130 remove None` 报错)与前端刷新/重复点击不同步;重试场景旧 id 已被处理。前端 `useAiSend.ts:221` IPC 失败不回滚 pendingApprovals,按钮残留。修复方向:①`ai_approve` 找不到 pending 时查审计表,若已 executed/rejected 返回「已处理」幂等成功(非报错) ②前端审批按钮点击防抖(同一 id 短期多次只发一次 IPC) ③前端刷新后 pending 态正确同步(已部分由 `useAiConversations.ts:119 pendingToolCalls` 恢复,核对边界)。— commands.rs(ai_approve 幂等) + useAiSend.ts(防抖)。
      +- [x] ✅(batch56·2026-06-16·workflow wsxxurgbr+主代核查) F-260616-06 [P2] — **「未找到挂起的审批」体验**。**全闭环落地**:①`commands.rs:217` ai_approve 幂等——内存 pending_approvals remove None 时查 `ai_tool_executions` 审计表,已 executed/rejected/failed 返回「已处理({status})」非报错 ②`useAiSend.ts:305` 审批按钮防抖——`_pendingApprovalIds` Set 守卫,同 id 短期多次点击只发一次 IPC,finally 块清理。— src-tauri/src/commands/ai/commands.rs + src/composables/ai/useAiSend.ts
      +
      +- [x] ✅(batch56·2026-06-16·workflow wsxxurgbr+主代核查) F-260616-07 [P2] — **流式对话失败自动重试**。**全闭环落地**:①`agentic.rs` stream_llm 调用处包重试循环(max_retries 形参,退避 1s→2s→4s,持 permit 防挤占) ②每重试 emit `AiStreamRetry` 事件(attempt/max_attempts) ③耗尽走原有 guard.reset+return ④state.rs 加 `agent_max_retries:Arc`(默认3) ⑤新 IPC `ai_set_agent_max_retries`(clamp 0-10) ⑥前端 api/ai.ts + types.ts(AiStreamRetry variant) + Settings.vue(input 0-10+debounce sync+i18n zh/en)。**设计决策**:一律尝试重试(简化策略,由退避控制成本),MidStream 重试语义等价于"看不到完整回复再试一次"。— src-tauri/src/commands/ai/agentic.rs + state.rs + commands.rs + lib.rs + src/api/ai.ts + types.ts + src/views/Settings.vue + src/i18n/{zh-CN,en}/settings.ts
      +
      +- [x] ✅(batch56·2026-06-16·workflow wsxxurgbr+主代核查) F-260616-08 [P2] — **AI 数据获取工具 offset 分页 + 探总量**。**全闭环落地**:①6 工具(list_projects/tasks/ideas/trash/read_file搜索模式/search_files)全部补 offset+limit 参数,返回 items/total/has_more 替代 truncated ②新增 `get_project_count`+`get_task_count` 探总量工具 ③knowledge_search 解钳 `min(3)`→`min(20)`。— src-tauri/src/commands/ai/tool_registry.rs + src-tauri/src/commands/knowledge.rs
      +
      +- [ ] F-260616-09 [P1/架构] — **AiSession 单例 → 多会话并发架构(多窗口来回切 + 并行执行)**。用户需求(3 条消息强化):「应用级支持同时具备多会话」+「同时具备多会话的能力」+「多个会话窗口来回切,**并行执行**」。现状(Explore 走查确认):`AiSession` **应用级单例**(`state.rs:164` `ai_session: Arc>`,init:205),messages/generating/stop_flag/pending_approvals/agent_language 全一份。设计「单例 + 软隔离」——generating 互斥(同一时刻**只一个对话能跑**) + 切换旧 loop 退出(`agentic.rs:177-190` B-260615-11) + 切换时 generating→readonly(`commands.rs:873-881`)。`llm_concurrency` per_conv 因此退化为单对话内并发(`state.rs:93-96` 注释已标注预留改 HashMap)。**memory 已标记「AiSession 单例未动」架构债**(aichat-arch-extensibility)。**残留 bug**(表象=新建会话上下文残留):前端 `newConversation`(`useAiConversations.ts:31-41`) 漏清 queue/generatingConvId/agentRound/searchQuery;后端 `ai_conversation_create`(`commands.rs:784-821`) 漏清 agent_language/stop_flag(设 true 停旧 loop 没复位 false)。**用户「并行执行」明确=B 方向**(A 只解切换残留不满足并行)。**分档**:**A 短期隔离修复**(补漏清字段,维持单例软隔离,解残留,低风险立即做,**B 前置**) + **B 多会话并发架构**(单例 → `HashMap`,每会话独立 messages/generating/stop_flag/pending_approvals + llm_concurrency per_conv 改真 per-conv HashMap + loop 内校验改 conv_id 索引各自 session + 切换不退出旧 loop 各自跑完真并发 + 事件路由核对 conversation_id 全覆盖)。**决策点(待定)**:(a) A 先做解燃眉 vs 直接 B——倾向 **A 先**(残留真 bug 立即解,B 架构另立项) (b) B 形态——AiSession 整体 HashMap vs 拆字段(messages/pending 已可按 conv reload,只拆 generating/stop_flag per-conv)——倾向**后者侵入小** (c) B 并发上限——复用 llm_concurrency.global 限并发会话数防 token 暴增——倾向是 (d) **多窗口 UI 形态**——(d1) 单面板侧栏会话列表切换+后台并行(改动小) vs (d2) 每会话独立 Tauri 窗口(`useAiWindow.ts` detached 已有基础可扩,改动大但「窗口来回切」语义最贴)——**待用户定**,倾向先 d1 后 d2 (e) 旧 loop 切换处理——不退出各自跑完(真并发,倾向) vs 仍退出。— state.rs(AiSession 去单例化) + commands.rs(create/switch 重构 + 漏清补) + agentic.rs(:177-190 校验改 conv_id 索引) + context.rs(ContextManager per-conv) + useAiConversations.ts(漏清补) + stores/ai.ts(per-conv state) + llm_concurrency per_conv + useAiWindow.ts(若 d2 多窗口)。**B 是大改架构升级,需单独设计文档 + 立项**。**待用户定 A 先做 / B 立项 / 多窗口 d1·d2 方向**。
      +
      +- [x] ✅(batch58·2026-06-16·workflow w2dw65maz+主代核查,vue-tsc 0err) F-260616-10 [P1·bug] — **provider 切换/默认值重启·刷新后失效(前端 activeProvider 不同步 DB 默认)**。用户实测:Settings 设 A 为默认 → 重启 → 默认变 B(列表第一个)。**根因全在前端,后端链路正确**(独立核验不信 PROGRESS.md:「已修复」声明不可信):`ai_set_provider`(`commands.rs:747-774`) 互斥落库✅ + `update_full`(`crud.rs:1127`) SQL UPDATE 含 `is_default`✅ + `get_active_provider`(`prompt.rs:9-32`) 走 `DB.is_default`✅。**根因链**:①重启后前端 `store.activeProvider = null`(`stores/ai.ts:53`,无 localStorage 持久化 + 无启动恢复) ②`loadProviders`(`useAiPanel.ts:91-93`) 只拉列表**不同步 DB 默认** → activeProvider 仍 null ③**UI 错觉**:`activeProviderName`(`AiChat.vue:1169-1172`) null 时 fallback `providers[0]`(列表第一个) ≠ 用户设的 A → 「默认变另一个」 ④**真覆盖**:用户点 provider bar 切换 `cycleProvider`(`AiChat.vue:1180-1185`),`findIndex(activeProvider=null)` 返回 **-1** → `next = ps[(−1+1)%len] = ps[0]` = 列表第一个 B → `setProvider(B)` → 后端互斥落库把 `DB.is_default` 从 A **真改成 B**。即使用户不点切换,UI 也显示 providers[0] 而非 A。**修复方向**:①`loadProviders` 后同步 `activeProvider` = DB.is_default 的 provider——后端加 `ai_get_active_provider` IPC 返回当前默认 id,或 `listProviders` 返回值带 `active_provider_id` ②`cycleProvider` findIndex=-1 陷阱修正(null 时应从 DB 默认起算,非 ps[0]) ③前端 activeProvider 持久化(localStorage `df-ai-active-provider`,对齐 `df-ai-active-conv` 模式)。— useAiPanel.ts(loadProviders 同步默认) + AiChat.vue(:1169 activeProviderName fallback + :1180 cycleProvider findIndex 陷阱) + commands.rs(新 ai_get_active_provider IPC) + api/ai.ts + stores/ai.ts(activeProvider 持久化)。**确定性 bug,低风险,可立即修**。
      +
      +- [ ] F-260616-11 [P1/设计风险] — **审批后 iteration 重置致 max_iterations/token 超配**。性能分析批次发现:`try_continue_agent_loop` 重新 spawn `run_agentic_loop`,**iteration 从 0 重计**(`agentic.rs:418` 注释,F-260616-03 续跑决策 a 的延续,审批续跑复用此入口)。每次审批(Med/High 工具)后配额重置,多次审批=反复重跑 max_iterations 轮,实际可跑远超配额,**token 消耗不受 max_iterations 严格控制**。影响:长对话多次审批致 token 失控 + 对话变慢(每审批一次重跑 LLM 轮次)。**方向(待评估)**:①审批不重置 iteration(累计计数)②审批是用户决策点重置合理但需独立「审批次数」配额 ③至少 token 总量兜底。— agentic.rs(try_continue iteration 计数策略)。**需决策**:iteration 累计 vs 重置。
      +
      +- [ ] F-260616-12 [P2/依赖F-09] — **retry 持 permit 不释放(多会话隐患)**。F-260616-07 落地的流式重试循环(`agentic.rs:238-239`)重试期间持有 global+per_conv permit 不释放(注释「防新请求挤占」)。当前 AiSession 单例 + 主 loop 串行无影响,但 **F-260616-09 多会话并发后**,重试期间阻塞其他对话 LLM 调用。**方向**:多会话落地时核对——主 loop 串行下重试持 permit 防自己挤占无意义,倾向重试不持 permit 或仅持 per_conv。— agentic.rs:238 + F-260616-09 多会话架构。**依赖 F-260616-09 立项后一并处理**。
      +
      +- [ ] F-260616-13 [P2/小优化] — **build_for_request 持锁重活 + system_prompt token 每轮重估**。性能分析批次发现:每轮 `stream_llm` 前 `session_arc.lock()`(`agentic.rs:251`)持锁期间做 `TokenEstimator::estimate_text(system_prompt)`(L252)+ `build_for_request`(L253 history clone + 裁剪)。system_prompt loop 外固定传入,**每轮重估其 token 是浪费**(可缓存);build_for_request 持锁做 history clone 是重活,消息多时(200 cap)锁持有期长。**方向**:①system_prompt token loop 外算一次缓存 ②build_for_request 先 clone messages 释放锁再裁剪。低收益优化。— agentic.rs:251-253。
      +
      +- [x] ✅(主代核验·2026-06-16) F-260616-14 [P2/核验] — **max_tokens 8192 截断 + 前端批量审批核验**。核验结论:①`max_tokens=8192`(实际 `agentic.rs:270`,行号漂移)≈6-8k 字日常够用,长回复截断由 batch59 MidStream 保文兜底不丢文,**可配低优先非阻塞**(登记可选优化)②前端批量审批 **AE-2025-01 已完整闭环**(ToolCardList.vue:4-11 `ai-batch-approve` 栏 + AiChat.vue:444 `@batch-approve`→`store.batchApprove` + useAiSend.ts:338 遍历 pendingApprovals 逐个 approveToolCall + i18n approveAll/rejectAll),满足「一轮多 Med/High 全 insert pending→一次批量批全部」③audit.rs:496 `process_tool_calls` 一轮多工具 Med/High 进审批门控 insert pending + Low 并行执行链路通。无阻塞 bug,销账。— agentic.rs:270 + ToolCardList.vue + audit.rs:496。
       
       ### 🔧 任务模块全栈核对(2026-06-16 · Explore 代理并行取证)
       
      @@ -127,11 +145,14 @@
         > **审查发现待修项(2026-06-16 · CR-08/10/13,均 med 非阻断,详见 [待审查.md](./待审查.md) 对应归档)**:
         - [x] ✅(batch50·主代理决策:接受现状降级 P2) **CR-10-A** [med] ai_regenerate 并发竞态窗口 → **统一并发模型**(锁内变更+锁外 spawn,ai_regenerate/ai_chat_send/ai_chat_force_send 同款),窗口有兜底(stop_flag 置位 loop 退出 + generating 拦截 send),med 非阻断。统一并发改造复杂(原子标记预占+3 入口),ROI 不明非优先,降级观察/架构改进候选
         - [x] ✅(batch50·主代理核查:无效) **CR-10-B** [med] commands.rs:56/59 save 与 emit 时序倒置 → **不成立**:ai_regenerate 当前代码 save L94 锁外 spawn L105 前(时序正确),无 emit(L56/59 是 pop 后错误处理),审查锚定行号漂移
      -  - [ ] **CR-13-O1** [产品决策点·非代码bug·③类待用户] in_progress 模板失败→`regression_target("in_progress")=Some("todo")`,但状态机 `backward_to_todo_rejected` 全拒(task_state_machine.rs:188-193)→ 回调 advance_task_atomic 被 InvalidState 拦截(task_advance_node.rs:72-77),任务原地保留 in_progress。**三选项待定**:a 接受现状补 UI 提示 / b 改 `regression_target("in_progress")=None` 对齐状态机 / c 放开 todo 回退(破坏闸门)— CR-260616-13 归档
      +  - [x] ✅(batch55·2026-06-16·workflow wem402ncv+主代核查) **CR-13-O1** [产品决策→已决b实施] regression_target("in_progress")→None 对齐状态机。workflow.rs:51 `in_progress => None`(替原 Some("todo"))+task_advance_node.rs 镜像同步+4 测试断言更新(含集成测改 None-skip 场景)。**主代核验**:git diff 2 文件 26+/24- 双处一致+注释对齐决策b理由+cargo test -p df-nodes 67/67 pass。— src-tauri/src/commands/workflow.rs + crates/df-nodes/src/task_advance_node.rs
         - [x] ✅(batch50·2026-06-16·workflow w6nqio4o5) **CR-08-O1** [med·非阻断] blocked 态推进按钮语义偏差 → 已修复(WF_EXCLUDED_FROM={'blocked'} + wfAdvanceAction computed 守卫,blocked 不显示工作流推进按钮,其他态不受影响,不动 ADVANCE_MAP)
         - [x] ✅(batch50·2026-06-16·workflow w6nqio4o5) **CR-15-1** [med] `crates/df-execute/src/shell.rs:60-79` 缺 `.kill_on_drop(true)` → 已修复(cmd.kill_on_drop(true) 三类型覆盖 + 注释配合 tokio::time::timeout 防孤儿,文案「进程已终止」名副其实)
         - [x] ✅(主代理验证:不成立·workspace cargo check exit 0) 🔴 **CR-23-1** [high·紧急·阻断构建] `crates/df-ai/src/context.rs:491` E0594 → **不成立**:审查代理跑 batch51 中间态 filter 版(EXIT 101),batch51 最终改 for 循环(EXIT 0,df-ai + workspace 双验证,代理自报属实)。context.rs:488 当前是 `for t in iter_mut()` 单层引用可写 status,无 filter 闭包。审查锚定滞后(跑 batch51 中间态)— CR-260616-23 归档
         - [x] ✅(主代理修·CSS :has item 左边线) **CR-22-1** [med·UX] `AiChat.vue` 已置顶非 hover 不可见 → 已修复(`.ai-conv-item:has(.ai-conv-item-act--pinned){ box-shadow: inset 2px 0 0 accent }` 左边线强调,不破坏 hover 浮出 toggle,原方案图钉常驻与 actions display:none 矛盾,改 :has item 左线最简)— CR-260616-22 归档
      +  - [ ] **CR-34-1** [med] useAiPanel.ts:101 loadProviders 守卫 `if(!state.activeProvider)` 只查 null 不查有效性 — localStorage savedProvider 指向已删除 provider 时 activeProvider 残留无效 id → cycleProvider:1185 findIndex=-1 跳过 ps[0] + 发消息消费方用无效 id。修法:守卫加 `|| !list.some(p=>p.id===state.activeProvider)` 残留无效时回落 DB is_default — CR-260616-34 归档
      +  - [ ] **CR-30-1** [med] agentic.rs:298 退避 `1< { if (ready && streaming && currentText) scheduleStreamParse(currentText) })`,mdReady 翻转时主动触发末块重算。原 todo 标注未实施系过时判断 — AiChat.vue loadMarkdown
       - [x] CR-260615-07 ✅ blockCache LRU (第⑩批 2026-06-16) — ~~_blockCache LRU+escapeHtml 抽~~ _blockCache 超 limit 由整体 clear 改删最早一条(Map.keys().next().value LRU 语义,边界 > 改 >= 防超限)+escapeHtml+replace 两处重复抽 escapeFallback 函数(renderStreamingMd/renderMd 兜底均调),行为零变化 — AiChat.vue
      @@ -335,7 +356,7 @@
       - [x] DOC-260615-06 ✅(2026-06-16) — ~~架构复核报告 §5 新发现 11 项补 todo~~ 拆 4 子项独立 todo(见下方),核对发现 **复核-新① 已解决**(`useAiConversations.ts:122` 已有 pendingToolCalls await 后二次 token 比对),只补 ③④⑪ 三项;其余 7 项已被全局 review 取代(R-PD 系列) — 详见 §6
       - [x] ✅(batch31·2026-06-16) 复核-新③ [P2] — **NodeCancelled 事件语义双标**。`executor.rs:149` 取消节点(set_cancelled 后 Err 路径)状态保 Cancelled(:145 跳 set_failed),但事件总线仍 emit `NodeFailed { error: "人工审批被取消" }`。前端若按 NodeFailed 分支判断会误归类「失败」非「取消」。修法:加 `NodeCancelled` variant 或 NodeFailed 加 `cancelled: bool` 字段。— 来源:架构复核报告 §5 新③,crates/df-workflow/src/executor.rs:145-153 + df-core/events.rs
       - [x] ✅(batch30·2026-06-16) 复核-新④ [P2] — **cancel_workflow_node 无节点终态前置校验**。`workflow.rs:352` 仅校验 execution_id 在注册表,不校验 node_id 状态。对已 Completed/Failed/Cancelled 节点调 set_cancelled 会静默覆盖终态(set_cancelled 绕 is_legal)。修法:加 `match sm.get(&node_id) { Running|Waiting => {}, _ => Err }` 守卫。— 来源:架构复核报告 §5 新④,src-tauri/src/commands/workflow.rs:341-358
      -- [ ] 复核-新⑪ [P2] — **主窗口看门狗幽灵**:detach 时主窗口 `state.streaming`/`state.generatingConvId` 未清(`useAiWindow.ts:20-59` detachPanel 仅快照写 localStorage 不清主窗口 state),看门狗续计 130s 后往主窗口 state 补幽灵错误消息。用户已切走无实际危害,仅主窗口面板重开时看到一条幽灵错误。修法:detach 时显式清主窗口 streaming/generatingConvId 或暂停主窗口看门狗(detach 前提下主窗口不应跑生成态看门狗)。— 来源:架构复核报告 §5 新⑪,src/composables/ai/useAiWindow.ts:20-59 + useAiEvents.ts:115 watchdog
      +- [x] ✅(batch54·2026-06-16·workflow+主代核查) 复核-新⑪ [P2] — **主窗口看门狗幽灵**。**核验更正**:原述「看门狗续计 130s 补幽灵错误」已被 CR-260615-24 兜底解决(detach→AiChat 卸载 App.vue v-if→onBeforeUnmount:1613 `stopListener()`→useAiEvents.ts:371 `clearStreamWatchdog()`,主代独立 grep 核验链路完整)。本次 workflow agent 补强:detachPanel 快照后清主窗口 `state.streaming=false`/`generatingConvId=null`(useAiWindow.ts:37-38),消除「面板重开生成态视觉残留」(进度条/spinner)。**安全性**:主/分离窗口 state 独立(各自 webview 独立 JS context,stores/ai.ts 单例仅同 webview 内共享),清主窗口 state 不影响分离窗口生成态;localStorage 快照保留供 resumeInDetached 恢复。vue-tsc EXIT=0 主代独立核验。— 来源:架构复核报告 §5 新⑪,src/composables/ai/useAiWindow.ts:37-38 + useAiEvents.ts:364-374 stopListener
       - [x] DOC-260615-07 ✅(第⑲批 2026-06-16) — ~~aichat §8 表补勾~~ aichat审查报告第八章 AR-1~11 优先级表逐行补状态勾注(AR-1退役✅/AR-2~7,9~11已修✅+commit/AR-8重评🔄)+文档头二次增补行;零新增条目对齐 DOC-13 瘦身方向;状态不一致 2 项(AR-1退役/AR-8重评)按 todo 标注非矛盾 — 详见 §7
       - [x] DOC-260615-08 ✅(2026-06-16) — ~~近期改动 CR-03 未做子项拆独立 todo~~ 拆 3 子项独立 todo(见下方)。原 CR-03(line 212)内联备注「未做留 todo」未独立追踪,现拆。— 详见 §8
       - [x] ✅(batch30·2026-06-16) CR-260615-03-a [P2] — **audit.rs Err 误报「项目已不存在」**。`audit.rs:52` `resolve_project_label` 用 `_` 合并 `Err`(DB 故障/锁/连接断)与 `Ok(None)`(真不存在),DB 出错时用户看到误导性「项目已不存在」而非错误。修法:分三臂 `Ok(Some)`/`Ok(None)`/`Err`(Err 打 warn 日志回退裸 id)。零行为破坏(仅 Err 分支加日志+改文案)。— 来源:近期改动审查 §2②,src-tauri/src/commands/ai/audit.rs:50-52
      @@ -425,7 +446,7 @@
       - [x] B-260615-21 ✅ 回滚user msg (第⑩批 2026-06-16) — ~~sendMessage catch 回滚 user message~~ push user msg 前捕获 const userMsgId(原内联未生成)+catch 块 filter 改 m.id!==aiMsgId && m.id!==userMsgId 一并回滚;机制:user/ai msg 均 nextMsgId() 唯一 id+push 进 state.messages(无索引依赖,按 id 过滤最稳) — useAiSend.ts
       
       **P2 — 前后端状态同步**
      -- [ ] B-260615-22 — [P2] ~~前后端状态不同步~~ → **由 B-260615-55(L0 握手) + B-260615-56(L2 强制发送) 解决**,本条保留为问题记录不再独立实施
      +- [x] ✅(batch55·2026-06-16·主代销账) B-260615-22 — [P2] ~~前后端状态不同步~~ → **由 B-260615-55(L0 握手) + B-260615-56(L2 强制发送) 解决**(L0 前端重接握手+L2 三级降级+force_send IPC),本条保留为问题记录不再独立实施。双项均已✅销账,本条闭环。
       - [x] CR-260615-12 ✅ 小项 _toastTimer 等 (第⑩批 2026-06-16) — ~~可选一组~~ _toastTimer 卸载未清(AiChat:436) + markdown 外层 span→div + SVG 常量散落 + appSettings 注释 + index 空行 + 路由 icon 语义 + options shape 校验 + projectNameById 共享 + useConfirm dev 警告 + i18n `t` as any — 详见走查 ⚪ 区
       - [x] CR-260615-09 ✅ .ai-md DRY (第⑩批 2026-06-16) — ~~五份 .ai-md 样式逐字重复~~ 抽全局 css (84条删+3留独有) — AiChat/ProjectDetail/Ideas/Knowledge/TaskDetail → `src/styles/ai-md.css` 或 `` 组件。TaskDetail.vue:263-333 新文件继续抄(CR-09 复发,定时走查 06-15 确认第 5 份)。B-24/25 复制粘贴源头 — 详见走查 ⑤ + 定时走查 P0复核.md
       - [x] CR-260615-10 ✅ useRendered DRY (第⑩批 2026-06-16) — ~~useMarkdown 加 `useRendered(getText)` 辅助~~ 消除 renderedDesc computed + onMounted(loadMarkdown) 三处重复(与 CR-09 同源) — 详见走查 ⑥
      @@ -495,7 +516,7 @@
       - [x] UX-2025-01 ✅ 选文字(文本偏移量方案) (第⑩批销账 06-15) — **流式渲染中选中文字保持**(§2.1)。现象:AI 流式输出时选文字被 delta 打断丢失选区。方案 A(推荐):`selectionchange` + 选区在消息容器内 → 暂停 rAF 流式 parse → 选区结束恢复;方案 B:已完成块独立 DOM 节点不参与 v-html 替换。关联 ARC-08 块级 memo(splitBlocks 已按块切分,末块单独更新天然支持方案 B)— src/components/AiChat.vue + src/composables/ai/useAiEvents.ts
       - [x] ✅(batch36·2026-06-16·workflow w098aapgd) UX-2025-02 [P1] — **消息操作栏(复制 / 重新生成)**(§1.2+§1.3)。AI 气泡 hover 显示操作栏:「📋 复制」+「🔄 重新生成」。复制 = F-260615-13 合并实现;重新生成:后端删最后一条 AI 消息 + 用倒数第二条 user 消息重跑 agentic loop(新增 `ai_regenerate` IPC)。代码块额外 hover 浮出「📋 复制代码」按钮(§2.2 子项)。— src/components/AiChat.vue + src-tauri/src/commands/ai/commands.rs
       - [x] ✅(batch36·2026-06-16·workflow w098aapgd) UX-2025-03 [P1] — **错误气泡操作入口**(error_type 枚举本身 batch34 B-42 已加,本批前端消费)(§4.1)。错误气泡底部增加按钮区:「🔁 重试」(取上一条 user 消息重发)+「⚙ 去设置」(仅 auth/provider 错误时显示,跳 Settings→AI Tab)。后端 `AiError` 加 `error_type: Option` 枚举(auth/network/timeout/provider_config/unknown),前端据此显隐按钮。— src-tauri/src/commands/ai/mod.rs(AiChatEvent) + src/api/types.ts + src/components/AiChat.vue
      -- [ ] UX-2025-04 [P1] — **断线保文**(§4.2)。流式中断时已接收文本不丢弃:`stream_llm` mid-stream error 改返回 `Some(partial_text, ...)` + `incomplete: true` 标志;agentic 收到 incomplete → 正常入库 + emit AiCompleted(非 AiError)+ 追加系统提示「⚠ 响应因网络中断不完整」;前端 AI 气泡底显示「继续生成」按钮。关联 B-260615-05(流尽空内容报错)同改 stream_recv.rs。— crates/df-ai/src/stream_recv.rs + src-tauri/src/commands/ai/agentic.rs + src/components/AiChat.vue
      +- [x] ✅(batch59·2026-06-16·workflow wdlt0xfl6) UX-2025-04 [P1] — **断线保文**(§4.2)。保文核心落地:stream_recv.rs `StreamResult` enum(Complete/Partial/InitFailed 三分支),MidStream 失败(流中途 chunk error/idle timeout/有 partial_text 未 finished)返 `Partial{partial_text}` **不重试保文**;agentic.rs 收 Partial → 入库 assistant 消息 + emit AiCompleted(incomplete=true,非 AiError) + 追加系统提示「⚠ 响应因网络中断不完整」;useAiEvents.ts:177 补 `AiStreamRetry` case(消除死事件穿透) + AiCompleted case 处理 incomplete(系统提示气泡镜像后端);types.ts:204 `incomplete?:boolean`。对齐决策 a1(Init 可重试/MidStream 保文不重试) + UX-04 决策 a。**「继续生成」按钮暂未做**(保文核心硬需求已满足,按钮可复用 UX-2025-03 重试能力或按需补)。— src-tauri/src/commands/ai/stream_recv.rs + agentic.rs + src/composables/ai/useAiEvents.ts
       
       **第二批 — 显著提升日常效率**
       
      @@ -527,7 +548,7 @@
       
       - [x] AE-2025-01 [P0] — **批量审批**。同一轮多 pending 时,ToolCardList 顶部显示「全部批准(N) / 全部拒绝」按钮。改动:①`ToolCardList.vue` 加批量操作栏(监听 pending_approval 数量)②`useAiSend.ts` 新增 `approveAll(rejectAll)` 循环调 `ai_approve`。预估 0.5 天 — src/components/ToolCardList.vue + src/composables/ai/useAiSend.ts (第⑫批销账 06-15)
       - [x] AE-2025-02 [P0] — **审批计数器 + 跳转**。输入框上方/header 显示 `⏳ N 项待审批` 徽标,点击跳转到第一个 pending 卡片。改动:①`AiChat.vue` header 区域加审批徽标 ②`ToolCardList.vue` 暴露 `scrollToFirstPending` 方法。预估 0.5 天 — src/components/AiChat.vue + src/components/ToolCardList.vue (第⑬批销账 06-15)
      -- [ ] AE-2025-03 [P0] — **write_file diff 预览**。write_file 审批时若文件已存在,展示前后对比 diff 而非裸 content(content 可能几百行代码截断到 300 字符)。改动:①后端 `tool_registry.rs` write_file handler 执行前读旧文件返回 diff ②`ToolCard.vue` pending_approval + name=write_file 时渲染 diff 视图(红删绿增)。关联 F-260615-10(diff 工具)可复用。预估 1 天 — src-tauri/src/commands/ai/tool_registry.rs + src/components/ToolCard.vue
      +- [ ] AE-2025-03 [P0] — **write_file diff 预览** ⚠️**降级阻塞(2026-06-16·batch54 workflow 探索+主代核验)**:后端 write_file **未返 diff**(`tool_registry.rs:694` 结果 payload `{path,bytes_written,old_size,encoding}` 无 diff 字段,`generate_diff` 仅 patch_file 调用),前端 ToolCard 无 diff 可渲染,审批 payload `AiApprovalRequired`/`PendingApproval` 亦无 diff(只有 `args={path,content,encoding}` 纯新内容,拿不到旧文件)。需**后端先补**再追加前端渲染。**两条路径**(均 src-tauri 改动,跨 mod.rs/audit.rs/types.ts/AiChat.vue 领地):路径 B(推荐,满足审批预览诉求)审批 payload 加 `diff` 字段 + audit.rs:555 挂起审批前预读旧文件 + `generate_diff` 注入事件;路径 A 仅 write_file handler 补 diff 但结果阶段才有(审批时看不到,不满足)。前端部分待后端 diff 返回后追加 `
      ` 红删绿增渲染。原诉求:write_file 审批时若文件已存在,展示前后对比 diff 而非裸 content(content 可能几百行代码截断到 300 字符)。改动:①后端 `tool_registry.rs` write_file handler 执行前读旧文件返回 diff ②`ToolCard.vue` pending_approval + name=write_file 时渲染 diff 视图(红删绿增)。关联 F-260615-10(diff 工具)可复用。预估 1 天 — src-tauri/src/commands/ai/tool_registry.rs + src/components/ToolCard.vue
       
       **P1 — 增强控制力**
       
      @@ -563,7 +584,7 @@
       - [ ] F-260614-10 — 知识库 MCP Server + Tier 2/3 — 对外 MCP 暴露 + 分层存储(当前仅 Tier 1 全栈) — source:PROGRESS Sprint15
       - [ ] T-260614-11 — 全局#6 条件表达式引擎升级 — df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值 — source:PROGRESS 全局问题
       - [x] T-260614-12 — ~~df-ideas 死代码~~ ✅ WF-E 部分完成(capture.rs 删 CaptureInput/IdeaCapture 死码,保留 Idea/IdeaScores 共享实体;promotion/scoring/adversarial 内"两套 Recommendation/PromotionPolicy 死枚举"嫌疑 agent 未确认存在/保留为对外契约,本次未动,待复查)(06-14, commit 89da9fa)
      -- [ ] F-260615-01 — **[P1 功能增强]** HumanNode 审批节点支持自定义选项 + 单选/多选类型。现状:config `options: Vec` 已支持任意数量(2/3/…数量扩展已通),但 `decision` 是单 `String` 仅单选语义,`options` 空=自由文本。增强目标:不止「同意/拒绝」二选一,可配置 N 个候选项 + 单选(single)/多选(multiple)两种类型。**改动面**:①`df-core/events.rs` `WorkflowEvent::HumanApprovalRequest` 加 `select_type`、`HumanApprovalResponse` decision 单值→多值(`decisions: Vec` 或保留 decision 兼容 + 加 decisions)②`human_node.rs` config 解析 `select_type` + 校验(多选时每项 ∈ options,可加 min/max 选中数约束)③IPC `approve_human_approval` 签名 ④前端 `stores/project.ts` approve + `api/types.ts` 事件类型 + 审批弹窗 UI(单选 radio / 多选 checkbox)⑤单测改断言 + 新增多选/超限测。**注意**:向后兼容现 single 调用方,`select_type` 缺省 = single — source:用户需求(06-15),crates/df-nodes/src/human_node.rs + src-tauri/src/commands/workflow.rs + src/stores/project.ts — **✅已实施(a3cccb070fe9c8821,2026-06-16,待commit)**:6 文件契约向后兼容(events.rs SelectType 枚举 Single/Multiple 缺省 Single+HumanApprovalResponse decision+decisions 双字段/human_node.rs 校验 single len==1·multiple len≥1·∈options/workflow.rs IPC 加 decisions+select_type Option 缺省兼容/types.ts/project.ts approve/ProjectDetail.vue checkbox 多选 UI)。cargo check 0 err/df-nodes 21 test(含 4 新增多选)/vue-tsc 0 err。主代理核查契约向后兼容 + 6 文件边界
      +- [x] F-260615-01 ✅(2026-06-16,待commit a3cccb070fe9c8821) — **[P1 功能增强]** HumanNode 审批节点支持自定义选项 + 单选/多选类型。现状:config `options: Vec` 已支持任意数量(2/3/…数量扩展已通),但 `decision` 是单 `String` 仅单选语义,`options` 空=自由文本。增强目标:不止「同意/拒绝」二选一,可配置 N 个候选项 + 单选(single)/多选(multiple)两种类型。**改动面**:①`df-core/events.rs` `WorkflowEvent::HumanApprovalRequest` 加 `select_type`、`HumanApprovalResponse` decision 单值→多值(`decisions: Vec` 或保留 decision 兼容 + 加 decisions)②`human_node.rs` config 解析 `select_type` + 校验(多选时每项 ∈ options,可加 min/max 选中数约束)③IPC `approve_human_approval` 签名 ④前端 `stores/project.ts` approve + `api/types.ts` 事件类型 + 审批弹窗 UI(单选 radio / 多选 checkbox)⑤单测改断言 + 新增多选/超限测。**注意**:向后兼容现 single 调用方,`select_type` 缺省 = single — source:用户需求(06-15),crates/df-nodes/src/human_node.rs + src-tauri/src/commands/workflow.rs + src/stores/project.ts — **✅已实施(a3cccb070fe9c8821,2026-06-16,待commit)**:6 文件契约向后兼容(events.rs SelectType 枚举 Single/Multiple 缺省 Single+HumanApprovalResponse decision+decisions 双字段/human_node.rs 校验 single len==1·multiple len≥1·∈options/workflow.rs IPC 加 decisions+select_type Option 缺省兼容/types.ts/project.ts approve/ProjectDetail.vue checkbox 多选 UI)。cargo check 0 err/df-nodes 21 test(含 4 新增多选)/vue-tsc 0 err。主代理核查契约向后兼容 + 6 文件边界
       - [x] F-260615-02 ✅(2026-06-16,待commit) — **[P1 功能]** task 详情查看。✅已实施:get_task_by_id IPC(复用 TaskRepo::get_by_id ok_or_else 转 Result)+lib.rs 注册+taskApi.get+/tasks/:id 路由+TaskDetail.vue(11 字段:标题/描述/状态/优先级/关联项目 router-link 解析名/分支标签/负责人/基础分支/工作流定义/创建更新时间;复用 constants/project 标签+formatDate+watch route.params.id 重载)+Tasks.vue 列表项 @click router.push;TaskRecord TS 类型已存在无需新增;主代理核查 cargo check 0 err/vue-tsc 0 err/git diff 6 文件边界干净。现状:`src/views/Tasks.vue` 仅列表,无独立 TaskDetail 视图/路由(grep 仅 `ProjectDetail.vue` 嵌套任务命中,无独立详情页)。需求:点击 task 查看详情。**数据模型已就绪**(`df-storage/src/models.rs:53` `TaskRecord` 12 字段:id / project_id / title / description / status / priority / branch_name / assignee / workflow_def_id / base_branch / created_at / updated_at)。**改动面**:①新建 `src/views/TaskDetail.vue` 视图 + 路由(`router/index.ts` `/tasks/:id`)②task 详情 IPC(`get_task_by_id`,核对 `commands/task.rs` 现有 IPC 是否已有,无则补)③`Tasks.vue` 列表项点击 → 跳详情 ④详情页字段展示(title/description 渲染、status/priority 标签、关联项目名解析 project_id→name、branch/assignee 信息、时间戳)⑤可选:详情页内编辑(`update_task` IPC 已存在,FR-D6)。— source:用户需求(06-15),src/views/Tasks.vue + src/router/index.ts + src-tauri/src/commands/task.rs
       - [x] F-260615-03 ✅ truncated 标志 (第⑩批 2026-06-16) — ~~list 工具截断标志~~ (list_trash补漏) 原完整分页方案,对抗分析降级。现状:4 工具 `truncate(50)` 硬截断,AI 不知被截断会回复「不存在」。**对抗分析裁决**:数据量未到 50 条线(projects=5, tasks=61),完整分页 breaking change 风险 > 收益 → **降级为最小修**:不改返回结构(non breaking),只在 truncate 时加 `truncated: true` 标志 + 工具描述注明"最多返回 N 条"。未来数据量到时再升完整分页(F-03-full)。**改动面**:tool_registry.rs 4 handler 截断处加 truncated bool 返回 — src-tauri/src/commands/ai/tool_registry.rs:127-165,377-379 — source:用户需求(06-15)+对抗分析裁决(06-15)
       - [x] F-260615-04 ✅ 卡片分组增强 (第⑩批 2026-06-16) — ~~read_dir / read_file 工具卡片连续时折叠/收起~~ 提高信息密度。现状:AI 探查目录常连续调多个 read_dir + read_file(先列目录再读多个文件),每个结果独立卡片平铺,长列表/大文件内容占满屏幕,信息密度低。需求:相邻同类读取卡片支持折叠——默认收起只显摘要(如「read_dir: 12 项」「read_file: src/main.rs (234 行)」),点击展开看详情;或连续 N 个同类卡片归组折叠。**改动面**:①`ToolCard.vue` 加折叠态(`collapsed` ref + 摘要/详情双视图 + chevron 图标 + 高度过渡)②连续同类检测/归组(`ToolCardList.vue` 按 `tool.name` 分组,已有列表容器适合放分组逻辑)③摘要提取(read_dir 数项数 / read_file 文件名 + 行数,解析 result)④折叠态持久化(可选,localStorage 按 conv)。关联信息密度构想(memory: devflow-info-density-concept,卡片折叠是其中一环)。— source:用户需求(06-15),src/components/ToolCard.vue + src/components/ToolCardList.vue — **勘察完成(2026-06-16,wxflofhf2)**:feasible/plan 11 步跨 5 文件(ToolCardList/ToolCard/useAiSend/stores/ai/global.css)。risk 标低但实为 UX 新行为+改核心 ai 状态文件(useAiSend.ts/stores/ai.ts)+plan 细节有误(useAiSend composable 无 emit 方法)。**拆小或留待**:先做 ToolCardList 分组+单卡折叠摘要(限定不碰 useAiSend/stores),完整折叠交互归信息密度构想单独立项
      @@ -605,7 +626,7 @@
       - [ ] F-07 trait 下沉 df-ai-core — 设计完备 4 项决策全定稿,退路可放 df-core,解锁 F-03/F-01
       - [x] R-PD-2 ✅ ScriptNode 不注册 (第⑩批 2026-06-16) — ~~ScriptNode 不注册 script~~ 3 行删除封死攻击面,工作流当前纯演示无真实脚本需求
       - [x] F-260615-06 ✅ patch_file (第⑩批 2026-06-16) — ~~patch_file(edit_file)~~ 局部文件更新工具,补齐 AI 文件操作闭环。完整设计见 [patch_file工具设计-2026-06-15.md](./02-架构设计/patch_file工具设计-2026-06-15.md)(API/三层防御/边界情况/替代方案否决/实施步骤)。**核心**: old_text 精确匹配为主+line 辅助+Mutex 并发安全+expected_hash 指纹防脏写。第一批实现核心三件套(~50行)。— src-tauri/src/commands/ai/tool_registry.rs 新增 handler
      -- [ ] ARC-06 composable 循环依赖 — 提 aiShared.ts 破环最小改动(或合回 stores/ai.ts 备选)
      +- [x] ✅(batch56·2026-06-16·workflow wsxxurgbr+主代核查) ARC-06 composable 循环依赖 — **破环全闭环**:①`aiShared.ts` 下沉 findToolCall(反向扫描O(1))+4个审批计时器函数(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers+APPROVAL_TIMEOUT_MS常量) ②`useAiEvents` 删 findToolCall 导出+改 import 从 aiShared 取 ③`useAiSend` 删审批计时器实现(改从 aiShared re-export)+删 drainQueue 直接调用(改经 `ai-drain-queue` 事件总线桥接) ④`stores/ai.ts` 加 `initDrainQueueListener()` 启动事件监听。**B-260616-19 CSS 缺失**(Knowledge.vue 窄屏标题挤压,agent 未产出 .css/.vue style 变更),待补。— src/composables/ai/aiShared.ts + useAiEvents.ts + useAiSend.ts + stores/ai.ts
       - [x] CR-09 .ai-md 样式 5 份→全局 css 抽取 — 零风险减法 (第⑬批销账 06-15)
       - [x] F-09 status 对齐前后端 ✅ (第⑯批销账 06-15) — 前端 map 删 in_progress/paused/cancelled(project 维度无数据源/UI 入口/消费方),保留 planning/active/completed。constants/project.ts(三 map 同步删+兜底改字面量)+Dashboard.vue getProjectStage(switch 收敛)+i18n zh-CN/en projects.status & dashboard.stage。后端不动(df-core enum 7 值是独立大改,F-260614-09 专项)。**注**:DB 实际只产生 planning(4 个建项目入口硬编码),真实生命周期=planning+deleted_at 软删二态
       - [x] F-260615-03 ✅ truncated (第⑩批 2026-06-16) — ~~最小修(截断标志)~~ **从完整分页降级**:不改返回结构(non breaking),truncate 时加 `truncated: true`(→ 见上方 :469 完整条目,本条为对抗分析区引用,勿重复实施)
      @@ -663,12 +684,12 @@
       > 来源:后台代理扫描 62 个 markdown 文档发现的问题
       
       **P0 — 严重阻塞**
      -- [ ] B-260615-57 [P0→缺口收窄] — ~~文件工具功能缺口~~ **06-15 批次后缺口 6→2→0**:已补 `patch_file`(F-06 ✅) / `search_in_file`(F-07 ✅) / `file_info`(F-08 ✅) / `append_file`(F-09 ✅) / `search_files`(F-12 ✅) / `delete_file`(F-16 ✅ 第⑯批) / `rename_file`(F-15 ✅ 第⑮批销账 06-15)。**缺口清零**:`rename_file`/`move_file` 已补(F-260615-15,根治 AI rename 走 run_command 的中文路径静默失败)。原根因(迫使 LLM 滥用 write_file 放大 FR-S7 覆盖写入风险)已由 patch_file 缓解。— src-tauri/src/commands/ai/tool_registry.rs
      +- [x] B-260615-57 ✅(2026-06-16,缺口清零销账) [P0→缺口收窄] — ~~文件工具功能缺口~~ **06-15 批次后缺口 6→2→0**:已补 `patch_file`(F-06 ✅) / `search_in_file`(F-07 ✅) / `file_info`(F-08 ✅) / `append_file`(F-09 ✅) / `search_files`(F-12 ✅) / `delete_file`(F-16 ✅ 第⑯批) / `rename_file`(F-15 ✅ 第⑮批销账 06-15)。**缺口清零**:`rename_file`/`move_file` 已补(F-260615-15,根治 AI rename 走 run_command 的中文路径静默失败)。原根因(迫使 LLM 滥用 write_file 放大 FR-S7 覆盖写入风险)已由 patch_file 缓解。— src-tauri/src/commands/ai/tool_registry.rs
       
       **P1 — 重要缺陷**
       - [x] B-260615-39 [P1] ✅(第⑰批销账 06-15) — ~~complete() 超时重试机制缺失~~ 新建 retry.rs(189行+6单测 retry_with_backoff/AttemptOutcome/is_*_retryable)+openai/anthropic complete() 包裹重试(3次/指数1s→2s→4s/±20%jitter/30s总预算止损/可重试 timeout·connect·5xx·429,不重试4xx·auth·body)/不建LlmError枚举(零侵入trait保anyhow::Result)/不用middleware(手写loop)/流式stream()不动/Settings降级常量MAX_COMPLETE_ATTEMPTS=3。**顺带修真bug:Anthropic complete()无60s timeout(FR-R4只修OpenAI一半,建连后挂起无限hang)**。complete()4调用方全低频后台(标题/知识/扫描/AiNode)有兜底,主对话走stream()不受影响。cargo test -p df-ai 37 passed `complete()` IPC 调用仅加 60s 单请求超时,但无重试机制。根因:网络波动时直接失败,无自动重试兜底。影响:偶发网络错误导致用户操作失败需手动重试。修法:实现指数退避重试机制(最多 3 次,总时长不超过 30s),并在 Settings 可配重试开关 — src-tauri/src/commands/ai/commands.rs
       - [x] B-260615-40 [P1] ✅(第⑯批销账 06-15) — ~~审批按钮无状态反馈~~ ToolCard 加 approving ref+spinner+disabled(防重入)+130s 本地计时器兜底+watch tc.status 离开 pending_approval 复位;useAiSend approveToolCall 删乐观置 running(原致审批整块消失切骨架屏,语义错位)保 pending_approval 直到后端事件回流;useAiStream onStreamTimeout 注释更新。loading 就近持有 ToolCard 本地不污染 store status 流转 — src/components/ToolCard.vue + src/composables/ai/useAiSend.ts
      -- [ ] B-260615-41 [P1] — ~~工作流执行无进度指示~~ 长时间工作流执行时,用户不知道执行进度和剩余时间。根因:缺乏执行进度可视化。修法:在工作流详情页添加进度条 + 执行步骤提示 + 预估剩余时间 — src/views/WorkflowDetail.vue(需新建)
      +- [x] B-260615-41 [P1] ✅(batch35 销账 2026-06-16,对齐决策a轻量) — ~~工作流执行无进度指示~~ batch35 TaskDetail 进度内嵌已满足决策 a 轻量:复用现有 NodeStarted/NodeCompleted 事件(纯前端 `handleWorkflowEvent` 按 exec_id 过滤 dispatch,不新增后端 progress 事件),`.wf-progress` 区显示 `workflowStepRunning{node}`(当前步骤)+`workflowStepsProgress{done}/{total}`(步骤进度)+完成/失败提示,不新建独立 WorkflowDetail.vue(决策 a 是嵌入现有 TaskDetail 推进按钮区非独立页),i18n key taskDetail.workflowStepRunning/stepsProgress/completedHint/failedHint zh-CN+en 双语已加。**预估剩余时间属方案 b 完整版,决策 a 明确不做** — src/views/TaskDetail.vue
       - [x] ✅(batch34·2026-06-16·workflow wvr7mdo0p) B-260615-42 [P1] — ~~错误信息不一致~~ 同类错误在不同场景下返回的信息格式不统一。根因:错误处理分散在不同模块,缺乏统一规范。影响:用户和调试者难以理解错误含义。修法:制定错误码规范 + 统一错误格式 + 前端错误码映射 — src-tauri/src/commands/ai/mod.rs + src/api/types.ts
       
       **P2 — 中等问题**
      diff --git a/docs/待决策.md b/docs/待决策.md
      new file mode 100644
      index 0000000..fec8768
      --- /dev/null
      +++ b/docs/待决策.md
      @@ -0,0 +1,339 @@
      +# 待决策队列
      +
      +> **职责分离**:
      +> - **主会话**(汇总/巡检)→ 【写入】"当前队列",登记待人工决策项(背景 + 决策点 + 选项 a/b/c + 推荐)。
      +> - **用户**(人工)→ 【拍板】选选项或确认推荐,回填理由(可选)。
      +> - **主会话**(拍板后)→ 决策项回流 [todo.md](./todo.md) 推进 + 移"已决归档"。
      +>
      +> 这里**只放需人工决策项**(产品取舍 / 方向选型 / 排期 / 用户实测澄清)。不放可自主推进待办(见 [todo.md](./todo.md))、不放审查登记(见 [待审查.md](./待审查.md))。
      +> 来源:2026-06-16 从 todo.md 抽离③④C类(可自主批 AE-03/复核⑪/UX-04 留 todo 推进)。
      +
      +## 决策规则
      +
      +- 每条须:背景 + 决策点 + 选项 + 推荐 + 关联 todo。
      +- 用户回填:`✅ 决策:X`(选 + 理由可选)。
      +- 拍板后:主会话回流 todo 实施 + 移本文件"已决归档"。
      +- 暂缓项标 ⏸️,不删(条件变化可重启)。
      +
      +## 状态图例
      +
      +🟡 待决 / ✅ 已决(回填决策) / ⏸️ 暂缓(待条件)
      +
      +---
      +
      +## 当前队列
      +
      +### ③类 — 产品取舍(有 a/b/c 决策点,核心待拍板)
      +
      +#### CR-13-O1 in_progress 模板失败退回语义
      +- **背景**:in_progress 模板失败→`regression_target("in_progress")=Some("todo")`,但状态机 `backward_to_todo_rejected` 全拒(task_state_machine.rs:188-193)→回调 advance_task_atomic 被 InvalidState 拦截,任务原地保留 in_progress。
      +- **决策点**:in_progress 执行失败应如何退回?
      +- **选项**:
      +  - a: 接受现状(原地 in_progress)+ 补 UI 提示「执行失败,请手动处理」
      +  - b: 改 `regression_target("in_progress")=None` 对齐状态机(失败不自动退回,留 in_progress 等人介入)
      +  - c: 放开 todo 回退(破坏闸门,执行失败可回 todo 重来)
      +- **推荐**:**b**(对齐状态机现状,失败留 in_progress 显式待人介入,不破坏闸门语义)
      +- **关联**:todo CR-13-O1 / CR-260616-13
      +- **状态**:✅ 已决(2026-06-16)— **决策:b**。对齐状态机,失败留 in_progress 等人介入,blocked 状态已提供合法通道。
      +
      +#### DEC-260616-03 CR-04/CR-06 流式核心改造授权
      +- **背景**:splitBlocks 手写正则切块(CR-04,行中裸 ```/4+ backtick 嵌套切错)+ loadMarkdown 重算(CR-06)是流式渲染核心改造,中风险(回归可能)。
      +- **决策点**:是否授权推进流式核心改造?
      +- **选项**:
      +  - a: ①授权巡检推进(主代理重点核查流式渲染/审批/输入不破坏)
      +  - b: ②单独立项谨慎做(不混巡检快推,独立验证)
      +  - c: ③暂不碰(保留自研块级 memo 现状)
      +- **推荐**:**a ①**(自研 memo 已稳,CR-04/06 是增强非阻断,授权巡检推进+重点核查)
      +- **关联**:todo DEC-260616-03 / CR-260615-04
      +- **状态**:✅ 已决(2026-06-16)— **决策:a**。授权巡检推进,自研 memo 已稳,CR-04/06 是增强非阻断。
      +
      +#### CR-260615-04 splitBlocks→marked.lexer
      +- **背景**:splitBlocks 手写正则 `/```[^\n]*\n[\s\S]*?(?:```|$)/g` 不要求行首+固定 3 backtick,与 marked 围栏规则不一致;行中裸 ```/4+ backtick 嵌套围栏切错,前块缓存固化错误 html。
      +- **决策点**:是否改用 marked.lexer 原生切块?(依赖 DEC-260616-03 授权)
      +- **选项**:
      +  - a: 改 marked.lexer(机制2原生,根治切错)
      +  - b: 补正则边界(治标,维护成本)
      +  - c: 不改(现状可接受,极端输入才触发)
      +- **推荐**:**a**(根治,依赖 DEC-03 授权)
      +- **关联**:todo CR-260615-04 / DEC-260616-03
      +- **状态**:✅ 已决(2026-06-16)— **决策:a 改 marked.lexer**。DEC-260616-03 已决a 授权推进,batch57 workflow(w4h5ygko1) Agent B 已实施 AiChat.vue splitBlocks 改 marked.lexer(tokenize),vue-tsc 0err + cargo check 0 err。todo CR-260615-04 同步销账。
      +
      +#### F-260616-06 审批「未找到挂起的审批」体验
      +- **背景**:pending_approvals 内存态(commands.rs:130 remove None 报错)与前端刷新/重复点击不同步;重试场景旧 id 已被处理;前端 useAiSend.ts:221 IPC 失败不回滚 pendingApprovals,按钮残留。
      +- **决策点**:修法组合选哪些?
      +- **选项**:
      +  - a: 后端 ai_approve 幂等(找不到 pending 查审计表,已 executed/rejected 返回「已处理」成功非报错)
      +  - b: 前端审批按钮防抖(同一 id 短期多次只发一次 IPC)
      +  - c: 前端刷新后 pending 态正确同步(核对 useAiConversations.ts:119 边界)
      +- **推荐**:**a + b**(后端幂等治本 + 前端防抖治标,c 已部分由现有恢复逻辑覆盖)
      +- **关联**:todo F-260616-06
      +- **状态**:✅ 已决(2026-06-16)— **决策:a + b**。先 b 前端防抖止血(5 分钟改动),后 a 后端幂等治本。**子决策:实施顺序=A(先 b 防抖,后 a 幂等)**。
      +
      +#### F-260616-07 流式对话失败自动重试
      +- **背景**:流式 stream_llm 零重试(agentic.rs:231-238 返回 None 直接放弃);df-ai retry.rs 只覆盖非流式 complete();手工重试已存在(错误气泡「重试」按钮)。
      +- **决策点**(4 个子决策):
      +  - (a) 流中途失败如何重试:a1 只重试流前失败(简单稳妥)/ a2 流中途失败截断已流文本重请求(复杂)
      +  - (b) 重试计数器粒度:每条消息预算 N 次(倾向)/ 每轮独立
      +  - (c) 退避策略:复用 retry.rs(1s→2s→4s 倾向)/ 用户可配
      +  - (d) 重试期 UI:错误气泡内「重试 n/m」(倾向)/ 独立 toast
      +- **推荐**:**a1 + 每条消息预算 + 复用 retry.rs 退避 + 错误气泡内更新**
      +- **关联**:todo F-260616-07
      +- **状态**:✅ 已决(2026-06-16)— **决策:a1 + 每条消息预算3次 + 复用 retry.rs 退避 + 错误气泡内更新**。流前失败重试,不碰流中途。**子决策:不加 Settings UI,硬编码默认 3 次(A)**。
      +
      +#### F-260616-08 AI 数据获取工具突破 50 条上限
      +- **背景**:MAX_LIST_RESULTS=50 硬覆盖 6 工具(list_projects/tasks/ideas/trash/search_files/read_file 搜索),配套只给 has_more/truncated 提示无 offset 翻页;knowledge_search min(3) 钳死。
      +- **决策点**(4 子):
      +  - (a) 翻页范围:全量工具补 offset(对齐 read_file 统一)/ 仅 hot 工具(list_tasks/search_files)
      +  - (b) 翻页 limit 默认:沿用 50 / 提到 100-200
      +  - (c) knowledge_search 上限:20 / 50
      +  - (d) 是否加 get_count/list_all 探总量工具
      +- **推荐**:原推荐不加探总量;用户决策:**需加 get_count/list_all 探总量工具**(AI 需知总量做分页策略,has_more 仅告知"还有"不知多少)
      +- **关联**:todo F-260616-08
      +- **状态**:✅ 已决(2026-06-16)— **决策:全量 offset + 沿用 50 + knowledge 20 + 加探总量工具(get_count/list_all)**。用户要求 AI 具备探总量能力。
      +
      +#### F-260616-09 AiSession 单例→多会话并发架构
      +- **背景**:用户需求(3 条消息强化):「应用级支持同时具备多会话」+「多个会话窗口来回切,**并行执行**」。现状 `AiSession` 应用级单例(state.rs:164 `ai_session: Arc>`),messages/generating/stop_flag/pending_approvals 全一份;同一时刻只一个对话能跑。残留 bug:新建会话上下文残留(前端 newConversation 漏清 queue/generatingConvId/agentRound;后端 ai_conversation_create 漏清 agent_language/stop_flag)。
      +- **决策点**(5 个子决策):
      +  - (a) **推进路径**:A 先做隔离修复(补漏清字段维持单例,解残留,低风险) vs 直接 B 架构升级 — 倾向 **A 先**(残留真 bug 立即解,B 另立项)
      +  - (b) **B 多会话形态**:AiSession 整体 HashMap vs 仅拆 generating/stop_flag per-conv(messages/pending 已可按 conv reload) — 倾向**后者**(侵入小)
      +  - (c) **B 并发上限**:复用 llm_concurrency.global 限并发会话数防 token 暴增 vs 无上限 — 倾向**复用限并发**
      +  - (d) **多窗口 UI 形态**:(d1) 单面板侧栏会话列表切换+后台并行(改动小) vs (d2) 每会话独立 Tauri 窗口(useAiWindow.ts detached 已有基础可扩,改动大但「窗口来回切」语义最贴) — **待用户定**,倾向先 d1 后 d2
      +  - (e) **旧 loop 切换处理**:不退出各自跑完(真并发) vs 仍退出 — 倾向**不退出真并发**
      +- **选项**:
      +  - a: A 先做隔离修复(B 前置,解残留) + B 立项排期(UI 待用户定 d1/d2)
      +  - b: 直接 B 架构升级(一步到位,大改+高风险)
      +  - c: 仅 A 隔离修复(满足"切会话不残留",并行执行缓做)
      +- **推荐**:**a**(A 先解燃眉真 bug,B 按架构演进另立项;UI 形态 d 待用户拍板再定 B 的工作量)
      +- **关联**:todo F-260616-09 / memory aichat-arch-extensibility(AiSession 单例未动架构债)
      +- **状态**:✅ 已决(2026-06-16)— **决策:a**(A 先隔离修复 + B 立项)。**UI 形态:d1+d2 都做**——侧边栏可切换,也可多窗口打开。先 d1(侧栏切换+后台并行),再 d2(独立 Tauri 窗口,detached 已有基础)。
      +
      +#### F-260615-14 AiChat 气泡截图拷贝
      +- **背景**:截图拷贝(气泡 DOM→图片→剪贴板)需加依赖 html2canvas(~90KB)或 dom-to-image-more(~3KB)。
      +- **决策点**:选型?
      +- **选项**:
      +  - a: html2canvas(~90KB,CSS 支持有限但社区成熟)
      +  - b: dom-to-image-more(~3KB,轻量,CSS 支持更弱)
      +  - c: 不做(文本拷贝 F-13 已够,截图 ROI 低)
      +- **推荐**:**b**(做减法,3KB 轻量,气泡样式简单风险低)或 **c**(等用户反馈需求强度)
      +- **关联**:todo F-260615-14
      +- **状态**:✅ 已决(2026-06-16)— **决策:c 不做**。文本拷贝+代码块复制已覆盖核心需求,等用户真实反馈再定。
      +
      +---
      +
      +### ④类 — 设计方向(方案未定,需人定 A/B/C 选型)
      +
      +#### F-260614-03 灵感对抗评估接 LLM
      +- **背景**:df-ideas adversarial.rs `evaluate()` 纯启发式,接 LLM 需改签名注入 provider。
      +- **决策点**:注入方式?
      +- **选项**:
      +  - a: trait 解耦(df-ideas 定义 IdeaAnalyzer trait,推荐)
      +  - b: df-ideas 直接依赖 df-ai
      +  - c: closure 注入
      +- **推荐**:**a**(trait 解耦,但前置 F-07 trait 下沉)
      +- **关联**:todo F-260614-03 / F-07
      +- **状态**:🟡 待决(阻塞于 F-07)
      +
      +#### F-260616-07 阶段3 AI 执行闭环 + F-03 收口三件
      +- **背景**:advance_task/run_workflow 注册为 AI 工具 + AiNode 自审闸门;F-03 收口三件(advance_task 注册 + update_task 描述/schema 移 status + df-storage tasks 白名单移 status)须同步落地防 AI 工具行为不一致。
      +- **决策点**:阶段3 范围/优先级?(依赖 F-07 trait 下沉让 AiNode 可复用)
      +- **选项**:
      +  - a: 全量(三件 + AiNode 自审 + run_workflow 工具)
      +  - b: 仅 F-03 收口三件(防行为不一致,先小步)
      +  - c: 暂缓(等 F-07 + 真实推进场景)
      +- **推荐**:**b 先做 F-03 收口**(防现状行为不一致),AiNode 自审等 F-07
      +- **关联**:todo F-260616-07 阶段3 / F-07
      +- **状态**:🟡 待决(阻塞于 F-07)
      +
      +#### F-260616-08 阶段4 Git 集成
      +- **背景**:kind 字段 + git 闸门节点 + worktree 隔离。
      +- **决策点**:阶段4 是否进路线?(工作流当前内部基础设施无真实 CI/CD 场景,R-PD-12 已否决做实 run_workflow)
      +- **选项**:
      +  - a: 进路线(设计+实施)
      +  - b: 暂缓(等工作流有真实使用场景)
      +- **推荐**:**b 暂缓**(对齐 R-PD-12 否决,等真实场景)
      +- **关联**:todo F-260616-08 阶段4
      +- **状态**:✅ 已决(2026-06-16)— **决策:b 暂缓**。对齐 R-PD-12 否决,等工作流有真实 CI/CD 场景。
      +
      +#### T-260614-11 条件表达式引擎升级
      +- **背景**:df-workflow conditions 仅支持 true/false 字面量,升级为真表达式求值。引擎设计好但无前端 UI 消费方(demoDag edges 无 condition 字段)。
      +- **决策点**:求值器实现方式?(已暂缓:等工作流真实场景)
      +- **选项**:
      +  - a: 手写求值器
      +  - b: 复活 set_skipped
      +  - c: default false + warn(均认可)
      +- **推荐**:**⏸️ 暂缓**(无消费方,等 BuildNode 落地跑构建→测试→部署真实场景)
      +- **关联**:todo T-260614-11
      +- **状态**:⏸️ 暂缓
      +
      +#### F-260614-08 决策治理产品化
      +- **背景**:活契约/规格契约自检机制(当前散落文档机制)产品化为可操作功能。
      +- **决策点**:产品化形态?(规格契约机制 devflow-spec-contract-mechanism 文档已定稿,落地链②-⑤未做)
      +- **选项**:
      +  - a: 独立功能页(规格契约自检 UI)
      +  - b: 嵌入现有流程(锚点+AI自检+子代理活契约化)
      +  - c: 暂缓(机制先行文档化,产品化等需求)
      +- **推荐**:**c 暂缓**(机制文档已定稿,产品化等真实需求驱动)
      +- **关联**:todo F-260614-08 / memory devflow-spec-contract-mechanism
      +- **状态**:✅ 已决(2026-06-16)— **决策:c 暂缓**。机制文档已定稿,产品化等真实需求驱动。
      +
      +#### F-260614-09 项目 status 字段治理
      +- **背景**:status 状态机规范化(planning/active/archived 等枚举约束)。F-09 前端已对齐(planning/active/completed),后端 df-core enum 7 值是独立大改。
      +- **决策点**:后端 status 枚举收口范围?
      +- **选项**:
      +  - a: 后端 enum 收窄对齐前端(planning/active/archived)
      +  - b: 双字段(status 生命周期 + stage 开发阶段,已否决过度设计)
      +  - c: 暂缓(DB 实际只产生 planning + deleted_at 软删二态,现状可接受)
      +- **推荐**:**c 暂缓**(DB 实际二态,收口 ROI 低,等真实多态需求)
      +- **关联**:todo F-260614-09 / F-09
      +- **状态**:✅ 已决(2026-06-16)— **决策:c 暂缓**。DB 实际只产生 planning 二态,收口 ROI 低。
      +
      +#### F-260614-10 知识库 MCP Server + Tier 2/3
      +- **背景**:对外 MCP 暴露 + 分层存储(当前仅 Tier 1 全栈)。
      +- **决策点**:MCP 暴露范围 + Tier 2/3 存储选型?
      +- **选项**:
      +  - a: 全量(MCP Server + Tier2 向量 + Tier3 冷归档)
      +  - b: 仅 MCP Server(对外暴露 Tier1)
      +  - c: 暂缓(等知识库真实外部消费需求)
      +- **推荐**:**c 暂缓**(Tier1 全栈已满足当前,等外部消费需求)
      +- **关联**:todo F-260614-10
      +- **状态**:✅ 已决(2026-06-16)— **决策:c 暂缓**。Tier1 全栈已满足当前,等知识库真实外部消费需求。
      +
      +---
      +
      +### 重投入排期(设计完备待实施,需人定优先级/时机)
      +
      +> 这些项方案已定(📐设计完成/完备),非"待决策方向",而是"待排期投入"。需用户定做不做/何时做/优先级。
      +
      +#### F-260614-07(F-07) df-ai-core trait 下沉拆 crate
      +- **背景**:架构前置,4 项决策全定稿。退路可放 df-core。解锁 F-03/F-01。
      +- **设计状态**:📐 完备(决策记录已收敛:原 F-03 A/B/C 作废,统一全局 AI trait 下沉独立 crate)
      +- **决策点**:排期?(解锁多项后续,但拆 crate 重投入)
      +- **推荐**:原推荐优先(可先放 df-core 过渡);用户决策:**拆独立 crate df-ai-core**(不放 df-core,一步到位)
      +- **关联**:todo F-260614-07 / F-03
      +- **状态**:✅ 已决(2026-06-16)— **决策:拆独立 crate df-ai-core**。不放 df-core 过渡,LlmProvider trait + 核心数据结构下沉独立 crate,df-ai/df-ideas/df-nodes 均依赖 df-ai-core。
      +
      +#### F-260614-01 模型能力系统 Phase 1
      +- **背景**:ModelCapability 数据模型 + ModelRouter 重写 + 7 调用点接入 + Settings 模型池 UI + AiChat 模型下拉。按需求自动匹配合适模型。
      +- **设计状态**:📐 设计完成
      +- **决策点**:排期?(依赖 F-07 trait 下沉)
      +- **推荐**:F-07 后接续
      +- **关联**:todo F-260614-01 / F-07
      +- **状态**:🟡 待排期(阻塞于 F-07)
      +
      +#### F-260614-06 导入历史项目(scan 第二步)
      +- **背景**:description 走 LLM(复用 scan_project_with_ai)+ 采样保留内容图(待 F-260614-05 多模态)+ monorepo 一层 + 批量并发;抽 create_with_binding 缓解 TODO。
      +- **设计状态**:📐 设计定稿(6 决策 06-14)
      +- **决策点**:排期?(依赖 F-260614-05 多模态采样)
      +- **推荐**:F-05 多模态后接续
      +- **关联**:todo F-260614-06 / memory devflow-project-path-binding
      +- **状态**:🟡 待排期
      +
      +#### F-260614-02 技能联想「使用」
      +- **背景**:首批 3 类联想已做,联想后实际触发/执行技能未实现。
      +- **决策点**:「使用」语义?(执行本机 claude 技能?AI 调用?需明确)
      +- **推荐**:需先澄清语义(关联 memory aichat-skill-slash-autocomplete)
      +- **关联**:todo F-260614-02
      +- **状态**:🟡 待澄清语义
      +
      +#### F-260614-04 多 Provider 负载均衡池
      +- **背景**:备用模型/多账号聚合,全局容量=min(各 provider 上限之和, global_cap)。
      +- **决策点**:排期?(增强非阻断)
      +- **推荐**:低优先(等 F-01 模型能力后)
      +- **关联**:todo F-260614-04
      +- **状态**:🟡 待排期
      +
      +#### F-260614-05 模型能力系统 Phase 2(多模态)
      +- **背景**:ChatMessage.content: String → Vec(Text/Image);前端粘贴/拖拽图片;vision 模型自动路由。
      +- **决策点**:排期?(解锁 F-06 导入采样)
      +- **推荐**:中等(解锁 F-06)
      +- **关联**:todo F-260614-05 / F-01
      +- **状态**:🟡 待排期
      +
      +#### AE-2025-04 会话级授权(Session Trust)
      +- **背景**:同会话内用户批准过某类操作后,后续同类自动放行;换会话清空重审。首批 write_file + run_command。设计已定(目录级粒度 + 仅写+执行首批 + Webhook 走独立 execution_token)。
      +- **设计状态**:📐 设计定(2026-06-16)
      +- **决策点**:排期?(涉及 mod.rs AiSession + audit.rs 审批链)
      +- **推荐**:中高(写→跑→看→改闭环高频连续操作,体验提升大)
      +- **关联**:todo AE-2025-04
      +- **状态**:🟡 待排期
      +
      +#### T-260614-06 Settings.vue 拆 panel 子组件
      +- **背景**:1042 行 god file,4 大功能域(AI 模型/Provider 表单/连接管理/通用设置)可拆 src/components/settings/。评估非低风险(纯重构零功能价值,新建 4 子组件 + props/emits + CSS 拆)。
      +- **决策点**:排期?(单独立项做更稳)
      +- **推荐**:低(纯重构零功能,等 god file 痛点加剧)
      +- **关联**:todo T-260614-06
      +- **状态**:🟡 待排期
      +
      +---
      +
      +### ④类续 — 设计方向(新增·batch55迁移)
      +
      +#### UX-2025-04 断线保文(incomplete标志)
      +- **背景**:流式中断时已接收文本不丢弃;stream_llm mid-stream error 改返回 partial_text + incomplete 标志;agentic 收 incomplete→正常入库+AiCompleted(非AiError)+「⚠ 响应因网络中断不完整」+「继续生成」按钮。
      +- **决策点**:改 stream_recv.rs 流式核心是否做?(中风险,回归可能)
      +- **选项**: a:授权推进(关联 DEC-260616-03 已决a) / b:暂不碰
      +- **推荐**:**a**(DEC-03 已授权,断线保文是用户体验硬伤)
      +- **关联**:todo UX-2025-04 / DEC-260616-03
      +- **状态**:✅ 已决(2026-06-16)— **决策:a**(授权推进。DEC-03 已授权,断线保文是用户体验硬伤)。
      +
      +#### ARC-260615-03 df-execute 归宿
      +- **背景**:df-execute 非空壳(shell.rs execute() 已完整实现+被 tool_registry.rs run_command 复用)。原todo「76行/1函数+TODO」基于 lib.rs 空判过时。
      +- **决策点**:合并进 df-nodes(破职责分离+动依赖树) vs 维持独立?
      +- **选项**: a:维持独立(职责清晰:节点定义vs执行运行时) / b:合并df-nodes(减少crate数)
      +- **推荐**:**a**(维持独立,转架构维护记录非清债)
      +- **关联**:todo ARC-260615-03
      +- **状态**:🟡 待决
      +
      +#### ARC-260615-07 中期技术债7件
      +- **背景**:df-core改名df-types / IPC层抽取df-app / AI loop下沉df-ai / 3 view绕store调api / 类型契约ts-rs代码生成 / AiSession多会话 / AppState分组 / IPC命名统一。
      +- **决策点**:哪些值得投?哪些暂缓?
      +- **选项**: a:逐项评估后挑高ROI的做 / b:全部暂缓(等业务功能优先)
      +- **推荐**:**b 暂缓**(当前业务功能未饱和,技术债不急)
      +- **关联**:todo ARC-260615-07
      +- **状态**:✅ 已决(2026-06-16)— **决策:a**(逐项评估挑高 ROI 的做,用户要求推进非暂缓)。
      +
      +#### B-260615-41 工作流执行无进度指示
      +- **背景**:长时间工作流执行用户不知进度/剩余时间。需进度条+步骤提示+预估剩余时间。
      +- **决策点**:实现深度?纯前端 vs 后端emit事件驱动
      +- **选项**: a:轻量(WorkflowDetail.vue 进度条+步骤文本,后端复用已有NodeStarted/Completed事件) / b:完整(后端新progress事件+预估算法)
      +- **推荐**:**a 轻量**(复用现有事件,最小改动)
      +- **关联**:todo B-260615-41
      +- **状态**:✅ 已决(2026-06-16)— **决策:a 轻量**(复用现有 NodeStarted/Completed 事件,纯前端消费)。
      +
      +#### B-260615-43 无撤销重做功能
      +- **背景**:AI操作(delete_project等)无撤销。操作历史栈+撤销/重做UI。
      +- **决策点**:撤销范围?全局vs仅AI操作?
      +- **选项**: a:仅AI工具操作(delete_task/delete_project/restore/run_command等) / b:全局(含项目/任务CRUD) / c:暂缓(二次确认已部分覆盖)
      +- **推荐**:**c 暂缓**(High-risk工具已有二次确认AE-05,撤销复杂度高)
      +- **关联**:todo B-260615-43
      +- **状态**:🟡 待决
      +
      +### C类 — 用户实测/澄清(需用户操作给反馈,主会话无法代办)
      +
      +#### S-260615-01 aichat API Key 401 根因
      +- **待用户操作**:跑直连 curl 测试,区分 key/provider_type/base_url/model 哪项错。见 [aichat-apikey-401排查-2026-06-15.md](./09-问题排查/aichat-apikey-401排查-2026-06-15.md)「验证步骤」。
      +- **背景**:代码链路全对(保存 keyring/读取/鉴权头/URL 拼接),401 来自服务端非 devflow bug。根因四选一。
      +- **状态**:🟡 待用户反馈
      +
      +#### S-260614-01 「显示多开」需求澄清
      +- **待用户操作**:截图确认。用户报"设置勾选显示多开但 AiChat 未显示",全 src grep 零命中,疑似旧版本/指分离窗口/想新增开关。
      +- **状态**:🟡 待用户澄清
      +
      +#### S-260614-02 审批可见性实测重评
      +- **待用户操作**:实测 tc 卡片是否渲染。B-01 修复后 pending_approvals 内存态不再被 switch 清空,前端查询有数据,但 state.pendingApprovals 在 AiChat.vue 是否有兜底渲染仍需实测。
      +- **选项**:A 加兜底渲染 / B 实测确认(去重:AR-3 修完已缓解,改实测重评)
      +- **状态**:🟡 待用户实测
      +
      +#### T-260614-01 多项 tauri dev 实测
      +- **待用户操作**:Sprint 9/10/14/15/16/18 多项未 tauri dev 实测 — 评分 IPC 缩放 / update_full / promote_idea / Store getter / token 落库 / 知识库 Tier 1 全栈 / LLM 并发 Semaphore / 知识生命线(#54 跟踪)。
      +- **状态**:🟡 待用户实测
      +
      +#### T-260614-02 切对话不中断路由实测
      +- **待用户操作**:部分场景运行时实测(A 路线场景 2/3)。
      +- **状态**:🟡 待用户实测
      +
      +---
      +
      +## 已决归档
      +
      +(空 — 用户拍板后移此区)
      diff --git a/docs/待审查.md b/docs/待审查.md
      index b50814a..bc716b5 100644
      --- a/docs/待审查.md
      +++ b/docs/待审查.md
      @@ -23,8 +23,101 @@
       
       ## 当前队列
       
      +### CR-260616-35 batch59 CR-34-1 守卫 + CR-30 流式重试对齐决策a1 + UX-2025-04 断线保文 — 待审
      +
      +- **范围**: 3 agent 合并批(workflow wdlt0xfl6,主代独立核查全过)。①**CR-34-1** useAiPanel.ts:102 守卫加有效性检查 `|| !list.some(p=>p.id===activeProvider)`(deleted provider 残留回落 DB is_default) ②**CR-30-1/CR-30-2 + UX-2025-04 合并**:stream_recv.rs `StreamResult` enum(Complete/Partial/InitFailed 三分支) + `classify_status_or_class`(4xx Fatal/5xx+429+timeout Retryable 镜像 retry::is_status_retryable) + agentic.rs 重试对齐决策 a1(Init 失败重试复用 retry::backoff_delay 1s→2s→4s±20%jitter + 30s 总预算 retry_deadline + Fatal 分类 4xx 立即放弃; MidStream Partial 不重试保文→入库 + AiCompleted(incomplete=true) + 系统提示「响应因网络中断不完整」) + 删 3 处误导注释(agentic.rs:12/commands.rs/state.rs) + retry.rs `pub mod` + `backoff_delay`/`is_status_retryable` 改 `pub` 暴露供 src-tauri 复用 + useAiEvents.ts:177 补 `case 'AiStreamRetry'`(消除死事件穿透) + AiCompleted case 处理 incomplete + types.ts:204/mod.rs `incomplete?:boolean` 字段(所有 emit 点补 None 向后兼容) + i18n aiStreamRetry/responseIncomplete 双语。
      +- **维度**: 流式核心中风险(重试循环正确性/保文语义/跨 crate pub 暴露影响面) + 前端事件消费完整性 + 决策 a1 对齐度(Init 可重试 ≤max_retries+jitter+30s+Fatal, MidStream 不重试) + B-260615-05 保留(空文本无 finished 仍 InitFailed 交重试)。
      +- **commit**: 待提交(累积 ~30 文件攒批)。
      +- **主代独立核查**: ✅ 全过(useAiPanel:102 守卫 / stream_recv:97 enum 三分支 + 注释 / agentic:242 retry_deadline + :274 Partial 不重试 + :286 InitFailed 重试 + :320 backoff_delay 复用 + :360 if incomplete 保文入库 + :400 emit incomplete:Some(true) / useAiEvents:177 AiStreamRetry case / types:204 incomplete / retry.rs:62+74 pub + agentic:14 `use df_ai::retry` 跨 crate 通)。
      +- **审查 agent 待复审重点**: ①stream_recv StreamResult 三分支边界(Partial vs InitFailed 空文本路径,B-260615-05 保留点) ②agentic 重试循环 permit RAII 释放/边界(max_retries=0/guard.reset) ③跨 crate retry pub 暴露是否过度(retry_with_backoff 仍 pub(crate)) ④AiCompleted incomplete 前端消费完整性(系统提示气泡镜像后端) ⑤UX-2025-04「继续生成」按钮**未做**(保文核心 ✅,按钮可复用 UX-2025-03 重试能力或按需补)。
      +
      +---
      +
       ## 已审归档
       
      +### CR-260616-34 batch58 F-260616-10 provider 默认值重启失效 bug — ⚠ 已审(PASS-WATCH)
      +
      +- **审查时间**: 2026-06-16(主线程兜底·agent 撞 GLM 限流 1305 · grep/read 核验源码 + 类型确认)
      +- **总体结论**: ⚠ **PASS-WATCH** — 🔴0 🟡1 ⚪3(核心 bug 双恢复修复 OK,已删除 provider 残留守卫 med 待修)
      +- **逐件核验**:核心 bug 修复 ✅(重启 activeProvider=null → useAiPanel.ts:53-54 模块级 savedProvider 同步恢复 + loadProviders:101-104 DB is_default 兜底,双恢复链路)/ is_default 字段确认 ✅(types.ts:177 AiProviderConfig.is_default boolean,ai_list_providers 返回)/ cycleProvider 兜底 ✅(AiChat.vue:1184 startId=activeProvider||is_default||null 三级兜底,L1187 Math.max(idx,0) 防 -1 越界)。
      +- **🟡 CR-34-1(med,已删除 provider 残留未校正)**:useAiPanel.ts:101 `if (!state.activeProvider)` 守卫只查 null 不查有效性。localStorage savedProvider 指向已删除 provider(L54 设 activeProvider=deleted-id)时,loadProviders 守卫 false 跳过 DB is_default 恢复 → activeProvider 残留无效 id → cycleProvider:1185 findIndex=-1 → :1187 next=ps[1] 跳过 ps[0],且潜在消费方(发消息读 activeProvider 调后端)用无效 id。修法:守卫改 `if (!state.activeProvider || !list.some(p => p.id === state.activeProvider))` 残留无效时回落 DB is_default。
      +- **⚪ low**:1(L52 注释「以 DB 为准」与行为不符,实际 savedProvider 优先 DB 兜底,行为合理注释误导)/ 2(setProvider:121 void appSettings.set 持久化失败静默,仅影响重启恢复可接受)/ 3(多窗口/分离窗口 activeProvider 一致性:模块级 state 各 webview 独立,主 setProvider 写 SQLite 分离窗口 state 滞后,既存架构非 CR-34 引入)。
      +- **待修项回流 todo**: **CR-34-1**(med,useAiPanel.ts:101 已删除 provider 残留守卫改查有效性)。
      +
      +---
      +
      +### CR-260616-33 batch57 CR-260615-04 splitBlocks→marked.lexer() — ✅ 已审(PASS)
      +
      +- **审查时间**: 2026-06-16(主线程兜底·agent 撞 GLM 限流 1305 · grep/read 核验源码)
      +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪3
      +- **逐件核验(全 PASS)**:splitBlocks(AiChat.vue:671-691 marked.lexer 切块,L672 getMarked 取实例 + L676 未就绪兜底 `return [text]` 不引入正则 + L677 lexer tokenize + L678-689 for 循环)/ code token 整段一块(L681-685 `type==='code'` + raw.trim 守卫空代码块跳过 + continue)/ 非代码 token 按 `\n{2,}` 切段(L688 split + b.trim 守卫)/ 空 token 非 string raw 防御(L679 `typeof string` continue)/ 空兜底(L690 `blocks.length ? blocks : [text]`)/ 签名不变(`splitBlocks(text):string[]` + renderStreamingBlocks:723 调用)/ 切块边界与 parse 渲染边界一致(同 marked 实例同围栏规则,CR-260615-04 根治目标达成)/ 流式路径回归(renderStreamingBlocks:718-733 + parseBlock/parseBlockNoCache 块缓存机制不变)。
      +- **⚪ low**:1(lexer 全文 tokenize 性能略高于旧正则,rAF 节流+块缓存吸收,流式实测无影响)/ 2(末块未闭合围栏场景 lexer 可能把后续段落吞进 code raw,流式中间态短暂切块不准,末块重 parse 修正)/ 3(L690 空兜底 lexer 空文本返空数组时回退原文)。
      +- **待修项回流 todo**: **无**(核心目标切块/渲染边界一致达成,防御充分)。
      +
      +---
      +
      +### CR-260616-32 batch57 B-260619 Knowledge.vue CSS窄屏标题挤压 — ✅ 已审(PASS)
      +
      +- **审查时间**: 2026-06-16(独立审查 agent · 亲跑 vue-tsc EXIT 0 + grep/read 核验源码)
      +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪3
      +- **逐件核验(全 PASS)**:min-width:0 传播链完整(L576 kn-detail-panel → L622 detail-head → L623 detail-title-row flex:1 → L625 detail-title,窄屏标题可正常收缩)/ 760px 断点 column-wrap 与 grid 兼容(L730 kn-layout 单列降级 + L731 detail-head flex-direction:column 无冲突)/ 按钮换行间距(L626 detail-actions gap 6px + flex-wrap:wrap,L732 窄屏 width:100%)。
      +- **⚪ low**:1(L623 `.detail-title-row { overflow:hidden }` 冗余,word-break 多行标题 height auto 自适应不真裁断,语义不清,建议删保留 min-width:0+flex:1)/ 2(L733 窄屏字号降级仅覆盖 .detail-title 18→15px,编辑态 .edit-input-title L692 固定 16px 未同步,窄屏编辑态 16px 反超展示态 15px)/ 3(18→15px 偏激进,详情主标题窄屏建议 ≥16px)。
      +- **待修项回流 todo**: **无**(三 low 风格/一致性微调非阻断)。
      +
      +---
      +
      +### CR-260616-31 batch56 F-260616-08 分页 offset+探总量 — ✅ 已审(PASS)
      +
      +- **审查时间**: 2026-06-16(主线程兜底·agent 撞 GLM 限流 1305 · grep/read 核验源码形态;**未亲跑 cargo check**,标准 API 语法无编译风险,待 agent GLM 恢复补 cargo 验证)
      +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪2
      +- **逐件核验(全 PASS)**:①offset 边界(tool_registry.rs:216/237/254 offset `as_u64().unwrap_or(0)`→usize,JSON 负数 as_u64 None→0,skip(offset).take(limit) 超 total 取空不 panic;has_more `(offset+page.len())Result>`,:224 unwrap_or_default Err→None→:231 Err,DB 故障不误判已处理)。
      +- **⚪ low**:1(pending 审计记录 + 内存 pending_approvals 丢失→:231 Err「未找到挂起的审批」文案略困惑,实际审批上下文已不可恢复 Err 正确,文案对有 pending 审计记录场景不精确)/ 2(防抖 _pendingApprovalIds 模块级跨调用累积,finally 清理可靠无泄漏,机制说明非问题)。
      +- **待修项回流 todo**: **无**(三维度全 PASS)。
      +
      +---
      +
      +### CR-260616-27 batch54 复核-新⑪ detach 清主窗口 state 看门狗幽灵(useAiWindow.ts) — ✅ 已审(PASS)
      +
      +- **审查时间**: 2026-06-16(主线程兜底·agent 撞 GLM 限流 1305 · grep/read 核验 + vue-tsc EXIT 0)
      +- **总体结论**: ✅ **PASS** — 🔴0 🟡0 ⚪0
      +- **逐件核验(全 PASS)**:detachPanel(useAiWindow.ts:28-38 快照在清 state 前写入 — L28-31 localStorage.setItem df-ai-gen/df-ai-text → L37-38 清 state.streaming/generatingConvId,顺序正确)/ state 独立性(主/分离窗口独立 webview JS context,resumeInDetached:87-119 靠 localStorage df-ai-gen/df-ai-text 重建 streaming L116-117,反证清主窗口 state 不影响分离窗口生成态)/ watchdog 兜底(stopListener clearStreamWatchdog 已由 CR-260615-24 兜底,本次仅清 state 视觉残留,注释:32-36 说明链路)/ reattach/closeDetachedWindow 清快照(L82-83/L125-126)。
      +- **待修项回流 todo**: **无**(全件 PASS)。
      +
      +---
      +
       ### CR-260616-26 batch53 F-05 去重根治(audit.rs High risk 审批前缓存命中去重) — ✅ 已审(PASS)
       
       - **审查时间**: 2026-06-16(独立审查 agent · 亲跑 cargo + vue-tsc 双 EXIT 0 **附 log 末尾证据** + grep/read 核验全件 + 边界核验)
      diff --git a/src-tauri/src/commands/ai/agentic.rs b/src-tauri/src/commands/ai/agentic.rs
      index a41fc34..33190b9 100644
      --- a/src-tauri/src/commands/ai/agentic.rs
      +++ b/src-tauri/src/commands/ai/agentic.rs
      @@ -9,6 +9,9 @@ use tokio::sync::Mutex;
       use df_ai::ai_tools::AiToolRegistry;
       use df_ai::context::TokenEstimator;
       use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
      +// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
      +// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
      +use df_ai::retry;
       
       use df_storage::db::Database;
       use df_storage::models::AiProviderRecord;
      @@ -18,7 +21,7 @@ use crate::state::{AppState, LlmConcurrency};
       use super::conversation::{save_conversation, TokenAccumulator};
       use super::knowledge_inject::maybe_spawn_extraction;
       use super::prompt::{build_system_prompt, get_active_provider};
      -use super::stream_recv::stream_llm;
      +use super::stream_recv::{stream_llm, StreamResult};
       use super::title::{ensure_conversation_title, spawn_ensure_title};
       use super::audit::process_tool_calls;
       
      @@ -32,6 +35,14 @@ use super::{AiChatEvent, AiSession, ErrorType};
       /// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。
       pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
       
      +/// 流式对话失败自动重试默认次数(F-260616-07 / 决策 a1)
      +///
      +/// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) +
      +/// retry::is_status_retryable Fatal 分类(4xx 非429 立即放弃) + 30s 总预算。
      +/// 只重试流前失败(Init Err:未输出任何 token);流中途失败(MidStream:已输出
      +/// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。
      +pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
      +
       // ============================================================
       // B-260615-09: generating 状态 RAII guard
       // ============================================================
      @@ -99,6 +110,7 @@ pub(crate) async fn run_agentic_loop(
           knowledge_config: crate::state::KnowledgeConfig,
           llm_concurrency: LlmConcurrency,
           max_iterations: usize,
      +    max_retries: usize,
       ) {
           // B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
           let mut guard = GeneratingGuard::new(session_arc.clone());
      @@ -170,7 +182,7 @@ pub(crate) async fn run_agentic_loop(
                   spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
                   guard.reset().await;
                   // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
      -            let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
      +            let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
                   return;
               }
       
      @@ -208,34 +220,187 @@ pub(crate) async fn run_agentic_loop(
               };
       
               // 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补)
      +        // 注:F-260616-07 重试循环内每次重建 retry_request(因 provider.stream 消费 body),
      +        // 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。
               let estimated_prompt: u32 = {
                   let est = TokenEstimator::default();
                   messages.iter().map(|m| est.estimate_message(m)).sum()
               };
       
      -        let request = CompletionRequest {
      -            model: provider_config.default_model.clone(),
      -            messages,
      -            temperature: Some(0.7),
      -            max_tokens: Some(8192),
      -            stream: true,
      -            tools: if tool_defs.is_empty() { None } else { Some(tool_defs.clone()) },
      -            tool_choice: None,
      -        };
      -
               // LLM 并发限流(全局 + 单对话双层),仅覆盖 stream_llm 调用本身;
               // 工具执行(process_tool_calls)是本地操作无 RPM 成本,permit 在 stream 后立即释放避免占槽
      +        //
      +        // CR-30-1 / F-260616-07 / 决策 a1: 流前失败(Init Err)重试,流中途失败(MidStream
      +        // Partial)不重试保文。重试退避复用 retry::backoff_delay(1s→2s→4s±20% jitter) +
      +        // retry::is_status_retryable Fatal 分类(stream_recv classify_status_or_class 镜像,
      +        // 4xx 非429 立即放弃) + 30s 总挂钟预算。重试期间持有 permit 不释放(防新请求挤占)。
               let _global_permit = llm_concurrency.acquire_global().await;
               let _per_conv_permit = llm_concurrency.acquire_per_conv().await;
      -        // 流式接收(内部处理 idle timeout / 断连检测 / 停止信号)
      -        let (full_text, tool_calls_acc, round_usage) = match stream_llm(&*provider, request, &app_handle, &stop_flag, ¬ify, &conv_id).await {
      -            Some(result) => result,
      -            None => {
      -                // 错误已在 stream_llm 中 emit,直接结束
      -                guard.reset().await;
      -                return;
      +
      +        // 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。
      +        // 超预算直接放弃重试交最终错误/保文路径。
      +        let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
      +
      +        let (full_text, tool_calls_acc, round_usage, incomplete) = {
      +            // outcome 累积最后一次成功/保文结果(Complete 或 Partial)。InitFailed 不写入,
      +            // 走重试或耗尽 return。
      +            let mut outcome: Option<(String, std::collections::HashMap, df_ai::provider::TokenUsage, bool)> = None;
      +
      +            for retry_attempt in 0..=max_retries {
      +                // 每次重试重建 request(CompletionRequest 无状态,但 provider.stream() 内部可能消费 body)
      +                let retry_request = CompletionRequest {
      +                    model: provider_config.default_model.clone(),
      +                    messages: {
      +                        let session = session_arc.lock().await;
      +                        let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
      +                        let (history_msgs, _trimmed) = session.messages.build_for_request(sys_tokens);
      +                        let mut msgs = vec![ChatMessage::system(&system_prompt)];
      +                        msgs.extend(history_msgs);
      +                        msgs
      +                    },
      +                    temperature: Some(0.7),
      +                    max_tokens: Some(8192),
      +                    stream: true,
      +                    tools: if tool_defs.is_empty() { None } else { Some(tool_defs.clone()) },
      +                    tool_choice: None,
      +                };
      +
      +                match stream_llm(&*provider, retry_request, &app_handle, &stop_flag, ¬ify, &conv_id).await {
      +                    StreamResult::Complete { text, tool_calls, usage } => {
      +                        // 正常完成(流尽 + finished,或用户主动停止)。incomplete=false。
      +                        outcome = Some((text, tool_calls, usage, false));
      +                        break;
      +                    }
      +                    StreamResult::Partial { text, tool_calls, usage } => {
      +                        // CR-30-2 / UX-2025-04 / 决策 a1: 流中途失败保文,**不重试**。
      +                        // 已 emit AiTextDelta(前端 currentText 已累积),此处保文入库 +
      +                        // AiCompleted(incomplete=true) + 系统提示。currentText 不混乱(未重试无追加)。
      +                        tracing::warn!(
      +                            conv_id = %conv_id,
      +                            text_len = text.len(),
      +                            "[ai] 流中途失败,保文不重试(incomplete=true),入库 + AiCompleted + 系统提示",
      +                        );
      +                        outcome = Some((text, tool_calls, usage, true));
      +                        break;
      +                    }
      +                    StreamResult::InitFailed { retryable } => {
      +                        // stream_llm 已 emit AiError。决定是否重试(仅 Init 失败可重试,决策 a1)。
      +                        let is_last = retry_attempt >= max_retries;
      +                        let now = tokio::time::Instant::now();
      +                        let budget_exhausted = now >= retry_deadline;
      +
      +                        // Fatal(4xx 非429/鉴权/参数错)立即放弃,不浪费预算重试
      +                        if !retryable {
      +                            tracing::warn!(
      +                                conv_id = %conv_id,
      +                                attempt = retry_attempt + 1,
      +                                "[ai] 流前失败 Fatal(4xx/鉴权),立即放弃不重试",
      +                            );
      +                            guard.reset().await;
      +                            return;
      +                        }
      +
      +                        if is_last || budget_exhausted {
      +                            // 重试耗尽或预算耗尽:错误已 emit,结束
      +                            tracing::warn!(
      +                                conv_id = %conv_id,
      +                                attempt = retry_attempt + 1,
      +                                budget_exhausted = budget_exhausted,
      +                                "[ai] 流前失败重试{}({}次),放弃",
      +                                if budget_exhausted { "预算耗尽" } else { "耗尽" },
      +                                max_retries + 1,
      +                            );
      +                            guard.reset().await;
      +                            return;
      +                        }
      +
      +                        // CR-30-1: 退避复用 retry::backoff_delay(attempt+1)(1s→2s→4s + ±20% jitter),
      +                        // 不再使用纯指数 `1u64 << retry_attempt`。attempt 传 retry_attempt+1
      +                        // (retry::backoff_delay 是 1-based,第 1 次重试 ~1s,第 2 次 ~2s,第 3 次 ~4s)。
      +                        let delay = retry::backoff_delay((retry_attempt + 1) as u32)
      +                            .min(retry_deadline.saturating_duration_since(now));
      +                        let total_retry = retry_attempt + 1; // 1-based 当前是第几次尝试
      +                        tracing::warn!(
      +                            conv_id = %conv_id,
      +                            attempt = total_retry,
      +                            max_attempts = max_retries + 1,
      +                            delay_ms = delay.as_millis() as u64,
      +                            "[ai] 流前失败(Retryable),{}ms 后重试 ({}/{})",
      +                            delay.as_millis(), total_retry, max_retries + 1
      +                        );
      +
      +                        // F-260616-07(d): emit 重试提示事件,前端在错误气泡内显示「重试 n/m」
      +                        // (对齐决策"错误气泡内更新")。CR-30-2: useAiEvents.ts 补 case 处理。
      +                        let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiStreamRetry {
      +                            attempt: total_retry as u32,
      +                            max_attempts: (max_retries + 1) as u32,
      +                            conversation_id: Some(conv_id.clone()),
      +                        });
      +
      +                        tokio::time::sleep(delay).await;
      +                        continue;
      +                    }
      +                }
      +            }
      +
      +            match outcome {
      +                Some(r) => r,
      +                None => {
      +                    // 不应到达:InitFailed 非 Fatal/未耗尽即重试,耗尽/Fatal 已 return;
      +                    // Complete/Partial 写入 outcome 后 break。防御兜底。
      +                    guard.reset().await;
      +                    return;
      +                }
                   }
               };
      +
      +        // CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收,
      +        // 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。
      +        // 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。
      +        if incomplete {
      +            let usage = df_ai::provider::TokenUsage {
      +                prompt_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens },
      +                completion_tokens: round_usage.completion_tokens,
      +                total_tokens: if round_usage.prompt_tokens == 0 { estimated_prompt + round_usage.completion_tokens } else { round_usage.total_tokens },
      +            };
      +            tokens.add(usage.prompt_tokens, usage.completion_tokens);
      +
      +            // 追加 partial assistant 消息(若无 tool_calls 且有文本)
      +            {
      +                let mut session = session_arc.lock().await;
      +                if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) {
      +                    tracing::warn!(
      +                        stale_conv = %conv_id,
      +                        active_conv = ?session.active_conversation_id,
      +                        "[ai] MidStream 保文后对话已切换,丢弃本轮 push(B-260615-11)"
      +                    );
      +                    return;
      +                }
      +                if !full_text.is_empty() {
      +                    let mut msg = ChatMessage::assistant(&full_text);
      +                    msg.model = Some(provider_config.default_model.clone());
      +                    session.messages.push(msg);
      +                    // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
      +                    let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
      +                    notice.model = Some(provider_config.default_model.clone());
      +                    session.messages.push(notice);
      +                }
      +            }
      +
      +            save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
      +            // 标题生成后台化(失败有 extract_title 兜底)
      +            spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
      +            guard.reset().await;
      +            // generating 复位后再 emit Completed(incomplete=true):前端据此标记消息为不完整
      +            let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted {
      +                total_tokens: usage.total_tokens,
      +                prompt_tokens: tokens.prompt(),
      +                completion_tokens: tokens.completion(),
      +                conversation_id: Some(conv_id.clone()),
      +                incomplete: Some(true),
      +            });
      +            return;
      +        }
               // stream 结束立即释放 permit,后续工具执行不受限流(本地操作无 RPM 成本)
               drop(_global_permit);
               drop(_per_conv_permit);
      @@ -289,7 +454,7 @@ pub(crate) async fn run_agentic_loop(
                   spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
                   guard.reset().await;
                   // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝)
      -            let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
      +            let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
                   return;
               }
       
      @@ -377,7 +542,7 @@ pub(crate) async fn run_agentic_loop(
       
           guard.reset().await;
           // generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成
      -    let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) });
      +    let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()) });
       }
       
       /// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
      @@ -434,6 +599,7 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
                       total_tokens: 0,
                       prompt_tokens: 0,
                       completion_tokens: 0,
      +                incomplete: None,
                       conversation_id: Some(conv_id),
                   });
               }
      @@ -485,6 +651,8 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
           let llm_concurrency = state.llm_concurrency.clone();
           // F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效)
           let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
      +    // F-260616-07: 流式失败重试次数快照
      +    let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
       
           // 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
           // 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
      @@ -494,6 +662,6 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
           });
       
           tauri::async_runtime::spawn(async move {
      -        run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
      +        run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
           });
       }
      diff --git a/src-tauri/src/commands/ai/commands.rs b/src-tauri/src/commands/ai/commands.rs
      index d480b8e..be8692f 100644
      --- a/src-tauri/src/commands/ai/commands.rs
      +++ b/src-tauri/src/commands/ai/commands.rs
      @@ -101,9 +101,11 @@ pub async fn ai_regenerate(
           let llm_concurrency = state.llm_concurrency.clone();
           // F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
           let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
      +    // F-260616-07: 流式失败重试次数快照
      +    let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
       
           tauri::async_runtime::spawn(async move {
      -        run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
      +        run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
           });
       
           Ok("ok".to_string())
      @@ -195,9 +197,11 @@ pub async fn ai_chat_send(
           let llm_concurrency = state.llm_concurrency.clone();
           // F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
           let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
      +    // F-260616-07: 流式失败重试次数快照
      +    let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
       
           tauri::async_runtime::spawn(async move {
      -        run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
      +        run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries).await;
           });
       
           Ok("ok".to_string())
      @@ -213,10 +217,20 @@ pub async fn ai_approve(
       ) -> Result {
           let mut session = state.ai_session.lock().await;
       
      -    let approval = session
      -        .pending_approvals
      -        .remove(&tool_call_id)
      -        .ok_or_else(|| format!("未找到挂起的审批: {}", tool_call_id))?;
      +    let approval = match session.pending_approvals.remove(&tool_call_id) {
      +        Some(a) => a,
      +        None => {
      +            // F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
      +            if let Some(rec) = state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await
      +                .unwrap_or_default()
      +            {
      +                if rec.status == "executed" || rec.status == "rejected" || rec.status == "failed" {
      +                    return Ok(format!("已处理({})", rec.status));
      +                }
      +            }
      +            return Err(format!("未找到挂起的审批: {}", tool_call_id));
      +        }
      +    };
           // recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
           let _recovered = approval.recovered;
       
      @@ -424,6 +438,7 @@ pub async fn ai_chat_edit(
           let knowledge_config = state.knowledge_config.lock().await.clone();
           let llm_concurrency = state.llm_concurrency.clone();
           let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
      +    let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
       
           tauri::async_runtime::spawn(async move {
               run_agentic_loop(
      @@ -437,6 +452,7 @@ pub async fn ai_chat_edit(
                   knowledge_config,
                   llm_concurrency,
                   max_iterations,
      +            max_retries,
               )
               .await;
           });
      @@ -472,6 +488,7 @@ pub async fn ai_chat_force_send(
                   total_tokens: 0,
                   prompt_tokens: 0,
                   completion_tokens: 0,
      +            incomplete: None,
                   conversation_id: Some(cid.clone()),
               });
           }
      @@ -499,7 +516,7 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
               session.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
               let conv_id = session.active_conversation_id.clone();
               drop(session);
      -        let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, conversation_id: conv_id });
      +        let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None, conversation_id: conv_id });
               return Ok(());
           }
           // 流式生成中:置位让 loop 自行收尾
      @@ -529,6 +546,7 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
                           total_tokens: 0,
                           prompt_tokens: 0,
                           completion_tokens: 0,
      +                    incomplete: None,
                           conversation_id: conv_id,
                       },
                   );
      @@ -601,6 +619,7 @@ pub async fn ai_stop_loop(
               total_tokens: 0,
               prompt_tokens: 0,
               completion_tokens: 0,
      +        incomplete: None,
               conversation_id: Some(conv_id),
           });
           Ok("ok".to_string())
      @@ -807,6 +826,7 @@ pub async fn ai_conversation_create(
                       total_tokens: 0,
                       prompt_tokens: 0,
                       completion_tokens: 0,
      +                incomplete: None,
                       conversation_id: Some(old_conv),
                   });
               }
      @@ -1082,3 +1102,20 @@ pub async fn ai_set_agent_max_iterations(
           state.agent_max_iterations.store(clamped, Ordering::SeqCst);
           Ok(())
       }
      +
      +/// 设置流式对话失败自动重试次数(F-260616-07 / 决策 a1:运行时调整,下次发消息生效)
      +///
      +/// 只重试流前失败(Init Err:未输出任何 token);流中途失败(MidStream Partial)保文不重试。
      +/// 退避复用 retry::backoff_delay(1s→2s→4s+jitter) + is_status_retryable Fatal 分类 +
      +/// 30s 总预算(详见 agentic.rs 流前重试循环)。
      +/// 范围 clamp 0-10:0 表示不重试(直接报错),上限 10 防过度重试烧 token/拖慢体验。
      +/// 默认 3(复用 retry.rs backoff_delay + 错误分类,详见 agentic.rs 重试循环)。
      +#[tauri::command]
      +pub async fn ai_set_agent_max_retries(
      +    state: State<'_, AppState>,
      +    value: u32,
      +) -> Result<(), String> {
      +    let clamped = value.clamp(0, 10) as usize;
      +    state.agent_max_retries.store(clamped, Ordering::SeqCst);
      +    Ok(())
      +}
      diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs
      index d432b29..eb30620 100644
      --- a/src-tauri/src/commands/ai/mod.rs
      +++ b/src-tauri/src/commands/ai/mod.rs
      @@ -100,7 +100,18 @@ pub enum AiChatEvent {
           /// 审批结果
           AiApprovalResult { id: String, approved: bool, conversation_id: Option },
           /// AI 响应完成
      -    AiCompleted { total_tokens: u32, prompt_tokens: u32, completion_tokens: u32, conversation_id: Option },
      +    ///
      +    /// `incomplete`(可选,默认 None=false):UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1——
      +    /// 流中途失败(MidStream)保文路径置 Some(true),标记此回复不完整。前端可据此差异化
      +    /// 展示(如附系统提示「⚠ 响应因网络中断不完整」)。正常完成/用户停止均 None(完整)。
      +    AiCompleted {
      +        total_tokens: u32,
      +        prompt_tokens: u32,
      +        completion_tokens: u32,
      +        /// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复
      +        incomplete: Option,
      +        conversation_id: Option,
      +    },
           /// 错误
           ///
           /// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。
      @@ -120,6 +131,11 @@ pub enum AiChatEvent {
           /// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
           /// - 点停止 → ai_stop_loop → 走完成流程(save + AiCompleted + generating=false)
           AiMaxRoundsReached { conversation_id: Option },
      +    /// 流式调用自动重试提示(F-260616-07:流前失败重试中)
      +    ///
      +    /// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
      +    /// attempt 从 1 开始(首次失败后第一次重试=attempt 1),max_attempts = max_retries + 1。
      +    AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option },
       }
       
       // ============================================================
      diff --git a/src-tauri/src/commands/ai/stream_recv.rs b/src-tauri/src/commands/ai/stream_recv.rs
      index 3a998a2..3f21a5e 100644
      --- a/src-tauri/src/commands/ai/stream_recv.rs
      +++ b/src-tauri/src/commands/ai/stream_recv.rs
      @@ -92,12 +92,42 @@ pub(crate) fn fmt_diag(provider_name: &str, kind: DiagKind, status_or_class: &st
           format!("[{}] {}({}): {}", provider_name, ctx, status_or_class, raw)
       }
       
      -/// 流式接收 LLM 响应,返回 (完整文本, 工具调用草稿)
      +/// 流式接收结果。显式区分三类出口,避免调用方对 None 的歧义推断
      +/// (UX-2025-04 / CR-30-1 / CR-30-2 / 决策 F-260616-07 a1)。
      +pub(crate) enum StreamResult {
      +    /// 正常完成(流尽 + finished 信号,或用户主动停止)。incomplete=false。
      +    /// 调用方按正常流程入库 + emit AiCompleted。
      +    Complete {
      +        text: String,
      +        tool_calls: HashMap,
      +        usage: df_ai::provider::TokenUsage,
      +    },
      +    /// 流中途断(MidStream chunk Err / idle timeout / provider stream-error / 有 partial_text
      +    /// 但流尽未收到 finished)。已 emit 过 AiTextDelta(前端 currentText 已累积),
      +    /// **不 emit AiError**——调用方保文入库 + emit AiCompleted(incomplete=true) + 系统提示。
      +    /// 调用方**不重试**(决策 a1)。
      +    Partial {
      +        text: String,
      +        tool_calls: HashMap,
      +        usage: df_ai::provider::TokenUsage,
      +    },
      +    /// Init 失败(provider.stream() Err:建连/鉴权/HTTP non-2xx)。已 emit AiError。
      +    /// `retryable`: 据状态码分类(retry::is_status_retryable 镜像):true=5xx/429/timeout/connect
      +    /// 可重试;false=4xx(非429)/鉴权/参数错 Fatal 立即放弃。调用方据此决定是否重试。
      +    InitFailed { retryable: bool },
      +}
      +
      +/// 流式接收 LLM 响应。
       ///
      -/// 三类异常处理:
      -/// - idle timeout(120s 无 chunk):判定连接静默断,emit AiError 返回 None
      -/// - 流尽但从未收到 finished 信号:判定异常中断,emit AiError 返回 None(丢弃残缺,不当完整入库)
      -/// - 用户停止(stop_flag):break 返回 Some(已收文本),由调用方入库展示后退出
      +/// 三类异常处理(返回 StreamResult 显式区分出口):
      +/// - Init 失败(provider.stream() Err):emit AiError + 返回 InitFailed{retryable}。
      +///   retryable 据状态码分类(retry::is_status_retryable 镜像):5xx/429/timeout/connect=true,
      +///   4xx(非429)/鉴权/参数错=false Fatal。调用方走流前重试(仅 retryable=true,≤max_retries 次,
      +///   复用 retry::backoff_delay 退避 + 30s 总预算)。
      +/// - MidStream 失败(流已建立后断/timeout/err 事件/有 partial_text 但流尽未 finished):
      +///   **不 emit AiError**,返回 Partial{...} 保文。调用方不重试,入库 + AiCompleted(incomplete)
      +///   + 系统提示网络中断。空文本仍 emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
      +/// - 用户停止(stop_flag):返回 Complete(incomplete 语义非异常中断)。
       ///
       /// 心跳与停止响应(B-260615-02 / B-260615-04):单 `tokio::select!` 三分支
       /// - `stream.next()`:正常 chunk 处理
      @@ -111,7 +141,7 @@ pub(crate) async fn stream_llm(
           stop_flag: &AtomicBool,
           notify: &tokio::sync::Notify,
           conv_id: &str,
      -) -> Option<(String, HashMap, df_ai::provider::TokenUsage)> {
      +) -> StreamResult {
           /// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
           const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
           /// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog
      @@ -157,13 +187,28 @@ pub(crate) async fn stream_llm(
                           chunk_result = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()) => {
                               match chunk_result {
                                   Err(_elapsed) => {
      -                                let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
      -                                    error: "流式响应超时(120 秒无数据,连接可能已断开)".to_string(),
      -                                    // 120s 无 chunk = idle timeout,归 Timeout
      -                                    error_type: Some(ErrorType::Timeout),
      -                                    conversation_id: Some(conv_id.to_string()),
      -                                });
      -                                return None;
      +                                // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1:
      +                                // idle timeout 属 MidStream 类失败——已有文本则保文(Partial),不重试。
      +                                // 空文本无文可保,emit AiError + InitFailed{retryable=true}(timeout 瞬态可重试)。
      +                                if full_text.is_empty() && tool_calls_acc.is_empty() {
      +                                    let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
      +                                        error: "流式响应超时(120 秒无数据,连接可能已断开)".to_string(),
      +                                        error_type: Some(ErrorType::Timeout),
      +                                        conversation_id: Some(conv_id.to_string()),
      +                                    });
      +                                    return StreamResult::InitFailed { retryable: true };
      +                                }
      +                                warn!(
      +                                    provider = %provider.name(),
      +                                    conv_id = %conv_id,
      +                                    text_len = full_text.len(),
      +                                    "[ai] 流中途 idle timeout,保文不重试(incomplete)",
      +                                );
      +                                return StreamResult::Partial {
      +                                    text: full_text,
      +                                    tool_calls: tool_calls_acc,
      +                                    usage: final_usage.unwrap_or_default(),
      +                                };
                                   }
                                   Ok(None) => break, // 流正常结束
                                   Ok(Some(chunk_result)) => match chunk_result {
      @@ -187,27 +232,35 @@ pub(crate) async fn stream_llm(
                                               final_usage = Some(u.clone());
                                           }
                                           // provider 流式错误事件(Anthropic SSE `type=="error"` 等):
      -                                    // 不走 finished 完成路径,发 AiError + 丢弃残缺,与 Err 分支对齐(OpenAI 路径一致性)。
      +                                    // UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
      +                                    // 不重试。空文本无文可保,emit AiError + InitFailed(保守 retryable=true,
      +                                    // err_msg 不可靠解析状态码,默认按可重试交调用方决定)。
                                           if let Some(err_msg) = &chunk.error {
                                               warn!(
                                                   provider = %provider.name(),
                                                   conv_id = %conv_id,
                                                   error = %err_msg,
      +                                            text_len = full_text.len(),
                                                   "[ai] provider 流式错误事件",
                                               );
      -                                        let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
      -                                            error: fmt_diag(
      -                                                provider.name(),
      -                                                DiagKind::MidStream,
      -                                                "stream-error",
      -                                                err_msg,
      -                                            ),
      -                                            // provider SSE error 事件体可为 401/overloaded 等多种,
      -                                            // 从文本分类不可靠,归 Unknown(前端可据 error 文本二次判断)
      -                                            error_type: Some(ErrorType::Unknown),
      -                                            conversation_id: Some(conv_id.to_string()),
      -                                        });
      -                                        return None;
      +                                        if full_text.is_empty() && tool_calls_acc.is_empty() {
      +                                            let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
      +                                                error: fmt_diag(
      +                                                    provider.name(),
      +                                                    DiagKind::MidStream,
      +                                                    "stream-error",
      +                                                    err_msg,
      +                                                ),
      +                                                error_type: Some(ErrorType::Unknown),
      +                                                conversation_id: Some(conv_id.to_string()),
      +                                            });
      +                                            return StreamResult::InitFailed { retryable: true };
      +                                        }
      +                                        return StreamResult::Partial {
      +                                            text: full_text,
      +                                            tool_calls: tool_calls_acc,
      +                                            usage: final_usage.unwrap_or_default(),
      +                                        };
                                           }
                                           if chunk.finished {
                                               finished_received = true;
      @@ -222,22 +275,33 @@ pub(crate) async fn stream_llm(
                                               status = %status_or_class,
                                               conv_id = %conv_id,
                                               error = %raw,
      +                                        text_len = full_text.len(),
                                               "[ai] 流式接收中途错误",
                                           );
      -                                    let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
      -                                        error: fmt_diag(
      -                                            provider.name(),
      -                                            DiagKind::MidStream,
      -                                            &status_or_class,
      -                                            &raw,
      -                                        ),
      -                                        // 流已建立后 next() 返 Err = SSE 传输断/解析错,归 Network
      -                                        // (extract_error_diag 已抠 status_or_class,但混合源难统一归 auth/timeout,
      -                                        // 主流为传输断,前端可据 error 文本二次判断 HTTP 4xx 等)
      -                                        error_type: Some(ErrorType::Network),
      -                                        conversation_id: Some(conv_id.to_string()),
      -                                    });
      -                                    return None;
      +                                    // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: MidStream chunk Err——
      +                                    // 已有文本则保文(Partial),不重试。空文本无文可保,emit AiError +
      +                                    // InitFailed{retryable=classify_status_or_class(status_or_class)}
      +                                    // (4xx Fatal 立即放弃,5xx/429/timeout/connect 可重试)。
      +                                    if full_text.is_empty() && tool_calls_acc.is_empty() {
      +                                        let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
      +                                            error: fmt_diag(
      +                                                provider.name(),
      +                                                DiagKind::MidStream,
      +                                                &status_or_class,
      +                                                &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 {
      +                                        text: full_text,
      +                                        tool_calls: tool_calls_acc,
      +                                        usage: final_usage.unwrap_or_default(),
      +                                    };
                                       }
                                   }
                               }
      @@ -262,26 +326,47 @@ pub(crate) async fn stream_llm(
                       }
                   }
       
      -            // 用户停止:已生成文本(可能残缺)交调用方入库展示
      +            // 用户停止:已生成文本(可能残缺)交调用方入库展示。Complete(incomplete=false)——
      +            // 用户主动停止语义非异常中断,正常入库 + AiCompleted(决策 a1: 不重试路径)。
                   if stopped {
      -                return Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()));
      +                return StreamResult::Complete {
      +                    text: full_text,
      +                    tool_calls: tool_calls_acc,
      +                    usage: final_usage.unwrap_or_default(),
      +                };
                   }
       
      -            // 断连检测:流尽但从未收到 finished 信号 = 异常中断,丢弃残缺不当完整入库。
      -            // B-260615-05:一律判异常(不区分内容空否)——空内容无 finished 同属异常:
      -            // 上游 agentic.rs !has_tool_calls 早 break 会按正常路径 emit AiCompleted,
      -            // 用户看空气泡无错误提示,转 P0 卡死入口。此处统一 emit AiError 拦截。
      +            // 断连检测:流尽但从未收到 finished 信号 = 异常中断。
      +            // B-260615-05:空内容无 finished emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
      +            // UX-2025-04:有 partial_text 则保文(Partial),不 emit AiError,不重试。
                   if !finished_received {
      -                let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
      -                    error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
      -                    // 流尽但未收到 finished = 连接异常中断,归 Network
      -                    error_type: Some(ErrorType::Network),
      -                    conversation_id: Some(conv_id.to_string()),
      -                });
      -                return None;
      +                if full_text.is_empty() && tool_calls_acc.is_empty() {
      +                    let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
      +                        error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
      +                        error_type: Some(ErrorType::Network),
      +                        conversation_id: Some(conv_id.to_string()),
      +                    });
      +                    return StreamResult::InitFailed { retryable: true };
      +                }
      +                warn!(
      +                    provider = %provider.name(),
      +                    conv_id = %conv_id,
      +                    text_len = full_text.len(),
      +                    "[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
      +                );
      +                return StreamResult::Partial {
      +                    text: full_text,
      +                    tool_calls: tool_calls_acc,
      +                    usage: final_usage.unwrap_or_default(),
      +                };
                   }
       
      -            Some((full_text, tool_calls_acc, final_usage.unwrap_or_default()))
      +            // 正常完成:流尽 + finished 信号到位,Complete(incomplete=false)
      +            StreamResult::Complete {
      +                text: full_text,
      +                tool_calls: tool_calls_acc,
      +                usage: final_usage.unwrap_or_default(),
      +            }
               }
               Err(e) => {
                   // 诊断:连接/鉴权/HTTP 错误,补 provider 标识 + HTTP 状态码/分类 + 原始文本,
      @@ -301,18 +386,53 @@ pub(crate) async fn stream_llm(
                           &status_or_class,
                           &raw,
                       ),
      -                // 建连失败混合源:401(auth)/404(provider_config)/connect(network)/timeout/unknown,
      -                // extract_error_diag 已抠 status_or_class 供前端 error 文本展示,但运行时文本分类
      -                // 归一 error_type 不可靠(同 raw 跨多类型),按任务约定难精确分类的旧点填 None,
      -                // 前端可据 error 文本中的 "HTTP 401" 等自行二次判断。
                       error_type: None,
                       conversation_id: Some(conv_id.to_string()),
                   });
      -            None
      +            // 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),
      +            }
               }
           }
       }
       
      +/// 据 extract_error_diag 产出的 status_or_class 字符串镜像 retry::is_status_retryable 分类。
      +///
      +/// status_or_class 取值: "HTTP 5xx"/"HTTP 4xx"(白名单码) / "timeout" / "connect" / "unknown"。
      +/// - HTTP 429 / 5xx → retryable=true(复用 retry::is_status_retryable 逻辑)
      +/// - HTTP 4xx(非429) → retryable=false Fatal(401/403/404/422 等)
      +/// - timeout / connect → retryable=true(瞬态,可重试)
      +/// - unknown / 其他 → retryable=true(保守可重试,避免误判 Fatal 错杀)
      +///
      +/// CR-30-1: 抽公共分类函数(决策 F-260616-07 a1 "复用 retry.rs 错误分类逻辑或镜像"),
      +/// 避免 stream_recv 与 agentic 各自重写状态码解析。
      +fn classify_status_or_class(status_or_class: &str) -> bool {
      +    use df_ai::retry::is_status_retryable;
      +    // 抠 status_or_class 内三位数字状态码("HTTP 401" → 401)
      +    let chars: Vec = status_or_class.chars().collect();
      +    let mut i = 0;
      +    let n = chars.len();
      +    while i + 3 <= n {
      +        if chars[i].is_ascii_digit()
      +            && chars[i + 1].is_ascii_digit()
      +            && chars[i + 2].is_ascii_digit()
      +        {
      +            let code: String = chars[i..i + 3].iter().collect();
      +            if let Ok(status) = code.parse::() {
      +                return is_status_retryable(status);
      +            }
      +            i += 3;
      +            continue;
      +        }
      +        i += 1;
      +    }
      +    // 无三位数字码:按文本分类(timeout/connect 可重试,unknown/其他保守可重试)
      +    let lower = status_or_class.to_lowercase();
      +    lower.contains("timeout") || lower.contains("connect") || true // unknown 保守可重试
      +}
      +
       // ============================================================
       // 单测:诊断提取/格式化(纯函数,不发 HTTP、不依赖 app_handle)
       // ============================================================
      @@ -320,6 +440,43 @@ pub(crate) async fn stream_llm(
       mod tests {
           use super::*;
       
      +    // ---- classify_status_or_class:CR-30-1 retryable 分类(镜像 retry::is_status_retryable) ----
      +
      +    /// 5xx → retryable=true
      +    #[test]
      +    fn classify_5xx_retryable() {
      +        assert!(classify_status_or_class("HTTP 500"));
      +        assert!(classify_status_or_class("HTTP 503"));
      +    }
      +
      +    /// 429 → retryable=true(限流可重试)
      +    #[test]
      +    fn classify_429_retryable() {
      +        assert!(classify_status_or_class("HTTP 429"));
      +    }
      +
      +    /// 4xx(非429) → retryable=false Fatal(鉴权/参数错立即放弃)
      +    #[test]
      +    fn classify_4xx_fatal() {
      +        assert!(!classify_status_or_class("HTTP 401"));
      +        assert!(!classify_status_or_class("HTTP 403"));
      +        assert!(!classify_status_or_class("HTTP 404"));
      +        assert!(!classify_status_or_class("HTTP 422"));
      +    }
      +
      +    /// timeout / connect → retryable=true(瞬态)
      +    #[test]
      +    fn classify_transient_retryable() {
      +        assert!(classify_status_or_class("timeout"));
      +        assert!(classify_status_or_class("connect"));
      +    }
      +
      +    /// unknown → 保守 retryable=true(不误判 Fatal 错杀)
      +    #[test]
      +    fn classify_unknown_retryable() {
      +        assert!(classify_status_or_class("unknown"));
      +    }
      +
           // ---- extract_error_diag:业务层 bail(provider 串已含状态码)----
       
           /// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错)
      diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs
      index 1f16813..7b669f4 100644
      --- a/src-tauri/src/commands/ai/tool_registry.rs
      +++ b/src-tauri/src/commands/ai/tool_registry.rs
      @@ -205,51 +205,57 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry {
       
           // ── 只读 (Low) ──
           registry.register(
      -        "list_projects", "列出所有项目,返回项目列表(ID、名称、状态、描述)。最多返回 50 条",
      -        df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
      -        { let db = db.clone(); Box::new(move |_args: serde_json::Value| {
      +        "list_projects", "列出所有项目,支持 offset/limit 分页。返回 items(项目列表)、total(总量)、has_more(是否有更多页)。默认 limit=50",
      +        df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
      +        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
                   let db = db.clone();
                   Box::pin(async move {
                       let repo = df_storage::crud::ProjectRepo::new(&db);
      -                let mut items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
      +                let items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
                       let total = items.len();
      -                let truncated = total > MAX_LIST_RESULTS;
      -                items.truncate(MAX_LIST_RESULTS);
      -                Ok(serde_json::json!({ "items": items, "truncated": truncated }))
      +                let offset = args["offset"].as_u64().unwrap_or(0) as usize;
      +                let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
      +                let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
      +                let has_more = (offset + page_items.len()) < total;
      +                Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
                   })
               })},
           );
           registry.register(
      -        "list_tasks", "列出任务,可按 project_id 筛选。最多返回 50 条",
      -        df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
      +        "list_tasks", "列出任务,可按 project_id 筛选,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
      +        df_ai::ai_tools::object_schema(vec![("project_id", "string", false), ("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
               { let db = db.clone(); Box::new(move |args: serde_json::Value| {
                   let db = db.clone();
                   Box::pin(async move {
                       let repo = df_storage::crud::TaskRepo::new(&db);
      -                let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
      +                let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
                           repo.query("project_id", pid).await?
                       } else {
                           repo.list_all().await?
                       };
                       let total = tasks.len();
      -                let truncated = total > MAX_LIST_RESULTS;
      -                tasks.truncate(MAX_LIST_RESULTS);
      -                Ok(serde_json::json!({ "items": tasks, "truncated": truncated }))
      +                let offset = args["offset"].as_u64().unwrap_or(0) as usize;
      +                let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
      +                let page_items: Vec<_> = tasks.into_iter().skip(offset).take(limit).collect();
      +                let has_more = (offset + page_items.len()) < total;
      +                Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
                   })
               })},
           );
           registry.register(
      -        "list_ideas", "列出所有灵感。最多返回 50 条",
      -        df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
      -        { let db = db.clone(); Box::new(move |_args: serde_json::Value| {
      +        "list_ideas", "列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
      +        df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
      +        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
                   let db = db.clone();
                   Box::pin(async move {
                       let repo = df_storage::crud::IdeaRepo::new(&db);
      -                let mut items = repo.list_all().await?;
      +                let items = repo.list_all().await?;
                       let total = items.len();
      -                let truncated = total > MAX_LIST_RESULTS;
      -                items.truncate(MAX_LIST_RESULTS);
      -                Ok(serde_json::json!({ "items": items, "truncated": truncated }))
      +                let offset = args["offset"].as_u64().unwrap_or(0) as usize;
      +                let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
      +                let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
      +                let has_more = (offset + page_items.len()) < total;
      +                Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
                   })
               })},
           );
      @@ -456,16 +462,19 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry {
               })},
           );
           registry.register(
      -        "list_trash", "列出回收站已删除项目(最多返回 50 条)",
      -        df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
      -        { let db = db.clone(); Box::new(move |_args: serde_json::Value| {
      +        "list_trash", "列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
      +        df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
      +        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
                   let db = db.clone();
                   Box::pin(async move {
                       let repo = df_storage::crud::ProjectRepo::new(&db);
      -                let mut items = repo.list_deleted().await?;
      -                let truncated = items.len() > MAX_LIST_RESULTS;
      -                items.truncate(MAX_LIST_RESULTS);
      -                Ok(serde_json::json!({ "items": items, "truncated": truncated }))
      +                let items = repo.list_deleted().await?;
      +                let total = items.len();
      +                let offset = args["offset"].as_u64().unwrap_or(0) as usize;
      +                let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
      +                let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
      +                let has_more = (offset + page_items.len()) < total;
      +                Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
                   })
               })},
           );
      @@ -568,23 +577,27 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry {
                       }
                       anyhow::bail!("读取文件失败: {}", e);
                   }
      -            // search 模式: 按行枚举收集含 search 子串的行,限 50 条
      +            // search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页
                   if let Some(search) = args["search"].as_str() {
      -                let search_max = 50usize;
      -                let mut matches_vec: Vec = Vec::with_capacity(search_max);
      +                const SEARCH_MAX: usize = 50;
      +                let search_offset = args["offset"].as_u64().unwrap_or(0) as usize;
      +                let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize;
      +                // 先收集全部匹配行用于 total 计数,再做 skip/take 分页
      +                let mut all_matches: Vec = Vec::new();
                       for (idx, line) in content.lines().enumerate() {
                           if line.contains(search) {
      -                        matches_vec.push(serde_json::json!({ "line": idx + 1, "content": line }));
      -                        if matches_vec.len() >= search_max { break; }
      +                        all_matches.push(serde_json::json!({ "line": idx + 1, "content": line }));
                           }
                       }
      -                let total = content.lines().filter(|l| l.contains(search)).count();
      +                let total = all_matches.len();
      +                let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect();
      +                let has_more = (search_offset + page_matches.len()) < total;
                       return Ok(serde_json::json!({
                           "path": path, "size": metadata.len(),
                           "search": search,
      -                    "matches": matches_vec,
      +                    "matches": page_matches,
                           "total": total,
      -                    "has_more": total > search_max,
      +                    "has_more": has_more,
                       }));
                   }
                   // 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
      @@ -1043,8 +1056,8 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry {
       
           // ── 文件搜索 (Low risk) ──
           registry.register(
      -        "search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条",
      -        df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false)]),
      +        "search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
      +        df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
               RiskLevel::Low,
               Box::new(|args: serde_json::Value| Box::pin(async move {
                   let resolved = resolve_workspace_path(
      @@ -1055,14 +1068,49 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry {
                   let recursive = args["recursive"].as_bool().unwrap_or(false);
                   let pattern_lower = pattern.to_lowercase();
                   const MAX_RESULTS: usize = 50;
      -            let mut results = Vec::new();
      +            let offset = args["offset"].as_u64().unwrap_or(0) as usize;
      +            let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize;
      +            // 先收集全部结果用于 total 计数,再做 skip/take 分页
      +            let mut all_results = Vec::new();
                   let mut total = 0u64;
      -            search_files_recursive(path, &pattern_lower, recursive, 0, 5, MAX_RESULTS, &mut results, &mut total).await?;
      -            let has_more = total as usize > MAX_RESULTS;
      -            Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": results, "total": total, "has_more": has_more }))
      +            // 用较大上限收集全量(分页由内存 skip/take 控制)
      +            search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
      +            let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
      +            let has_more = (offset + page_results.len()) < total as usize;
      +            Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
               })),
           );
       
      +    // ── 探总量工具 (Low risk, F-260616-08) ──
      +    registry.register(
      +        "get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
      +        df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
      +        { let db = db.clone(); Box::new(move |_args: serde_json::Value| {
      +            let db = db.clone();
      +            Box::pin(async move {
      +                let repo = df_storage::crud::ProjectRepo::new(&db);
      +                let items = repo.list_active().await?;
      +                Ok(serde_json::json!({ "total": items.len() }))
      +            })
      +        })},
      +    );
      +    registry.register(
      +        "get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
      +        df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
      +        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
      +            let db = db.clone();
      +            Box::pin(async move {
      +                let repo = df_storage::crud::TaskRepo::new(&db);
      +                let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
      +                    repo.query("project_id", pid).await?
      +                } else {
      +                    repo.list_all().await?
      +                };
      +                Ok(serde_json::json!({ "total": tasks.len() }))
      +            })
      +        })},
      +    );
      +
           registry
       }
       
      diff --git a/src-tauri/src/commands/knowledge.rs b/src-tauri/src/commands/knowledge.rs
      index e3c83f6..674fe84 100644
      --- a/src-tauri/src/commands/knowledge.rs
      +++ b/src-tauri/src/commands/knowledge.rs
      @@ -107,7 +107,7 @@ pub async fn knowledge_search(
           state: State<'_, AppState>,
           input: KnowledgeSearchInput,
       ) -> Result, String> {
      -    let limit = input.limit.unwrap_or(3).min(3);
      +    let limit = input.limit.unwrap_or(3).min(20);
           state
               .knowledge
               .search(&input.query, input.kind.as_deref(), limit)
      diff --git a/src-tauri/src/commands/workflow.rs b/src-tauri/src/commands/workflow.rs
      index 006a2ad..8a0538d 100644
      --- a/src-tauri/src/commands/workflow.rs
      +++ b/src-tauri/src/commands/workflow.rs
      @@ -35,17 +35,20 @@ struct WorkflowEventPayload {
       /// 映射表(失败退一步):
       ///   - testing     → in_review
       ///   - in_review   → in_progress
      -///   - in_progress → todo
      +///   - in_progress → None(状态机禁止→todo,留 in_progress 等人介入)
       ///   - 其他(done/blocked/cancelled/todo 等) → None(无退回映射,跳过回调)
       ///
      -/// 设计选择:in_progress → todo(而非 None)。理由:in_progress 是执行态,失败退回 todo
      -/// 符合"执行未达预期 → 回起点重排"的直觉语义;若选 None 会丢失"执行失败"信号。
      +/// 设计选择:in_progress → None(而非 todo)。理由:状态机禁止 in_progress→todo
      +/// (task_state_machine.rs backward_to_todo_rejected),失败退回 todo 会被
      +/// advance_task_atomic 的 InvalidState 拦截,任务原地保留 in_progress 且无信号;
      +/// 改为 None 则回调跳过推进,留 in_progress 等人介入。
       /// 起点状态 todo 无可退态 → None。
       fn regression_target(target: &str) -> Option<&str> {
           match target {
               "testing" => Some("in_review"),
               "in_review" => Some("in_progress"),
      -        "in_progress" => Some("todo"),
      +        // in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入
      +        "in_progress" => None,
               _ => None,
           }
       }
      diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
      index 317a90c..f82aa39 100644
      --- a/src-tauri/src/lib.rs
      +++ b/src-tauri/src/lib.rs
      @@ -53,6 +53,7 @@ pub fn run() {
                                       total_tokens: 0,
                                       prompt_tokens: 0,
                                       completion_tokens: 0,
      +                                incomplete: None,
                                       conversation_id: conv_id,
                                   },
                               );
      @@ -131,6 +132,7 @@ pub fn run() {
                   commands::ai::ai_list_skills,
                   commands::ai::ai_set_concurrency_config,
                   commands::ai::ai_set_agent_max_iterations,
      +            commands::ai::ai_set_agent_max_retries,
                   // 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
                   commands::ai::audit::list_tool_executions,
                   // 知识库
      diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs
      index 2c92473..5095207 100644
      --- a/src-tauri/src/state.rs
      +++ b/src-tauri/src/state.rs
      @@ -180,6 +180,11 @@ pub struct AppState {
           /// loop 入口 load 快照透传形参;当前 loop 锁定边界,热改下次发消息生效)。
           /// 默认 10,与 agentic.rs::DEFAULT_MAX_AGENT_ITERATIONS 对齐。
           pub agent_max_iterations: Arc,
      +    // ── 流式对话失败自动重试 ──
      +    /// 流式对话失败自动重试次数(F-260616-07 / 决策 a1:只重试流前失败 Init Err——未输出
      +    /// 任何 token;流中途失败 MidStream Partial 保文不重试。退避复用 retry::backoff_delay +
      +    /// is_status_retryable Fatal 分类 + 30s 总预算,详见 agentic.rs 重试循环。默认 3)。
      +    pub agent_max_retries: Arc,
           // ── 工作流执行状态 ──
           /// 工作流执行 → 节点状态机注册表
           ///
      @@ -217,6 +222,9 @@ impl AppState {
                   agent_max_iterations: Arc::new(AtomicUsize::new(
                       crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
                   )),
      +            agent_max_retries: Arc::new(AtomicUsize::new(
      +                crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
      +            )),
                   workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
                   db,
                   event_bus: EventBus::new(),
      diff --git a/src/api/ai.ts b/src/api/ai.ts
      index a59624a..606db74 100644
      --- a/src/api/ai.ts
      +++ b/src/api/ai.ts
      @@ -115,6 +115,11 @@ export const aiApi = {
           return invoke('ai_set_agent_max_iterations', { value })
         },
       
      +  /** 设置流式对话失败自动重试次数(F-260616-07:只重试流前失败,0=不重试,默认3,上限10) */
      +  setAgentMaxRetries(value: number): Promise {
      +    return invoke('ai_set_agent_max_retries', { value })
      +  },
      +
         /** 列出本机 Claude 技能(skills + commands + plugins) */
         listSkills(): Promise {
           return invoke('ai_list_skills')
      diff --git a/src/api/types.ts b/src/api/types.ts
      index 106f379..ffb6249 100644
      --- a/src/api/types.ts
      +++ b/src/api/types.ts
      @@ -199,7 +199,9 @@ export type AiChatEvent = ({
       } | {
         type: 'AiApprovalResult'; id: string; approved: boolean
       } | {
      -  type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number
      +  // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
      +  // 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。
      +  type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean
       } | {
         type: 'AiError'; error: string; error_type?: AiErrorType
       } | {
      @@ -209,6 +211,9 @@ export type AiChatEvent = ({
       } | {
         // F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
         type: 'AiMaxRoundsReached'
      +} | {
      +  // F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
      +  type: 'AiStreamRetry'; attempt: number; max_attempts: number
       }) & {
         conversation_id?: string
       }
      diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue
      index f2cb6f3..4203633 100644
      --- a/src/components/AiChat.vue
      +++ b/src/components/AiChat.vue
      @@ -1180,8 +1180,11 @@ const isViewingGenerating = computed(() =>
       function cycleProvider() {
         const ps = store.state.providers
         if (ps.length < 2) return
      -  const idx = ps.findIndex(p => p.id === store.state.activeProvider)
      -  const next = ps[(idx + 1) % ps.length]
      +  // F-260616-10: activeProvider 为 null 时从 DB 默认(is_default)起算,避免 findIndex=-1 跳到 providers[0]
      +  const startId = store.state.activeProvider || ps.find(p => p.is_default)?.id || null
      +  const idx = startId ? ps.findIndex(p => p.id === startId) : -1
      +  // idx=-1 且无默认 provider 时 fallback 到 0(极端边界,正常路径不会走到)
      +  const next = ps[(Math.max(idx, 0) + 1) % ps.length]
         store.setProvider(next.id)
         // UX-2025-14: 切换反馈 — toast 提示当前 model + 临时 bar 展示 model 名(2s 后淡出)。
         // toast 机制:复用本组件自管 reactive showToast(分离窗口无 App.vue 根 toast)。
      diff --git a/src/composables/ai/aiShared.ts b/src/composables/ai/aiShared.ts
      index d027497..8ad564f 100644
      --- a/src/composables/ai/aiShared.ts
      +++ b/src/composables/ai/aiShared.ts
      @@ -1,13 +1,21 @@
       //! AI composables 共享层 — 多 composable 共用的模块级状态与纯函数
       //!
      -//! 存在意义:打破 useAiEvents ↔ useAiStream 之间的循环依赖。
      -//! - useAiStream.onStreamTimeout 调用 nextMsgId
      -//! - useAiEvents.handleEvent 调用 useAiStream 的 resetStreamWatchdog/clearStreamWatchdog
      -//! 把两者互相依赖的 nextMsgId(全局消息 id 自增计数器,无其他依赖的纯函数 + 模块级状态)
      -//! 下沉到本模块,使 useAiStream 改为依赖 aiShared 而非 useAiEvents,环即消除。
      +//! 存在意义:打破 composable 间循环依赖。
      +//! - 原环1(useAiEvents ↔ useAiStream):nextMsgId 下沉到本模块消除
      +//! - 原环2(useAiEvents ↔ useAiSend):findToolCall + 审批计时器 下沉到本模块消除
       //!
       //! 下沉范围(严格限定为「无依赖、被多个 composable 共用」的成员):
       //! - nextMsgId / _msgCounter:events/stream/send/window 四个 composable 共用同一计数器
      +//! - findToolCall:events/send 两个 composable 共用的纯查找函数(仅读 state.messages)
      +//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers):
      +//!   events 模块触发(start/clear/allClear),send 模块也需导出给组件,故下沉到共享层
      +
      +import { aiApi } from '@/api'
      +import { state } from '@/stores/ai'
      +import i18n from '@/i18n'
      +import type { AiToolCallInfo } from '@/api/types'
      +
      +const t = ((i18n as any).global.t as (k: string, named?: Record) => string).bind((i18n as any).global)
       
       /** 客户端消息自增 id(全局唯一,多个 composable 共用 nextMsgId) */
       let _msgCounter = 0
      @@ -16,3 +24,73 @@ let _msgCounter = 0
       export function nextMsgId(): number {
         return ++_msgCounter
       }
      +
      +/**
      + * 在全部消息中查找指定 id 的工具调用卡片(用于状态流转)
      + *
      + * 扫描策略:从尾部反向遍历并提前退出。tool_result/approval/completed
      + * 通常对应最近触发的 tool_use(agent 顺序执行工具并尽快回填结果),
      + * 反向扫描使其命中落在尾部 → 平均 O(1)、最坏 O(n)。(原正向全量扫描为 O(n²),
      + * 每条 tool_result 都从头扫整条历史。)
      + *
      + * 注:未引入 Map 索引。state.messages 在 newConversation /
      + * switchConversation / deleteConversation 等处被整体替换(且这些操作散落在
      + * useAiEvents 之外),独立索引极易与响应式数组失配形成"陈旧引用"——状态卡
      + * 片读到已不存在的对象。反向扫描零额外状态、改动最小且行为完全一致。
      + */
      +export function findToolCall(id: string): AiToolCallInfo | undefined {
      +  for (let i = state.messages.length - 1; i >= 0; i--) {
      +    const toolCalls = state.messages[i].toolCalls
      +    if (!toolCalls) continue
      +    // 同一消息内工具调用通常很少,沿用正向扫描;命中即返回
      +    for (let j = 0; j < toolCalls.length; j++) {
      +      if (toolCalls[j].id === id) return toolCalls[j]
      +    }
      +  }
      +  return undefined
      +}
      +
      +// ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ──
      +
      +/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝
      +const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
      +
      +/** toolCallId → 审批超时计时器 */
      +const _approvalTimers = new Map>()
      +
      +/**
      + * 启动审批超时计时器(幂等:同 id 重复启动不重建)。
      + * 到点回调:调 ai_approve(id, false) 自动拒绝 + push 系统错误消息。
      + */
      +export function startApprovalTimer(toolCallId: string, toolName: string): void {
      +  if (_approvalTimers.has(toolCallId)) return
      +  const timer = setTimeout(() => {
      +    _approvalTimers.delete(toolCallId)
      +    aiApi.approve(toolCallId, false).catch(e => {
      +      console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
      +    })
      +    state.messages.push({
      +      id: `approval-timeout-${nextMsgId()}`,
      +      role: 'assistant',
      +      content: t('ai.approvalTimeout', { toolName }),
      +      isError: true,
      +      timestamp: Date.now(),
      +    })
      +  }, APPROVAL_TIMEOUT_MS)
      +  _approvalTimers.set(toolCallId, timer)
      +}
      +
      +/** 清除单个审批计时器 */
      +export function clearApprovalTimer(toolCallId: string): void {
      +  const timer = _approvalTimers.get(toolCallId)
      +  if (timer) {
      +    clearTimeout(timer)
      +    _approvalTimers.delete(toolCallId)
      +  }
      +}
      +
      +/** 清除全部审批计时器(整流超时收尾/组件卸载时调用) */
      +export function clearAllApprovalTimers(): void {
      +  for (const timer of _approvalTimers.values()) clearTimeout(timer)
      +  _approvalTimers.clear()
      +}
      diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts
      index 37128e2..d868c79 100644
      --- a/src/composables/ai/useAiEvents.ts
      +++ b/src/composables/ai/useAiEvents.ts
      @@ -5,8 +5,10 @@
       //! - _startPromise: startListener 并发去重(防 onMounted 与 sendMessage 首发竞态重复注册)
       //!
       //! 耦合:
      -//! - handleEvent 调 useAiConversations.loadConversations、useAiSend.drainQueue、
      +//! - handleEvent 调 useAiConversations.loadConversations、
       //!   useAiStream.{resetStreamWatchdog,clearStreamWatchdog}、本模块 flushCurrentText/findToolCall/notifyConversationChanged
      +//! - drainQueue 经 ai-drain-queue 事件总线桥接(原直接 import useAiSend 构成循环依赖,已破除)
      +//! - 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared(同上)
       //! - nextMsgId 已下沉到 aiShared(原为本模块导出,useAiStream 亦依赖之构成循环依赖,故抽出)
       
       import { listen, emit } from '@tauri-apps/api/event'
      @@ -15,15 +17,15 @@ import { aiApi } from '@/api'
       import { useAppSettingsStore } from '@/stores/appSettings'
       import { state } from '@/stores/ai'
       import i18n from '@/i18n'
      -import { nextMsgId } from './aiShared'
      +import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
       import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
      -import { drainQueue, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './useAiSend'
       import { loadConversations } from './useAiConversations'
       import type { AiChatEvent, AiMessage, AiToolCallInfo } from '@/api/types'
       
       // composable 内非组件上下文(无 setup),用 vue-i18n 全局实例的 t 而非 useI18n()。
       // 通过 any 中转规避 vue-i18n 深度 message schema 泛型导致的 TS2589(类型实例化过深)。
      -const t = ((i18n as any).global.t as (key: string) => string).bind((i18n as any).global)
      +// CR-30-2: 签名放宽支持插值参数(复用现有 t('ai.xxx', {...}) 模式,如 aiShared.ts:75)。
      +const t = ((i18n as any).global.t as (key: string, params?: Record) => string).bind((i18n as any).global)
       
       let _unlistenAiEvent: (() => void) | null = null
       let _unlistenConvChanged: (() => void) | null = null
      @@ -113,31 +115,6 @@ export function flushCurrentText() {
         if (last && last.role === 'assistant') last.content = state.currentText
       }
       
      -/**
      - * 在全部消息中查找指定 id 的工具调用卡片(用于状态流转)
      - *
      - * 扫描策略:从尾部反向遍历并提前退出。tool_result/approval/completed
      - * 通常对应最近触发的 tool_use(agent 顺序执行工具并尽快回填结果),
      - * 反向扫描使其命中落在尾部 → 平均 O(1)、最坏 O(n)。(原正向全量扫描为 O(n²),
      - * 每条 tool_result 都从头扫整条历史。)
      - *
      - * 注:未引入 Map 索引。state.messages 在 newConversation /
      - * switchConversation / deleteConversation 等处被整体替换(且这些操作散落在
      - * useAiEvents 之外),独立索引极易与响应式数组失配形成"陈旧引用"——状态卡
      - * 片读到已不存在的对象。反向扫描零额外状态、改动最小且行为完全一致。
      - */
      -export function findToolCall(id: string): AiToolCallInfo | undefined {
      -  for (let i = state.messages.length - 1; i >= 0; i--) {
      -    const toolCalls = state.messages[i].toolCalls
      -    if (!toolCalls) continue
      -    // 同一消息内工具调用通常很少,沿用正向扫描;命中即返回
      -    for (let j = 0; j < toolCalls.length; j++) {
      -      if (toolCalls[j].id === id) return toolCalls[j]
      -    }
      -  }
      -  return undefined
      -}
      -
       /** token 用量展示开关(读 appSettings,与 Settings.vue 共享 key `df-show-token-usage`) */
       function isShowTokenUsage(): boolean {
         return appSettings.get('df-show-token-usage', false)
      @@ -197,6 +174,17 @@ export function handleEvent(event: AiChatEvent) {
             // 心跳事件:仅维持看门狗(已在上方 reset),无需额外处理;显式 case 防 switch 穿透
             break
       
      +    case 'AiStreamRetry': {
      +      // CR-30-2 / F-260616-07(d) / 决策 a1: 流前失败重试中——后端 emit AiError(Init Err)
      +      // 后再 emit 此事件。前端更新错误气泡内容为「重试 n/m」,对齐决策"错误气泡内更新"。
      +      // 看门狗已在上方 NO_RESET_WATCHDOG 之外(此事件不在集合内)reset,不卡整流超时。
      +      const lastErr = state.messages[state.messages.length - 1]
      +      if (lastErr && lastErr.isError) {
      +        lastErr.content = t('ai.aiStreamRetry', { attempt: event.attempt, max: event.max_attempts })
      +      }
      +      break
      +    }
      +
           case 'AiMaxRoundsReached':
             // F-260616-03: 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问。
             // 后端已 save 落库,这里仅翻 pendingMaxRounds=true 驱动 UI 卡片;看门狗已由
      @@ -284,6 +272,17 @@ export function handleEvent(event: AiChatEvent) {
             state.generatingConvId = null
             state.agentRound = 0 // AE-2025-07: agentic 结束,复位轮次(隐藏进度条)
             pendingMaxRounds.value = false // F-260616-03: 收尾(停止/续跑后新一轮达 max 才会再 set),清操作卡
      +      // UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true),
      +      // 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。
      +      // 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。
      +      if (event.incomplete) {
      +        state.messages.push({
      +          id: `incomplete-${nextMsgId()}`,
      +          role: 'assistant',
      +          content: t('ai.responseIncomplete'),
      +          timestamp: Date.now(),
      +        } as AiMessage)
      +      }
             // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
             if (isShowTokenUsage()) {
               state.lastTokenUsage = {
      @@ -304,8 +303,8 @@ export function handleEvent(event: AiChatEvent) {
             localStorage.removeItem('df-ai-text')
             void loadConversations()
             notifyConversationChanged()
      -      // 队列续发:当前完成后自动发下一条(后端 generating 已复位,不会被"正在生成中"拒绝)
      -      drainQueue()
      +      // 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖)
      +      emit('ai-drain-queue', {})
             break
           }
       
      @@ -379,7 +378,6 @@ export function useAiEvents() {
           stopListener,
           handleEvent,
           flushCurrentText,
      -    findToolCall,
           friendlyError,
           notifyConversationChanged,
         }
      diff --git a/src/composables/ai/useAiPanel.ts b/src/composables/ai/useAiPanel.ts
      index 608a8f5..1ecc476 100644
      --- a/src/composables/ai/useAiPanel.ts
      +++ b/src/composables/ai/useAiPanel.ts
      @@ -12,6 +12,7 @@ import { watch } from 'vue'
       import { aiApi } from '@/api'
       import { useAppSettingsStore } from '@/stores/appSettings'
       import { state } from '@/stores/ai'
      +import type { AiProviderConfig } from '@/api/types'
       
       const appSettings = useAppSettingsStore()
       
      @@ -47,6 +48,11 @@ export function restoreUiState() {
       }
       restoreUiState()
       
      +// F-260616-10: 启动恢复 activeProvider(对齐 df-ai-active-conv 模式)。
      +// loadProviders 后还会二次校验 DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。
      +const savedProvider = appSettings.get('df-ai-active-provider', null)
      +if (savedProvider) state.activeProvider = savedProvider
      +
       // loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次)
       watch(() => appSettings.cache['df-ai-ui'], (v) => applyUiState(v as any))
       
      @@ -87,9 +93,16 @@ function toggleMaximize() {
       
       // ── Providers / Skills / 清屏 ──
       
      -/** 拉取 provider 列表(用于顶部 bar 展示与切换) */
      +/** 拉取 provider 列表(用于顶部 bar 展示与切换) + 同步 DB 默认 provider 到 activeProvider */
       async function loadProviders() {
      -  state.providers = await aiApi.listProviders()
      +  const list = await aiApi.listProviders()
      +  state.providers = list
      +  // F-260616-10: 重启后 activeProvider=null,从 DB is_default 恢复(后端链路正确,仅前端缺同步)
      +  // CR-34-1: 守卫加有效性检查 — savedProvider 指向已删除 provider 时残留无效 id
      +  if (!state.activeProvider || !list.some((p: AiProviderConfig) => p.id === state.activeProvider)) {
      +    const def = list.find((p: AiProviderConfig) => p.is_default)
      +    if (def) state.activeProvider = def.id
      +  }
       }
       
       /** 拉取本机技能列表(用于 `/` 联想,静默失败) */
      @@ -101,10 +114,12 @@ async function loadSkills() {
         }
       }
       
      -/** 切换活跃 provider(后端 + 本地 state) */
      +/** 切换活跃 provider(后端 + 本地 state + 持久化) */
       async function setProvider(providerId: string) {
         await aiApi.setProvider(providerId)
         state.activeProvider = providerId
      +  // F-260616-10: 持久化到 appSettings,重启后恢复(对齐 df-ai-active-conv 模式)
      +  void appSettings.set('df-ai-active-provider', providerId)
       }
       
       /** 清空当前会话消息(后端 + 本地 state) */
      diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts
      index be2478b..bb00b04 100644
      --- a/src/composables/ai/useAiSend.ts
      +++ b/src/composables/ai/useAiSend.ts
      @@ -6,22 +6,24 @@
       //!   L2 force:  排队>30s → 弹 confirm → 用户确认后调 ai_chat_force_send 复位残留并强制发出
       //!
       //! 模块级私有:
      -//! - 无(队列存在 state.queue 中,看门狗/审批状态机依赖 events 与 stream)
      +//! - _pendingApprovalIds:审批按钮防抖守卫
       //!
       //! 耦合:
       //! - sendMessage 调 useAiEvents.startListener(useAiEvents 导出但不在解构集中暴露给组件,
       //!   故 startListener 同时经 useAiEvents 导出,sendMessage 直接 import 调用)
       //! - sendMessage 调 useAiStream.resetStreamWatchdog
      -//! - drainQueue 在 sendMessage 完成后由 handleEvent(AiCompleted) 调用 — 故 drainQueue 必须为模块级 export
      +//! - drainQueue 由 handleEvent(AiCompleted) 经 ai-drain-queue 事件总线触发(非直接调用,
      +//!   打破 events↔send 循环依赖);本模块模块级监听该事件
       
      +import { listen } from '@tauri-apps/api/event'
       import { invoke } from '@tauri-apps/api/core'
       import { aiApi } from '@/api'
       import { useAppSettingsStore } from '@/stores/appSettings'
       import { state } from '@/stores/ai'
       import i18n from '@/i18n'
       import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
      -import { startListener, findToolCall } from './useAiEvents'
      -import { nextMsgId } from './aiShared'
      +import { startListener } from './useAiEvents'
      +import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
       
       const t = ((i18n as any).global.t as (k: string, named?: Record) => string).bind((i18n as any).global)
       
      @@ -33,61 +35,14 @@ const QUEUE_LIMIT = 10
       /// 排队超时阈值(ms) — 超过后弹 confirm 让用户选择"强制发送"或继续等(B-260616-02)
       const QUEUE_TIMEOUT_MS = 30_000
       
      -/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝,防 pending_approval 永久卡死对话。
      -/// 纯前端定时器:审批依赖页面交互,页面关闭/不处理即超时 auto-reject 合理(后端无超时)。
      -/// TODO: 未来接 Settings 可配(df-ai-approval-timeout-ms),当前写死减范围(避免改 Settings.vue god file)
      -const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
      +// 审批计时器(startApprovalTimer/clearApprovalTimer/clearAllApprovalTimers)已下沉到 aiShared.ts
      +// 打破 useAiEvents ↔ useAiSend 循环依赖。本模块 re-export 供组件使用。
       
       /**
      - * 审批超时计时器:toolCallId → timer。
      - * 在 AiApprovalRequired(审批开始等待)启动,AiApprovalResult/AiToolCallCompleted(状态离开 pending_approval)、
      - * onStreamTimeout(整流超时收尾)、stopListener(组件卸载)时清除。
      - * 与 useAiEvents._toolTimers(慢执行 toast)同模式,但本计时器到点会改状态(auto-reject),
      - * 故与 running 态 toast 解耦——审批态不应被 toast 计时器误触发。
      + * F-260616-06: 审批按钮防抖守卫——同一 id 短期多次点击只发一次 IPC。
      + * IPC 往返 <100ms,300ms 窗口足够覆盖竞态。
        */
      -const _approvalTimers = new Map>()
      -
      -/**
      - * 启动审批超时计时器(幂等:同 id 重复启动不重建,沿用现有计时器)。
      - * 到点回调:调 ai_approve(id, false) 自动拒绝 + push 一条系统错误消息到 state.messages
      - * (参照 onStreamTimeout push 模式;composable 无 UI 上下文,toast 跨层复杂,系统消息更简单闭环)。
      - * TODO: toast 跨层接线(目前仅 push 系统消息,无即时强提示)
      - */
      -export function startApprovalTimer(toolCallId: string, toolName: string): void {
      -  if (_approvalTimers.has(toolCallId)) return
      -  const timer = setTimeout(() => {
      -    _approvalTimers.delete(toolCallId)
      -    // 自动拒绝:调 ai_approve(false) 通知后端不执行该工具
      -    aiApi.approve(toolCallId, false).catch(e => {
      -      // IPC 失败(网络断/后端已关):后端状态已乱,前端仍 push 提示让用户知晓
      -      console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
      -    })
      -    // push 系统错误消息(参照 onStreamTimeout push 模式)
      -    state.messages.push({
      -      id: `approval-timeout-${nextMsgId()}`,
      -      role: 'assistant',
      -      content: t('ai.approvalTimeout', { toolName }),
      -      isError: true,
      -      timestamp: Date.now(),
      -    })
      -  }, APPROVAL_TIMEOUT_MS)
      -  _approvalTimers.set(toolCallId, timer)
      -}
      -
      -/** 清除单个审批计时器(审批完成/拒绝时调用) */
      -export function clearApprovalTimer(toolCallId: string): void {
      -  const timer = _approvalTimers.get(toolCallId)
      -  if (timer) {
      -    clearTimeout(timer)
      -    _approvalTimers.delete(toolCallId)
      -  }
      -}
      -
      -/** 清除全部审批计时器(onStreamTimeout/stopListener 整流收尾时调用) */
      -export function clearAllApprovalTimers(): void {
      -  for (const timer of _approvalTimers.values()) clearTimeout(timer)
      -  _approvalTimers.clear()
      -}
      +const _pendingApprovalIds = new Set()
       
       /**
        * 入队时记录时间戳,供 UI 展示排队时长和触发超时提示。
      @@ -348,8 +303,12 @@ export async function tryForceSend(confirmFn: (msg: string) => Promise)
        *  B-260616-08:不再乐观置 running——原写法让 .ai-tool-approval 整块消失(切骨架屏),
        *  按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 */
       async function approveToolCall(toolCallId: string, approved: boolean) {
      -  // 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
      -  const tc = findToolCall(toolCallId)
      +  // F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
      +  if (_pendingApprovalIds.has(toolCallId)) return
      +  _pendingApprovalIds.add(toolCallId)
      +  try {
      +    // 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
      +    const tc = findToolCall(toolCallId)
         // 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
         // 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 loading。
         // 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
      @@ -370,6 +329,9 @@ async function approveToolCall(toolCallId: string, approved: boolean) {
           }
           throw e
         }
      +  } finally {
      +    _pendingApprovalIds.delete(toolCallId)
      +  }
       }
       
       /** 批量审批:遍历 pendingApprovals 逐个调用 approveToolCall */
      @@ -420,8 +382,17 @@ export function useAiSend() {
           stopChat,
           isQueueTimedOut,
           tryForceSend,
      +    // 审批计时器已下沉到 aiShared.ts,此处 re-export 供组件使用
           startApprovalTimer,
           clearApprovalTimer,
           clearAllApprovalTimers,
         }
       }
      +
      +// ARC-06: 模块级监听 ai-drain-queue 事件(由 useAiEvents AiCompleted case 发射)
      +// 打破 useAiEvents ↔ useAiSend 循环依赖:events 不再直接 import 调用 drainQueue
      +let _drainUnlisten: (() => void) | null = null
      +export async function initDrainQueueListener(): Promise {
      +  if (_drainUnlisten) return
      +  _drainUnlisten = await listen('ai-drain-queue', () => { drainQueue() })
      +}
      diff --git a/src/composables/ai/useAiWindow.ts b/src/composables/ai/useAiWindow.ts
      index 67b35ea..abe21a5 100644
      --- a/src/composables/ai/useAiWindow.ts
      +++ b/src/composables/ai/useAiWindow.ts
      @@ -29,6 +29,13 @@ async function detachPanel() {
           localStorage.setItem('df-ai-gen', state.generatingConvId)
           localStorage.setItem('df-ai-text', state.currentText)
         }
      +  // 主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context,
      +  // stores/ai.ts 模块级单例仅在同 webview 内共享),清主窗口 state 不影响分离窗口生成态。
      +  // 分离窗口接管生成后,主窗口不应再持生成态残留(防重开面板时 UI 显生成中幽灵/进度条)。
      +  // watchdog 在主窗口 AiChat 卸载(App.vue v-if detached)→ stopListener → clearStreamWatchdog 时已清,
      +  // 此处仅清内存 state 视觉残留;localStorage 快照保留供 resumeInDetached 恢复。
      +  state.streaming = false
      +  state.generatingConvId = null
         const convId = state.activeConversationId
         const sep = convId ? `?conv=${encodeURIComponent(convId)}` : ''
         const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
      diff --git a/src/i18n/en/ai.ts b/src/i18n/en/ai.ts
      index 9a04054..f8e0b60 100644
      --- a/src/i18n/en/ai.ts
      +++ b/src/i18n/en/ai.ts
      @@ -22,8 +22,12 @@ export default {
           errorAuth: 'Request failed: the API key is invalid or lacks permission.',
           errorTimeout: 'The response timed out. Please try again.',
           errorNetwork: 'Network connection failed. Please check your network.',
      +    // CR-30-2 / F-260616-07(d) / decision a1: stream pre-failure retry bubble copy
      +    aiStreamRetry: '⚠ Request failed, retrying ({attempt}/{max})…',
           streamInterruptedAfterTool: '⚠ The tool finished, but the following reply was interrupted (no data stream for a long time). You can click Continue to retry.',
           streamInterrupted: '⚠ The response was interrupted (no data stream for a long time). This may be due to a network disconnection or a backend error. Please try again.',
      +    // UX-2025-04 / CR-30-2 / decision a1: system notice bubble after mid-stream failure preserve (frontend mirrors backend session.messages)
      +    responseIncomplete: '⚠ The response is incomplete due to a network interruption. The above is the partial content received. You can resend to get a full reply.',
           queueFull: 'Send queue is full (max {limit} items)',
           forceSendConfirm: 'AI generation appears stuck. Force-stop and send anyway? (This may interrupt the current response)',
           forceSendBtn: 'Force Send',
      diff --git a/src/i18n/en/settings.ts b/src/i18n/en/settings.ts
      index 1678216..208a71a 100644
      --- a/src/i18n/en/settings.ts
      +++ b/src/i18n/en/settings.ts
      @@ -79,6 +79,9 @@ export default {
           labelAgentMaxIterations: 'Agent max iterations',
           descAgentMaxIterations: 'Max rounds of the agentic loop (stream → tool → feedback) (1-50, default 10); changes take effect on the next message',
       
      +    labelAgentMaxRetries: 'Stream failure retries',
      +    descAgentMaxRetries: 'Auto-retry count on stream failure (0-10, default 3); only retries pre-stream failures (no token output yet); mid-stream failures are abandoned',
      +
           // ===== Knowledge base panel =====
           panelKnowledge: '📚 Knowledge base',
           labelAutoExtract: 'Auto-distill knowledge',
      diff --git a/src/i18n/zh-CN/ai.ts b/src/i18n/zh-CN/ai.ts
      index ffb7ba9..ae95d00 100644
      --- a/src/i18n/zh-CN/ai.ts
      +++ b/src/i18n/zh-CN/ai.ts
      @@ -22,8 +22,12 @@ export default {
           errorAuth: '调用失败:API Key 无效或无权限',
           errorTimeout: '响应超时,请重试',
           errorNetwork: '网络连接失败,请检查网络',
      +    // CR-30-2 / F-260616-07(d) / 决策 a1: 流前失败重试中错误气泡内更新文案
      +    aiStreamRetry: '⚠ 调用失败,正在重试({attempt}/{max})…',
           streamInterruptedAfterTool: '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。',
           streamInterrupted: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
      +    // UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文后的系统提示气泡(前端镜像后端 session.messages)
      +    responseIncomplete: '⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。',
           queueFull: '待发送队列已满(最多 {limit} 条)',
           forceSendConfirm: 'AI 生成似乎卡住了。是否强制结束当前生成并发送消息?(可能中断正在生成的回复)',
           forceSendBtn: '强制发送',
      diff --git a/src/i18n/zh-CN/settings.ts b/src/i18n/zh-CN/settings.ts
      index 764f3d1..a6cef75 100644
      --- a/src/i18n/zh-CN/settings.ts
      +++ b/src/i18n/zh-CN/settings.ts
      @@ -79,6 +79,9 @@ export default {
           labelAgentMaxIterations: 'Agent 最大轮次',
           descAgentMaxIterations: 'Agentic 循环(流式→工具→回传)的最大轮次(1-50,默认 10);热改下次发消息生效',
       
      +    labelAgentMaxRetries: '流式失败重试次数',
      +    descAgentMaxRetries: '流式对话失败时自动重试次数(0-10,默认 3);只重试未输出任何 token 的流前失败,已输出文本的失败直接放弃',
      +
           // ===== 知识库面板 =====
           panelKnowledge: '📚 知识库',
           labelAutoExtract: '自动提炼知识',
      diff --git a/src/stores/ai.ts b/src/stores/ai.ts
      index ae88218..aee6baa 100644
      --- a/src/stores/ai.ts
      +++ b/src/stores/ai.ts
      @@ -36,7 +36,7 @@ import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, A
       const MESSAGE_CAP = 200
       import { useAiEvents } from '@/composables/ai/useAiEvents'
       import { useAiStream } from '@/composables/ai/useAiStream'
      -import { useAiSend } from '@/composables/ai/useAiSend'
      +import { useAiSend, initDrainQueueListener } from '@/composables/ai/useAiSend'
       import { useAiConversations } from '@/composables/ai/useAiConversations'
       import { useAiWindow } from '@/composables/ai/useAiWindow'
       import { useAiPanel } from '@/composables/ai/useAiPanel'
      @@ -130,6 +130,8 @@ const filteredConversations = computed(() => {
        * (composable 内部全部是模块级单例实现,展开的只是引用,无重复实例化开销)。
        */
       export function useAiStore() {
      +  // ARC-06: 启动 drain-queue 事件监听(由 useAiEvents AiCompleted 经事件总线触发)
      +  void initDrainQueueListener()
         return {
           state,
           filteredConversations,
      diff --git a/src/views/Knowledge.vue b/src/views/Knowledge.vue
      index ceaa447..c7e4021 100644
      --- a/src/views/Knowledge.vue
      +++ b/src/views/Knowledge.vue
      @@ -619,11 +619,11 @@ onMounted(() => {
       .detail-section { padding-bottom: 18px; margin-bottom: 18px; border-bottom: 0.5px solid var(--df-border); }
       .detail-section:last-child { border-bottom: none; margin-bottom: 0; }
       
      -.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
      -.detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
      -.detail-kind-icon { font-size: 20px; }
      -.detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; }
      -.detail-actions { display: flex; gap: 6px; flex-shrink: 0; }
      +.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; min-width: 0; }
      +.detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; overflow: hidden; }
      +.detail-kind-icon { font-size: 20px; flex-shrink: 0; }
      +.detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; overflow-wrap: break-word; min-width: 0; }
      +.detail-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
       
       .detail-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; font-size: 11px; }
       .badge-plain { color: var(--df-text-dim); }
      @@ -725,9 +725,11 @@ onMounted(() => {
       .form-textarea { min-height: 80px; resize: vertical; font-family: inherit; }
       .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
       
      -/* ===== 响应式:窄屏详情标题防竖线化(B-260616-19) ===== */
      +/* ===== 响应式:窄屏详情标题防竖线化(B-260619) ===== */
       @media (max-width: 760px) {
         .kn-layout { grid-template-columns: 1fr; }
      +  .detail-head { flex-direction: column; align-items: flex-start; }
      +  .detail-actions { width: 100%; }
         .detail-title { font-size: 15px; }
       }
       
      diff --git a/src/views/Settings.vue b/src/views/Settings.vue
      index 479a52e..2da2efe 100644
      --- a/src/views/Settings.vue
      +++ b/src/views/Settings.vue
      @@ -277,6 +277,17 @@
                            @change="syncAgentMaxIterations" />
                   
                 
      +          
      +
      + {{ $t('settings.labelAgentMaxRetries') }} + {{ $t('settings.descAgentMaxRetries') }} +
      +
      + +
      +
      @@ -626,6 +637,8 @@ const settings = reactive({ llmPerConvConcurrency: appSettings.get('df-llm-per-conv-concurrency', 2), // F-260616-01: Agentic 循环最大轮次,默认 10(与后端 DEFAULT_MAX_AGENT_ITERATIONS 对齐) agentMaxIterations: appSettings.get('df-ai-agent-max-iterations', 10), + // F-260616-07: 流式失败重试次数,默认 3(与后端 DEFAULT_MAX_AGENT_RETRIES 对齐) + agentMaxRetries: appSettings.get('df-ai-agent-max-retries', 3), }) function applyTheme(theme: string) { @@ -664,6 +677,10 @@ watch(() => settings.agentMaxIterations, (v) => { void appSettings.set('df-ai-agent-max-iterations', v) }) +watch(() => settings.agentMaxRetries, (v) => { + void appSettings.set('df-ai-agent-max-retries', v) +}) + // 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC) // 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器 let _concurrencyTimer: ReturnType | null = null @@ -704,6 +721,23 @@ function syncAgentMaxIterations() { }, 300) } +// 同步流式失败重试次数到后端(debounce 300ms) +// F-260616-07: 后端 loop 入口 load 快照锁定边界——热改后当前 loop 不受影响, +// 下次发消息才生效。双 clamp:此处 0-10 + 后端 command 再 clamp 0-10 +let _agentRetryTimer: ReturnType | null = null +function syncAgentMaxRetries() { + // clamp 0-10,防越界 + settings.agentMaxRetries = Math.min(10, Math.max(0, settings.agentMaxRetries ?? 3)) + if (_agentRetryTimer) clearTimeout(_agentRetryTimer) + _agentRetryTimer = setTimeout(async () => { + try { + await aiApi.setAgentMaxRetries(settings.agentMaxRetries) + } catch (e) { + console.error('同步流式重试次数失败:', e) + } + }, 300) +} + watch(() => settings.language, (val) => { void appSettings.set('df-language', val) // 同步 i18n 实例,让界面立即跟随语言切换 @@ -772,12 +806,15 @@ onMounted(() => { syncConcurrencyConfig() // F-260616-01: 启动同步一次 Agentic 最大轮次(防刷新页面后后端未恢复用户设定值) syncAgentMaxIterations() + // F-260616-07: 启动同步一次流式失败重试次数(防刷新页面后后端未恢复用户设定值) + syncAgentMaxRetries() }) // 组件卸载清全部 debounce timer,防 unmount 后旧 timer 仍 fire 写已销毁 reactive(timer 泄漏 FR-C3) onUnmounted(() => { if (_concurrencyTimer) clearTimeout(_concurrencyTimer) if (_agentIterTimer) clearTimeout(_agentIterTimer) + if (_agentRetryTimer) clearTimeout(_agentRetryTimer) if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer) if (_toastTimer) clearTimeout(_toastTimer) })