diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 50e9661..3ca3467 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -742,11 +742,22 @@ impl LlmProvider for AnthropicCompatProvider { // 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。 // usage 累积:message_start 给 input_tokens,message_delta 给累计 output_tokens(非增量),message_stop 带出。 let mut usage_accum: Option = None; + // B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。 + // precheck(Init 路径,发送前)漏的 case,靠此在 SSE error 事件暴露实际 messages 结构到前端 raw。 + let messages_summary = Self::summarize_messages(&body.messages); let stream = resp .bytes_stream() .eventsource() .map(move |event| match event { - Ok(ev) => Ok(apply_anthropic_event(&ev.data, &mut usage_accum)), + Ok(ev) => { + let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum); + // GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream + // 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。 + if let Some(err) = chunk.error.as_mut() { + *err = format!("{} | messages 摘要: {}", err, messages_summary); + } + Ok(chunk) + } Err(e) => { // 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message, // 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...): diff --git a/docs/todo.md b/docs/todo.md index b05ebe3..e14546a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -48,6 +48,32 @@ > **📦 已完成项归档**: [07-项目管理/todo归档/2026-06.md](./07-项目管理/todo归档/2026-06.md) — 2026-06-18 拆分, 已完成 `[x]` 与历史分析段迁此。另有 [2026-06-18.md](./07-项目管理/todo归档/2026-06-18.md) — 本次归档。 +### ✅ 2026-06-19 BUG-260619-06 L0 clear 致冷启动审批丢失(批2+跨批遗留·非batch8回归·方案A已修复) + +> **来源**:CR-260619-06 巡检独立核验(不信主代自审 PASS)。F-09 batch8(commit 6ad4ec2)在 L0 握手新增 `session.pending_approvals.clear()`,与冷启动 restore 重建链路时序冲突,致重启后待审批工具**完全丢失**。 + +**现象**:重启 devflow 后,DB 里 status=pending 的待审批工具(tool_call)在前端不显示 toolCard,用户无法审批;即使触发审批报「未找到挂起的审批」。restore 功能被 batch8 clear 抵消。 + +**根因(跨函数时序冲突 + 设计契约违反)**: +1. **restore 填充(冷启动同步)**:`AppState::init`(`state.rs:336` setup:25 block_on)→ `restore_pending_approvals`(`audit.rs:291`)从 DB `list_pending` 重建 pending 到 `session.pending_approvals` 内存。 +2. **clear 必触发(冷启动必达)**:`AiChat.vue:2229 onMounted → store.startListener()` → `useAiEvents.ts:386 emit('ai-client-ready')` → L0 回调(`lib.rs:38`)→ **`lib.rs:62 session.pending_approvals.clear()` 无条件清空全部**(含 restore 重建)。 +3. **前端不显示**:`ai_pending_tool_calls`(`commands.rs:508-517` 数据源 = `session.pending_approvals.values()` 内存,非 DB)→ clear 后返空。 +4. **审批报错**:`ai_approve`(`commands.rs:328 remove` 内存)→ None → DB `find_by_tool_call_id` 查到 status=pending 但不在「已处理」白名单(`:336 executed/rejected/failed`)→ `:346 return Err「未找到挂起的审批」`。 + +**batch8 回归证据**:`git show 6ad4ec2 -- lib.rs` 确认 `session.pending_approvals.clear()`(`:62`)是 batch8 **新增**;批2 版本 L0 只 reset generating,不 clear pending。 + +**设计契约直接违反**:`commands.rs:1672-1675` switchConversation 用 `retain(... != Some(&conversation_id))` 精确保留 restore 重建的 pending,注释明说「防 init 重建的内存 HashMap 被清空,重启恢复链路:restore → switchConversation → ai_pending_tool_calls → ai_approve」。L0 `:62 clear()` 无条件清空全部 → 断该链路。 + +**修复方案**: +- **方案 A(推荐)**:`lib.rs:62` `clear()` → `retain(|_, a| !a.recovered)`。仅清非 recovered(本次会话/HMR 死 pending),保留 restore 重建(`PendingApproval.recovered=true` `audit.rs:331`)。对齐 switchConversation 保护意图,两全。 +- **方案 B**:删 `lib.rs:62`(批2 前无此 clear;pending 清理由 switchConversation retain / delete_conversation retain `:1723` / ai_chat_clear 各路径精确管理)。 + +**核验清单(实施时)**:① cargo check --workspace;② 手测:DB 注入 status=pending 记录 → 重启 → 前端显待审批 → 审批成功落库;③ 回归 HMR 场景(后端不重启,死 pending 仍被清)。 + +**关联**:F-09 batch8 / CR-260619-06 / 详单见 [待审查.md CR-260619-06](./待审查.md)。 + +--- + ### 🔴 2026-06-18 Agentic 最大轮次设置不生效(设 30 仍按 10 截断·session-role-diagnose-only·未实施) > 用户报:Settings 里 Agentic 最大循环轮次设 30,但实际跑到 10 就提醒「达到最大」。走查全链路定位根因 + 记 todo,不改代码。 @@ -80,7 +106,7 @@ > 本轮 git diff 核验工作区未提交改动 + 最新提交 1cd7652。session-role-diagnose-only。 -- [ ] B-260617-11 [P2] — **tauri.conf.json 打包目标收窄未提交,若误入库锁死非 Windows 构建**。工作区改动 `bundle.targets: "all" → ["nsis"]`(src-tauri/tauri.conf.json:28),收窄到仅 Windows NSIS 安装包。若意图为本地只打 Windows 包,合理;**但若随其他改动一并提交**,macOS(dmg/app)、Linux(deb/appimage)构建将不可用,影响其他开发者/CI。**确认点**:该改动是临时本地构建还是有意入库?临时则建议提交前 revert 此行;有意则建议改为按平台条件配置而非硬编码单一 target。—— src-tauri/tauri.conf.json(:28) +- [x] ✅(主代核验·tauri.conf.json:28 `["nsis"]` 已入库 commit a2871a6 非临时工作区·当前 Windows 开发保留合理·**WATCH**:跨平台意图待用户,若需 mac/Linux 改 `"all"` 或按平台条件配置) B-260617-11 [P2] — **tauri.conf.json 打包目标收窄**。`bundle.targets: ["nsis"]`(:28)锁 Windows NSIS,已入库。当前 Windows 开发保留;跨平台待用户定。—— src-tauri/tauri.conf.json(:28) ### 🔧 2026-06-17 走查·DeepSeek reasoning_content 实施审查(P1 提交不完整) @@ -349,4 +375,61 @@ - [ ] **UX-260618-17 [P1🟡]** — **ProjectDetail.handleApprovalMulti 漏 submitting 复位(防双击破口)**(CR-260618-25 前端审查发现·Agent B 对抗核验)。`ProjectDetail.vue:431-437` handleApprovalMulti 无 submitting set true/finally,模板 :196-202 只绑 `:disabled="multiDecisions.length === 0"` → 多选审批 approveHumanApproval IPC 进行中按钮不禁用,用户可重复点确认重复触发 IPC。同模板 handleApproval(:417-425)正确 try/finally,handleApprovalMulti 漏对齐。**修法**:顶 `submitting.value = true` + `try { ... } finally { submitting.value = false }`,对齐 handleApproval。— `src/views/ProjectDetail.vue`(:431-437 + :196-202) +### 🔧 2026-06-19 文件拆分升级(3 代理并行分析·建任务·未实施) + +> 大文件统计(总 51143 行)+ 3 代理并行分析拆分方案。与已有 SMELL-P0-2(tool_registry)/SMELL-P0-3(AiChat.vue)/SMELL-P1-9(crud.rs) 合并。**通用执行原则**:可见性升级(私有 fn/struct → pub(super))/ 测试跟随被测函数 / **不改逻辑不改 await 边界不改签名** / 三段式验证(cargo check+test+clippy 分 crate,对齐 [[workflow-cargo-timeout-wrap]])/ 保守方案(热路径整块搬不拆函数体)。 + +**P0(超红线/最脏,本周期优先)** + +- [ ] **REFACTOR-260619-01 [P0]** — **commands.rs(ai,1923 行)拆 5 模块**。5 组职责正交:B 发送审批控制(1140,13 IPC)/C 提供商(305)/D 会话CRUD(360)/E 杂项(63)+A helper。**先拆 C `provider_cfg.rs`(零风险试水:仅依赖 ai_providers 无 session 锁/per_conv/spawn)→ D `conversation_crud.rs`(`conversation.rs` 已存在,命名避冲突)→ E `misc_cfg.rs`→ B `chat_control.rs`(最复杂,等 F-09 B 批4 更稳)**。关键:`pub use self::commands::*;` glob 保留(mod.rs:58)否则 invoke_handler 注册断。— `src-tauri/src/commands/ai/commands.rs` + `mod.rs:58` +- [ ] **REFACTOR-260619-02 [P0]** — **anthropic_compat.rs(1025 行)拆模块**。含本会话加的 precheck/summarize(诊断)+ convert_request(159)+ SSE 解析(109)。**保守拆 3 模块**:`types.rs`(请求响应结构体 ~90)+ `sse.rs`(apply_anthropic_event ~160+测试)+ 残留 provider.rs(convert/complete/stream/precheck 留 impl 块不拆,因 Rust impl 不跨文件)。激进拆 4(加 convert.rs)需把 convert_request 等 4 关联 fn 改自由函数(动 6 处 Self:: 调用点),风险高不推荐。— `crates/df-ai/src/anthropic_compat.rs` +- [ ] **REFACTOR-260619-03 [P0]** — **audit.rs(959 行)拆 5 模块**。`audit/{mod,list,reason,finalize,dedup,process}.rs`。`process_tool_calls`(250+行热路径)**整块搬 process.rs 不拆函数体**(锁内 await 边界不动,CR-260618-11#5 性能注记)。11+ 私有 fn 升 pub(super):audit_tool_call/audit_finalize/find_cached_high_risk_result/build_write_file_diff/build_approval_reason 等。`PENDING_APPROVAL_PLACEHOLDER`/`risk_str` 提 mod.rs 共享。— `src-tauri/src/commands/ai/audit.rs` + +**P1** + +- [ ] **REFACTOR-260619-04 [P1]** — **ToolCard.vue(1527 行)拆 5 子组件+composable+util**。先 `useToolFormat.ts`(纯函数 ~280,零风险)+ `ToolResultBody.vue`(420 最大块);再 ToolCardHeader/ToolApproval/useToolApproval。风险:折叠态 shouldKeepOpen 三处共享 / 审批状态机断链(B-260616-08 回归)/ ToolCardList 批量审批联动。— `src/components/ToolCard.vue` + `ToolCardList.vue` +- [ ] **REFACTOR-260619-05 [P1]** — **agentic.rs(1231 行)抽 agentic_runtime.rs + agentic_stream.rs**。主 loop 是**单函数 720 行不可按函数拆**(工具执行/审批是 loop 内 if 分支;process_tool_calls 在 audit.rs)。先抽 A+E+F `agentic_runtime.rs`(GeneratingGuard+try_continue_agent_loop+ContinueSnapshot,~195,最高收益最低风险)+ B+C `agentic_stream.rs`(StreamOutcome+stream_one_provider,~165)。**D run_agentic_loop 等 F-09 B 批4 落地再评估**(避免 per_conv 双线作战)。— `src-tauri/src/commands/ai/agentic.rs` +- [ ] **REFACTOR-260619-06 [P1]** — **ai_node.rs(1107 行)拆 3 模块**。`ai/{mod,params,ai_node,self_review}.rs`。params.rs(provider 解析 helper ~270)+ ai_node.rs(~130)+ self_review.rs(~300)。测试分块清晰(752/939/1069 三段),低风险。fixture provider_stub/config_with 留 params.rs `pub(super)`。— `crates/df-nodes/src/ai_node.rs` +- [ ] **REFACTOR-260619-07 [P1]** — **(已有 SMELL-P0-3)AiChat.vue(4075)拆 ConversationSidebar/ChatHeader/MessageList/ChatInput**。方案已定,状态/composable 已外移,拆 template+局部 script。前置:先提交工作区未提交改动(本会话 shouldRenderMsg/scroll/1214 预检等)。— `src/components/AiChat.vue` +- [ ] **REFACTOR-260619-08 [P1]** — **(已有 SMELL-P0-2)tool_registry.rs(2023)按功能分组注册函数拆**。register_crud_tools/register_file_tools(已抽)/register_workflow_tools 等,每个 <200 行。— `src-tauri/src/commands/ai/tool_registry.rs` + +**P2 暂缓(窗口未到/收益低)** + +- [ ] **REFACTOR-260619-09 [P2 暂缓]** — **context.rs(1332)等 F-15 压缩链路稳定再拆**。生产 745+测试 587。impl 跨文件方案(同 crate 多 impl 块,零字段可见性改动)。先 sanitize.rs(最大连续块 ~184)。当前 context.rs 被 F-15/压缩频繁改动,拆分窗口未到。— `crates/df-ai/src/context.rs` +- [ ] **REFACTOR-260619-10 [P2]** — **scan.rs(1015)拆 4 模块**(stack/discover/readme/sample)。纯函数低风险收益低。共享 SAMPLE_IGNORED_DIRS/truncate_chars/read_readme_raw 提 mod.rs。— `crates/df-project/src/scan.rs` +- [ ] **(已有 SMELL-P1-9)crud.rs(2212)按表拆** project_repo/task_repo/conversation_repo/idea_repo。— `crates/df-storage/src/crud.rs` + +**执行顺序建议**:01-C provider_cfg(零风险试水) → 02 anthropropic → 03 audit → 04 ToolCard useToolFormat → 05 agentic_runtime → 06 ai_node → 07 AiChat.vue → 08 tool_registry。每步 cargo check+test+clippy 分 crate + 手测。 + +--- + + +### 💡 2026-06-19 新需求(任务关联灵感·已分析·待实施) + +> 用户需求:推进任务时能即时、方便地关联到灵感及灵感的对抗式评估等相关信息。当前关联链路是 Idea → promote → Project → Tasks,任务和灵感只能通过项目间接关联,无法直接追溯。 + +- [ ] **F-260619-01 [P2]** — **任务关联灵感:TaskRecord 新增 idea_id 字段**。推进任务时即时查看关联灵感的描述、多维评分(scores)、对抗式评估(ai_analysis)等信息,辅助决策。 + + **数据模型变更**:TaskRecord 新增 `pub idea_id: Option`(关联灵感 ID,可空=未关联),`#[serde(default)]` 兼容旧 JSON。 + + **涉及改动(7 处)**: + + 1. **迁移 V20**(`migrations.rs`):tasks 表 `ALTER TABLE ADD COLUMN idea_id TEXT`(nullable,老数据 NULL);V1 建表 SQL 同步补 idea_id 列(新库直接有);steps 数组追加 `(20, migrate_v20)`;用 `column_exists` 探测(同 v17/v18/v19 模式),对新库/老库均安全。 + + 2. **Model**(`models.rs`):TaskRecord 加 `pub idea_id: Option` + `#[serde(default)]`。 + + 3. **CRUD 层**(`crud/task_repo.rs`):`task_from_row` 加 `idea_id: row.get("idea_id")?`;`impl_repo!` 的 insert/update SQL 加 idea_id 列 + params 占位;`list_active` / `list_deleted` / `advance_status_atomic` 的显式 SELECT 列表补 idea_id。 + + 4. **白名单**(`crud/settings.rs`):tasks 白名单加 `"idea_id"`(允许 `update_field` 改关联)。 + + 5. **命令层**(`commands/task.rs`):`CreateTaskInput` 加 `pub idea_id: Option`;`create_task` 构造 TaskRecord 时写入 idea_id;`update_task` 对 idea_id 做跨表存在性校验(对标 project_id 校验模式:查 ideas 表确认存在,空值=解除关联允许通过)。 + + 6. **AI 工具层**(`commands/ai/tool_registry.rs`):`create_task` 工具 schema 加 idea_id 可选参数;`update_task` 工具 schema 加 idea_id(描述说明可关联灵感)。 + + 7. **前端**:任务创建表单加灵感选择器(下拉选 ideas 列表,可空);任务详情页加关联灵感卡片(只读,展示灵感标题 + 描述摘要 + 多维评分 scores + 对抗式评估 ai_analysis + promoted_to 状态);`update_task` 支持 idea_id 字段更新(空值解除关联)。前端获取方式:任务详情展开时若 idea_id 非空,调 `list_ideas` 或新增 `get_idea_by_id` IPC 拉取关联灵感记录渲染卡片。 + + **验收标准**:① 创建任务时可选关联灵感;② 任务详情页展示关联灵感的描述 + 评分 + 对抗式评估;③ 可更新/解除关联(update_task idea_id = "" 清空);④ 老任务(idea_id NULL)无回归;⑤ `cargo check --workspace EXIT 0` + `vue-tsc EXIT 0`。 + + — `crates/df-storage/src/{models.rs,migrations.rs,crud/task_repo.rs,crud/settings.rs}` + `src-tauri/src/commands/{task.rs,ai/tool_registry.rs}` + 前端任务组件 + --- diff --git a/docs/待审查.md b/docs/待审查.md index 79c89e5..8bbeb05 100644 --- a/docs/待审查.md +++ b/docs/待审查.md @@ -806,6 +806,83 @@ - **关联**:F-09 阶段2 batch5(决策c-1 落地)。 - **待修项回流 todo**: **无** 🔴/🟡 项(WATCH-1 为 prompt 描述修正,非代码问题) +### CR-260619-06 F-09 batch8 启动恢复多conv restore+L0清残留(纯后端)(agent f09-batch8·主代兜底·commit 6ad4ec2) — ✅ 已审(PASS·⚪1 WATCH + 🟡1 跨批遗留·独立 grep/read + git diff 时序对抗核验·不跑 cargo 避 batch5 中间态) + +- **范围**(2 文件):audit.rs restore_pending_approvals 多 conv 分配(按 conversation_id 分组惰性建/复用 PerConvState,无主 None 不建 R-9)+ 2 单测(分配不变量/全 None 无 per_conv);lib.rs L0 握手遍历 per_conv 清多 conv 残留 generating(dirty_convs + 逐个复位 + 各发 AiCompleted 补偿 + active 兜底)。 +- **审查要点**:① restore 多 conv 分配正确性(convs_restored 去重 + conv() 幂等惰性建);② 无主 None 不建 per_conv(R-9);③ L0 遍历全 per_conv(非单 active)清残留;④ 各 dirty conv AiCompleted 补偿(按 conversation_id 路由);⑤ active 兜底(双写期边界);⑥ 与双写兼容(顶层双写复位);⑦ 不改 IPC 签名/前端/顶层字段。 +- **验证(主代兜底)**:cargo check --workspace 0 + test 98(96+2 新)+ grep restore 多 conv/L0 遍历印证。 +- **关联**:F-09 阶段2 batch8(**F-09 后端完整**;batch4 启用·前后端待用户)。 + +**复审结论(2026-06-19·独立 grep/read 核验源码当前形态 + git diff 跨版本时序对抗·commit 6ad4ec2·**不跑 cargo** 避 batch5 llm_concurrency 中间态误报·memory [[review-batching-worktree-transient]])**: ✅ **PASS** — 🔴0 🟡1(跨批遗留·非 batch8 引入)⚪1 + +**逐项核验表(file:line 佐证 + 判定)**: + +| 项 | 核验点 | 佐证 | 判定 | +|---|---|---|---| +| A-restore 多 conv 分配 | convs_restored HashSet 去重 + conv() 幂等惰性建 | `audit.rs:306` `let mut convs_restored: HashSet` · `:316-323` `if let Some(cid)=rec.conversation_id.as_deref() { if !cid.is_empty() && convs_restored.insert(cid.to_string()) { let _ = session.conv(cid); } }` — insert 返 true 仅首次触发 conv(),同 conv 二次 insert 返 false 跳过(去重);conv() 内 `entry().or_insert_with(PerConvState::new)`(mod.rs:427-431)已存在复用同实例,幂等无副作用 | ✅ | +| B-无主 None 不建 per_conv(R-9) | conversation_id=None 不进 if 分支 | `audit.rs:316` `if let Some(cid)=rec.conversation_id.as_deref()` — None 不匹配 Some 模式,跳过 conv() 调用,直接走 `:324-338` insert pending_approvals 单层表;`:309-312` risk 解析失败 continue 守卫保留(损坏记录不恢复,CR-22 销账后语义) | ✅ | +| C-pending_approvals 单层 | 不进 per_conv,conversation_id 保留业务语义 | `audit.rs:324-338` `session.pending_approvals.insert(tool_call_id, PendingApproval{..conversation_id: rec.conversation_id..})` — conversation_id 作业务字段保留非路由键(mod.rs:293-295 注释印证);recovered:true / diff:None(AE-2025-03) | ✅ | +| D-DB 字段真实 | AiToolExecutionRecord.conversation_id 存在 | `df-storage/models.rs:206` `pub conversation_id: Option` · `conversation_repo.rs:242 list_pending()` 返 `Vec` · `:95 from_row` `conversation_id: row.get("conversation_id")?` 真实读 DB 列 | ✅ | +| E-L0 遍历全 per_conv | 非 active 单值,遍历 HashMap 全 dirty conv | `lib.rs:47-52` `let mut dirty_convs: Vec = session.per_conv.iter().filter(|(_,c)| c.generating).map(|(id,_)| id.clone()).collect()` — 遍历全 per_conv(HMR/dev 热载多 conv 并发跑场景全覆盖) | ✅ | +| F-逐 conv 复位 + 顶层双写 | 每 dirty conv generating=false + 顶层同步 | `lib.rs:55-59` `for cid in &dirty_convs { if let Some(conv)=session.per_conv.get_mut(cid) { conv.generating=false; } }` · `:61` `session.generating=false`(批2 双写桥接复位) | ✅ | +| G-active 兜底 | 双写期顶层=true 但 per_conv 空边界 | `lib.rs:66-70` `if let Some(active)=session.active_conversation_id.clone() { if !active.is_empty() && !dirty_convs.contains(&active) { dirty_convs.push(active); } }` — 保证 active conv 至少收一个 AiCompleted(active_conversation_id 字段 mod.rs:288 Option 真实存在);`:67 !dirty_convs.contains(&active)` 守卫防重复 emit | ✅ | +| H-AiCompleted 补偿路由 | 各 dirty conv 各发一个,按 conversation_id 路由 | `lib.rs:72-87` `if was_generating { for cid in &dirty_convs { let _ = app_h.emit("ai-chat-event", AiChatEvent::AiCompleted{..conversation_id:Some(cid.clone())}); } }` — AiCompleted 变体含 conversation_id 字段(mod.rs:118-125),各 cid 各 emit;`:53 was_generating = !dirty_convs.is_empty() \|\| session.generating` 守卫无残留不空发 | ✅ | +| I-2 单测有效性 | 分配不变量 + 全 None 边界 | `audit.rs:864-940 test_restore_multi_conv_distribution_invariant`:4 pending 行(2 conv-a + 1 conv-b + 1 None)复现分配逻辑 → 断言 per_conv.len()=2 / conv_read 可达 / 同 conv 复用同指针(幂等 `as *const _` 相等) / pending_approvals.len()=4(含无主) · `audit.rs:943-958 test_restore_only_ownerless_no_per_conv`:全 None → 断言 per_conv.is_empty() + convs_restored.is_empty()。两测逐字复现实现分配逻辑,契约对齐 conv()/conv_read() | ✅ | +| J-不改 IPC/前端/顶层字段 | 纯后端,无 IPC 签名变更 | `git show 6ad4ec2 --stat` 仅 audit.rs(+131)/lib.rs(+75/-29 改 setup listener);无 commands.rs IPC handler 变更 / 无前端 ts 改动 / 无 mod.rs PerConvState 字段增删(复用批1 struct) | ✅ | + +**对抗核验印证**: +- **convs_restored 去重 vs conv() 幂等双重保险?** ✅ 双重保险但非冗余:convs_restored.insert 首次返 true 触发 conv() 一次;conv() 内 entry().or_insert_with 本身幂等(已存在不重建)。即便去重失效(理论),conv() 仍幂等。双重防御,正确性无依赖单一机制。 +- **L0 遍历是否真覆盖多 conv?** ✅ `session.per_conv.iter()` 遍历 HashMap 全部条目,filter generating 收集所有 dirty conv,非单 active 读。注释 :43-46 显式声明「批2 前仅读 active per_conv.generating 单值;批8 改遍历全部 per_conv 各归各复位」。 +- **批5 llm_concurrency 中间态冲突?** ✅ batch5 改 LlmConcurrency(state.rs per_conv Semaphore HashMap),与 batch8 改 AiSession.per_conv(状态) + lib.rs L0 listener 完全独立。两 HashMap 独立无耦合(CR-260619-05 E 项已印证)。本次不跑 cargo 正是避 batch5 调整中间态致误报。 +- **restore 在 session.lock 内调 conv() 借用安全?** ✅ `audit.rs:302 let mut session = state.ai_session.lock().await` 取独占锁,`:321 session.conv(cid)` 取 &mut PerConvState 在锁内单次借用,`:324 session.pending_approvals.insert` 另一字段借用——两次顺序借用(非同时),Rust 借用检查通过(主代 cargo check --workspace EXIT 0 印证,非本审查跑)。 +- **filter `!cid.is_empty()` 必要性?** ✅ 防空串 conv_id 惰性建无意义 per_conv(空串非合法 conv_id);conv() 本身不校验空串,restore 侧显式守卫合理防御。 + +**🔴→🟡 时序对抗降级(L0 clear 致冷启动审批丢失)— 非批8 引入,跨批遗留**: + +冷启动时序核验确认「L0 clear 抹掉 restore 重建的 pending」**潜在路径存在**,但**归属判定修正**: + +| 环节 | 核验 | 佐证 | +|---|---|---| +| 1. restore 填充 | ✅ 真实 | `state.rs:340 restore_pending_approvals(&state).await` 在 init 内 · `lib.rs:25 block_on(AppState::init)` 同步执行 · `audit.rs:291` 从 DB list_pending 重建到 `session.pending_approvals` 内存 | +| 2. L0 clear 触发 | ✅ 真实 | `useAiEvents.ts:386 emit('ai-client-ready')` 在 startListener 内(AiChat onMounted 必挂载触发)→ `lib.rs:38 listener` → `lib.rs:62 session.pending_approvals.clear()` 无条件清空 | +| 3. 前端不显示 | ✅ 真实 | `ai_pending_tool_calls`(commands.rs:507-517)数据源 = `session.pending_approvals.values()` **内存**(非 DB),clear 后返空 | +| 4. 审批报错 | ⚠️ **部分不成立** | `ai_approve`(commands.rs:328 内存 remove None)→ `:334 find_by_tool_call_id` 查 DB status=pending → `:336` 白名单「executed/rejected/failed」不含 pending → `:346 return Err「未找到挂起的审批」`。**但**前端步骤 3 已拉不到 pending,用户**根本看不到审批卡**不会触发 ai_approve,步骤 4 是理论路径非实际触发 | + +**🔴→🟡 归属修正(推翻「batch8 引入回归」判定)**: + +`git show 5c15b72:src-tauri/src/lib.rs`(批2,即 batch8 前一版)L0 listener **已有无条件 clear pending**: +- `if was_generating { ...; session.pending_approvals.clear(); ... }`(was_generating=true 分支) +- `else { session.pending_approvals.clear(); }`(was_generating=false 分支) +- **两分支都 clear = 无条件 clear**(行为等价 batch8 `:62` 外层单处 clear) + +`git show 6ad4ec2 -- src-tauri/src/lib.rs` diff 印证:batch8 仅**重构 clear 位置**(批2 两分支各一处 → batch8 外层一处合并),`session.pending_approvals.clear()` 出现次数 2→1(**净减**,非新增)。**batch8 未引入 clear,更未引入回归**——潜在冷启动审批丢失问题**批2 起就存在**,属跨批遗留。 + +`git log -S "session.pending_approvals.clear()" -- src-tauri/src/lib.rs` 印证:该字符串首次引入在 `d2cb38c`(任务推进链,B-260616-01 L0 握手首引),batch8 仅改变出现次数(合并)。**clear 非 batch8 新增铁证**。 + +**设计契约矛盾确认(跨批遗留,非 batch8)**:`commands.rs:1672-1675` switchConversation `retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id))` 注释明说「防 init 重建的内存 HashMap 被清空,重启恢复链路:restore → switchConversation(此处不清目标)→ ai_pending_tool_calls → ai_approve」——该恢复链前提是 restore 重建的 pending 在 switchConversation 时仍在内存,但 L0 clear(批2 起)在冷启动 AiChat onMounted 时就触发(早于用户手动 switchConversation)抹掉 restore 成果。**矛盾真实存在,但归属批2+,非 batch8**。 + +**为何另一位审查 agent 误判 batch8 引入**:`git show 6ad4ec2 -- lib.rs` 只看 batch8 diff 内的 `+ session.pending_approvals.clear()`(那是合并后上提的外层一处),**未对比批2 版本(5c15b72)已有两处 clear**,误判为新增。印证 memory [[code-review-anti-contamination]]:对抗核验须跨版本 git diff,不能只看单 commit diff 的 +/- 行。 + +**⚪ WATCH-1(AiCompleted 补偿前端「各归各复位」描述精度,low)**:lib.rs:74 注释 + prompt 声称「前端 useAiEvents.ts:133-140 按 conversation_id 路由各归各复位」,实际 useAiEvents.ts:133-140 `isCurrent=false`(非当前展示 conv)收到 AiCompleted 时清的是**全局单值 `state.generatingConvId=null`**(:136),非按 conv 各清。因 devflow 前端 generatingConvId 是单值(同时刻只展示一对话),多 conv 后台跑 loop 切走后完成时清全局 null 无副作用。**补偿事件设计意图(防 HMR 残留 generating 卡死)达成**,描述略宽泛。建议注释改为「前端按 conversation_id 过滤,非当前 conv 收到 AiCompleted 清全局 generatingConvId(单值)」。非阻塞。 + +**🟡 WATCH-2(L0 clear 致冷启动审批丢失·跨批遗留·非 batch8)**:L0 `lib.rs:62 session.pending_approvals.clear()` 无条件清空,抹掉 `state.rs:340 restore_pending_approvals` 重建的 pending → 冷启动后 ai_pending_tool_calls(内存数据源)返空 → 待审批 toolCard 不显示。**归属批2+(5c15b72 起两分支无条件 clear),非 batch8 引入**。batch8 仅合并 clear 位置(行为等价)。修复方向(回填 todo,供主代/用户评估,**不属 batch8 审查范围必修**): + - **方案 A(推荐)**:`lib.rs:62 clear()` → `retain(|_, a| !a.recovered)`。仅清非 recovered(本次会话产生的死 pending / HMR 残留),保留 restore 重建(`PendingApproval.recovered=true`,audit.rs:331)。对齐 switchConversation 保护意图,HMR 清死 pending 与冷启动保 restore 两全。 + - **方案 B**:删 `lib.rs:62`。pending 清理由 switchConversation retain / delete_conversation retain / ai_chat_clear 各路径精确管理。 + - **判定**:此 🟡 是**跨批遗留问题曝光**(批2 起就存在),batch8 未恶化(行为等价)。是否修属产品决策(冷启动审批恢复是否是承诺功能),非 batch8 审查必修项。 + +**⚠️ batch5 中间态说明(审查范围外记录)**: +- 本次审查**全程未跑 cargo**(memory [[review-batching-worktree-transient]]:F-09 batch5 agent 正在改 agentic.rs/state.rs,跑 cargo check 抓中间态致误报)。源码形态核验 + git diff 跨版本时序对抗 > check 快照。主代兜底 cargo check --workspace EXIT 0 + test 98 passed 为稳定态报告(本审查采信但不复跑)。 + +- **待修项回流 todo**: **无** 🔴 项(batch8 本身 PASS 无回归) · 🟡 **1 项跨批遗留**(BUG-260619-06-cross-batch L0 clear 致冷启动审批丢失·**归属批2+ 非 batch8**·方案 A `retain(!recovered)` 推荐·供主代/用户评估是否修,非 batch8 审查必修) + +### CR-260619-07 F-09 batch4 启用多会话上线(决策e真并发·前后端·agent f09-batch4·commit d899c58) — 🟡 待审 + +- **范围**(前后端大改,F-09 上线):IPC 签名加 conv_id(ai_is_generating/ai_chat_send/ai_chat_force_send/ai_chat_stop + 内部移除 active 一致性校验)+ 删 switch readonly(决策 e)+ 删 ai_conversation_create 强制结束旧 loop + 删双写桥接/顶层字段(messages/generating/stop_flag/notify/iteration_used/agent_language/model_override/session_trust 全删,per_conv 唯一真相源)+ 删双写代码(全模块)+ pending_approvals retain 目标 conv + finalize_pending_placeholders 按 conv_id + 前端(api/ai.ts+useAiSend/useAiWindow)传 conv_id。保留 session_state(SW 预留标 allow)+ readonly(前端不读)。 +- **审查要点**:① IPC 签名 conv_id 前端传参(api/ai.ts/useAiSend/useAiWindow);② 删 readonly 后 switch 生成中可切(决策 e)+ 后台 conv 跳过 reload;③ 顶层字段全删(per_conv 唽一)+ 双写清理完整(grep 顶层引用 0);④ conv_id 上下文一致(前端 activeConversationId);⑤ pending_approvals retain 目标 conv(不误杀他 conv);⑥ switch reload 边界;⑦ 保留项(session_state/readonly)合理(零调用方预留按用户指导保留)。 +- **验证(主代兜底)**:cargo check --workspace 0 + vue-tsc 0 + test 98 + grep 顶层删除/per_conv 唯一印证。 +- **⚠️ 双会话回归留用户验收**:开 A 跑→切 B 发→A 后台不退出不污染 B;审批/max 续跑跨会话。后端 per_conv 隔离 + 事件 conversation_id 路由保障。 +- **关联**:F-09 完整(batch1-8+batch4 上线)。 + --- ## 已审归档 diff --git a/src-tauri/src/commands/ai/commands.rs b/src-tauri/src/commands/ai/commands/chat.rs similarity index 63% rename from src-tauri/src/commands/ai/commands.rs rename to src-tauri/src/commands/ai/commands/chat.rs index e45ed03..2649554 100644 --- a/src-tauri/src/commands/ai/commands.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -1,4 +1,14 @@ -//! 所有 `#[tauri::command]` IPC 函数 — 由 mod.rs 重导出供 invoke_handler 引用 +//! chat 域 IPC — 发送 / 重新生成 / 编辑 / 强制发送 / 停止 / 审批 / 上下文分段与压缩 / 循环控制 +//! +//! 由原 `commands.rs`(单文件 1829 行 God 文件)按域拆分,本文件聚焦 chat 域 13 个 IPC + +//! chat 专用 helper(`finalize_pending_placeholders`)与 `PendingToolCallInfo`。 +//! 其他域(provider/conversation/skills/config)仍留在 `commands/mod.rs`,后续批拆分。 +//! +//! re-export:由 `commands/mod.rs` 经 `pub use self::chat::*;` 拉到 `commands::*`, +//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`, +//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。 +//! +//! F-09 batch4 conv_id 签名(决策 e 真并发)原样保留,不改 IPC 签名/行为(纯搬迁)。 use std::sync::atomic::Ordering; @@ -6,22 +16,20 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use df_ai::provider::{ChatMessage, ContentPart}; -// df-ai 重导出 df_ai_core(供下游直接引用 trait/类型);src-tauri 不直接依赖 df-ai-core crate。 -use df_ai::df_ai_core::model::ModelConfig; use df_types::types::new_id; -use df_storage::models::AiProviderRecord; use crate::state::AppState; use crate::commands::{err_str, now_millis}; -use super::agentic::{run_agentic_loop, try_continue_agent_loop}; -use super::audit::{audit_finalize, emit_data_changed}; -use super::conversation::save_conversation; -use super::knowledge_inject::build_knowledge_context; -use super::prompt::build_system_prompt; -use super::skills::{read_skill_content, SkillInfo, skills_cached}; +// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 +use super::super::agentic::{run_agentic_loop, try_continue_agent_loop}; +use super::super::audit::{audit_finalize, emit_data_changed}; +use super::super::conversation::save_conversation; +use super::super::knowledge_inject::build_knowledge_context; +use super::super::prompt::build_system_prompt; +use super::super::skills::read_skill_content; -use super::AiChatEvent; +use super::super::AiChatEvent; /// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER /// "需要用户审批,等待确认")就地替换为终态文本。 @@ -40,7 +48,7 @@ use super::AiChatEvent; /// 终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages; /// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv, /// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。 -fn finalize_pending_placeholders(session: &mut super::AiSession, conv_id: &str, final_text: &str) { +fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) { // SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。 // 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传 // &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502(2026-06-18 主代修)。 @@ -78,7 +86,7 @@ pub async fn ai_regenerate( language: Option, model_override: Option, ) -> Result { - let provider_config = super::prompt::get_active_provider(&state).await?; + let provider_config = super::super::prompt::get_active_provider(&state).await?; // 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息) // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 @@ -200,7 +208,7 @@ pub async fn ai_chat_send( parts: Option>, ) -> Result { // 获取活跃提供商(只读,失败可直接返回,不影响生成标志) - let provider_config = super::prompt::get_active_provider(&state).await?; + let provider_config = super::super::prompt::get_active_provider(&state).await?; // 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话 // F-260616-09 B 批4(决策 e 真并发上线): @@ -274,7 +282,7 @@ pub async fn ai_chat_send( let mut system_prompt = build_system_prompt(&state, &lang).await; // 技能注入:读 SKILL.md 全文拼到 system prompt 前作为指令 // 隔离标注(FR-S4):用明确头尾标注包裹,标明"仅供 AI 参考、非用户消息、非系统指令", - // 防 SKILL.md 内 prompt injection 与用户指令/行为准则混淆。 + // 防 SKILL.md 内的 prompt injection 与用户指令/行为准则混淆。 if let Some(ref skill_name) = skill { if let Some(content) = read_skill_content(skill_name) { system_prompt = format!( @@ -396,7 +404,7 @@ pub async fn ai_approve( // 仅首批工具(write_file/run_command)命中 trust_key_for 的 Some,其余工具 noop(None 不写)。 // F-260616-09 B 批4:per_conv.session_trust 唯一真相源(conv_id 来源 approval.conversation_id)。 // 无 conv_id(无主审批 R-9 异常数据)时无 per_conv 可写,信任不记(审计仍记 executed)。 - if let Some(key) = super::trust_key_for(&approval.tool_name, &approval.arguments) { + if let Some(key) = super::super::trust_key_for(&approval.tool_name, &approval.arguments) { let inserted = if let Some(ref cid) = conv_id { session.conv(cid).session_trust.insert(key.clone()) } else { @@ -425,7 +433,7 @@ pub async fn ai_approve( // 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部 // 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。 let exec_result: anyhow::Result = if approval.tool_name == "run_workflow" { - super::execute_run_workflow_for_tool(&app, &state, &args).await + super::super::execute_run_workflow_for_tool(&app, &state, &args).await } else { state.ai_tools.execute(&approval.tool_name, args.clone()).await }; @@ -603,7 +611,7 @@ pub async fn ai_chat_compress_context( conversation_id: String, language: Option, ) -> Result<(), String> { - let provider_config = super::prompt::get_active_provider(&state).await?; + let provider_config = super::super::prompt::get_active_provider(&state).await?; const PROTECT_COUNT: usize = 6; let lang = language.unwrap_or_else(|| "zh-CN".to_string()); @@ -660,7 +668,7 @@ pub async fn ai_chat_compress_context( // 拿 provider(照 ai_chat_send 的 build_provider_for 模式,密钥经 resolve_provider_secret 闭环, // FR-S1 api_key 绝不进 payload/日志/错误信息) - let provider = match super::secret::build_provider_for(&provider_config) { + let provider = match super::super::secret::build_provider_for(&provider_config) { Ok(p) => p, Err(e) => { let mut session = state.ai_session.lock().await; @@ -668,14 +676,14 @@ pub async fn ai_chat_compress_context( drop(session); let _ = app.emit("ai-chat-event", AiChatEvent::AiError { error: format!("压缩失败: {}", e), - error_type: Some(super::ErrorType::Auth), + error_type: Some(super::super::ErrorType::Auth), conversation_id: Some(conv_id), }); return Err(format!("压缩失败: {}", e)); } }; let llm_concurrency = state.llm_concurrency.clone(); - let summary = match super::compress::compress_via_llm( + let summary = match super::super::compress::compress_via_llm( provider.as_ref(), &provider_config, active_msgs, @@ -691,7 +699,7 @@ pub async fn ai_chat_compress_context( drop(session); let _ = app.emit("ai-chat-event", AiChatEvent::AiError { error: format!("压缩失败: {}", e), - error_type: Some(super::ErrorType::Unknown), + error_type: Some(super::super::ErrorType::Unknown), conversation_id: Some(conv_id), }); return Err(format!("压缩失败: {}", e)); @@ -731,7 +739,7 @@ pub async fn ai_chat_edit( language: Option, model_override: Option, ) -> Result { - let provider_config = super::prompt::get_active_provider(&state).await?; + let provider_config = super::super::prompt::get_active_provider(&state).await?; // 原子占用 generating + 替换末条 user content + truncate 其后 // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 @@ -850,10 +858,10 @@ pub async fn ai_chat_force_send( conversation_id: Option, ) -> Result { // 获取活跃提供商(只读,失败可直接返回,不占用 generating) - let provider_config = super::prompt::get_active_provider(&state).await?; + let provider_config = super::super::prompt::get_active_provider(&state).await?; // 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。 - // stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察到中间态; + // stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态; // 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。 // // F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板), @@ -1137,693 +1145,3 @@ pub async fn ai_stop_loop( }); Ok("ok".to_string()) } - -// ============================================================ -// 提供商管理 -// ============================================================ - -/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识 -fn mask_api_key(key: &str) -> String { - let chars: Vec = key.chars().collect(); - if chars.len() <= 8 { - return "•".repeat(chars.len()); - } - let prefix: String = chars[..4].iter().collect(); - let suffix: String = chars[chars.len() - 4..].iter().collect(); - format!("{}••••{}", prefix, suffix) -} - -/// 列出所有已配置的 AI 提供商(is_default 真相源为 DB,重启不丢) -#[tauri::command] -pub async fn ai_list_providers(state: State<'_, AppState>) -> Result, String> { - let mut providers = state.ai_providers.list_all().await.map_err(err_str)?; - // IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。 - // 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文) - for p in &mut providers { - let real = if !p.api_key.is_empty() { - p.api_key.clone() // 未迁移(老明文) - } else { - super::secret::get_provider_secret(&p.id).unwrap_or_default() // 迁移后从 keyring - }; - p.api_key = if real.is_empty() { String::new() } else { mask_api_key(&real) }; - } - Ok(providers) -} - -/// 保存/更新 AI 提供商配置 -#[tauri::command] -pub async fn ai_save_provider( - state: State<'_, AppState>, - id: Option, - name: String, - base_url: String, - api_key: String, - default_model: String, - provider_type: String, - model_configs: Vec, -) -> Result { - // F-260618-06:接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label), - // 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。 - // 编辑已有提供商时保留原 created_at,避免被覆盖 - // F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。 - // 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。 - let existing = match &id { - Some(pid) => state.ai_providers.get_by_id(pid).await.map_err(err_str)?, - None => None, - }; - let created_at = existing.as_ref().map(|p| p.created_at.clone()).unwrap_or_else(now_millis); - // is_default:编辑保留原值;新建时若全表尚无默认则设为默认(首个自动默认,避免无默认可用) - let is_default = match &existing { - Some(p) => p.is_default, - None => !state.ai_providers.list_all().await - .map_err(err_str)? - .iter().any(|p| p.is_default), - }; - // F-260614-04c: enabled/weight 编辑保留原值(前端 Settings 改值经此落库); - // 新建默认进池(enabled=true,weight=50)。 - let (enabled, weight) = match &existing { - Some(p) => (p.enabled, p.weight), - None => (true, 50), - }; - // FR-S1:密钥存 OS keyring,DB api_key 列恒空(不入明文)。 - // api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。 - let provider_id = id.clone().unwrap_or_else(new_id); - if !api_key.is_empty() { - // 显式改/填密钥 → 写 keyring(现状不变) - if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) { - return Err(format!("密钥保存到系统钥匙串失败: {}", e)); - } - } else if let Some(pid) = &id { - // 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1)。 - // 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key, - // 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。 - // 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态); - // 迁移失败则 Err 阻断保存且 INSERT OR REPLACE 不执行 → DB 明文保留,绝不劣化现状。 - let old = state.ai_providers.get_by_id(pid).await - .map_err(err_str)?; - if let Some(old) = old { - if !old.api_key.is_empty() - && super::secret::get_provider_secret(pid).is_none() - { - // DB 有明文 且 keyring 无 → 即时迁移补密钥 - if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) { - return Err(format!( - "检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\ - 已保留原密钥未改动——请检查系统钥匙串权限后再次保存。", - e - )); - } - tracing::info!( - "[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring(R-PD-1 兜底)", - pid - ); - } - // else: keyring 已有 / DB 已空 → 下方 INSERT OR REPLACE 清 DB 明文安全 - } - } - let api_key = String::new(); // DB 恒空(真实密钥在 keyring) - let record = AiProviderRecord { - id: provider_id, - name, - provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type }, - api_key, - base_url, - default_model, - models: None, - model_configs, - is_default, - config: None, - created_at, - updated_at: now_millis(), - // F-260614-04c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。 - enabled, - weight, - }; - let id = record.id.clone(); - state - .ai_providers - .insert(record) - .await - .map_err(err_str)?; - Ok(id) -} - -/// F-260614-04c: 轻量更新 provider 池配置(enabled/weight),不改其它字段、不触密钥迁移。 -/// -/// 与 `ai_save_provider` 的区别: -/// - ai_save_provider 是全量保存(name/base_url/api_key/model...),编辑路径会走 R-PD-1 密钥 -/// 迁移 + INSERT OR REPLACE 全字段;前端 Settings「负载均衡池」开关/权重滑块仅需改这俩字段, -/// 不应重发整张表(尤其避免空 api_key 触发密钥迁移分支)。 -/// - 本 IPC 仅 UPDATE enabled/weight(经 update_field 或 update_full),重建 caps 表。 -/// -/// 落库后立即重建 per_provider caps(set_provider_caps),保证开关/权重变更对 agentic loop -/// 即时生效(下条消息即按新配置 acquire)。caps 重建非强一致(软收敛:已持 permit 不回收)。 -#[tauri::command] -pub async fn ai_update_provider_pool( - state: State<'_, AppState>, - provider_id: String, - enabled: bool, - weight: u32, -) -> Result<(), String> { - // 验证 provider 存在(防前端传错 id 静默无操作) - let mut record = state - .ai_providers - .get_by_id(&provider_id) - .await - .map_err(err_str)? - .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; - // weight 落库前 clamp 到 [0,100](对齐 crud update 的 weight.min(100),防越界)。 - let weight = weight.min(100); - if record.enabled == enabled && record.weight == weight { - // 无变化:跳过 DB 写 + caps 重建(幂等,防前端重复点击触发不必要的 IO)。 - return Ok(()); - } - record.enabled = enabled; - record.weight = weight; - record.updated_at = now_millis(); - // update_full 走 UPDATE 全字段(含 enabled/weight,波12已加);api_key 不变(DB 恒空)。 - state - .ai_providers - .update_full(&record) - .await - .map_err(err_str)?; - // 重建 per_provider caps:本 provider 被禁用/weight=0 → 不入新表 → acquire_for_provider - // 对其返 None(无限流,但 provider_pool::select 已把它移出候选,实际不会被 acquire)。 - // caps 重建逻辑收敛到 AppState::reload_provider_caps(单点真理,启动 + 变更共用)。 - state.reload_provider_caps().await; - Ok(()) -} - -#[tauri::command] -pub async fn ai_set_provider( - state: State<'_, AppState>, - provider_id: String, -) -> Result<(), String> { - // 验证提供商存在 - let provider = state - .ai_providers - .get_by_id(&provider_id) - .await - .map_err(err_str)? - .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; - - // 互斥写 DB:目标 is_default=true,其余=false。仅写变化的记录。 - let providers = state.ai_providers.list_all().await.map_err(err_str)?; - for p in &providers { - let should = p.id == provider_id; - if p.is_default != should { - let mut updated = p.clone(); - updated.is_default = should; - updated.updated_at = now_millis(); - state.ai_providers.update_full(&updated).await.map_err(err_str)?; - } - } - - let mut session = state.ai_session.lock().await; - session.active_provider_id = Some(provider.id); - Ok(()) -} - -/// 删除 AI 提供商 -#[tauri::command] -pub async fn ai_delete_provider( - state: State<'_, AppState>, - provider_id: String, -) -> Result<(), String> { - state.ai_providers.delete(&provider_id).await.map_err(err_str)?; - // CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方, - // 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写) - if let Err(e) = super::secret::delete_provider_secret(&provider_id) { - tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e); - } - // 删除的若是当前默认,清空 active 指向,避免悬空 - let mut session = state.ai_session.lock().await; - if session.active_provider_id.as_deref() == Some(&provider_id) { - session.active_provider_id = None; - } - Ok(()) -} - -// ============================================================ -// 模型列表拉取 + 单模型探测(F-01 阶段5 IPC) -// ============================================================ - -/// 将前端 provider_type 规范化为 fetch_and_probe 接受的类型。 -/// -/// Settings.vue 存的 provider_type 是 "openai_compat" / "anthropic"(对齐 build_provider 工厂), -/// 而 model_fetch::fetch_and_probe 分派用 "openai_compat" / "anthropic_compat"。 -/// 两个工厂入口类型语义一致(anthropic 协议),仅命名不同,这里收敛归一。 -fn normalize_provider_type_for_fetch(provider_type: &str) -> String { - match provider_type { - "anthropic" => "anthropic_compat".to_string(), - other => other.to_string(), - } -} - -/// 测试连接并拉取厂商模型列表(F-01 阶段5) -/// -/// 流程: -/// 1. DB 取 AiProviderRecord(get_by_id) -/// 2. FR-S1:经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB, -/// 绝不进日志/返回值/错误信息) -/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调 -/// df_ai::model_fetch::fetch_and_probe → Vec(每个模型名已探测出 4 维度) -/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec -/// -/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。 -#[tauri::command] -pub async fn ai_fetch_models( - state: State<'_, AppState>, - provider_id: String, -) -> Result, String> { - let provider = state - .ai_providers - .get_by_id(&provider_id) - .await - .map_err(err_str)? - .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; - - // FR-S1:内存解析密钥,绝不外泄(不入日志/返回值/错误信息) - let api_key = df_storage::secret::resolve_provider_secret(&provider); - let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type); - - let probed = df_ai::model_fetch::fetch_and_probe(&fetch_type, &provider.base_url, &api_key) - .await - .map_err(err_str)?; - - // 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/ - // context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。 - // 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router - // 排序摇摆。对齐 provider 级 enabled/weight 编辑保留逻辑(commands.rs:1041 match existing)。 - // 新模型(旧池无同 model_id)用探测默认值。 - let merged: Vec = probed - .iter() - .map(|c| match provider.model_configs.iter().find(|o| o.model_id == c.model_id) { - Some(old) => ModelConfig { - weight: old.weight, - enabled: old.enabled, - label: old.label.clone(), - ..c.clone() - }, - None => c.clone(), - }) - .collect(); - - // 写回 model_configs(更新 updated_at) - let mut updated = provider.clone(); - updated.model_configs = merged.clone(); - updated.updated_at = now_millis(); - state - .ai_providers - .update_full(&updated) - .await - .map_err(err_str)?; - - Ok(merged) -} - -/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。 -/// -/// 用途:拉取后用户手动补一个模型名、或想重探某模型的能力维度。 -/// 直接调 df_ai::model_probe::probe(&model_id),返回填充了 probe_source 的 ModelConfig。 -#[tauri::command] -pub async fn ai_probe_model( - _state: State<'_, AppState>, - model_id: String, -) -> Result { - Ok(df_ai::model_probe::probe(&model_id)) -} - -// ============================================================ -// 对话管理 -// ============================================================ - -/// 创建新对话 -#[tauri::command] -pub async fn ai_conversation_create( - _app: AppHandle, - state: State<'_, AppState>, -) -> Result { - // 懒创建:仅生成 id 存内存,不落库;避免新建后不发消息产生空记录。 - // 首条消息发送后由 save_conversation upsert 写入。 - let id = new_id(); - let now = now_millis(); - - // F-260616-09 B 批4(决策 e 真并发上线):新建会话**不杀旧 loop**。 - // 旧实现(B-260615-10)生成中强制结束旧 conv 的 loop + clear 其 per_conv,在真并发下会误杀 - // 后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv), - // 仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。 - // - // B-260618-06 保留:切换 active 前先持久化旧 active conv(防其内存 messages 因后续操作丢失)。 - // 注意:旧 conv 若有后台 loop 在跑,loop 自身的 save_conversation 也会持久化,此处 save 幂等。 - let old_conv = { - let session = state.ai_session.lock().await; - session.active_conversation_id.clone() - }; - let old_has_msgs = { - let session = state.ai_session.lock().await; - old_conv.as_deref() - .and_then(|oc| session.conv_read(oc)) - .map(|c| !c.messages.is_empty()) - .unwrap_or(false) - }; - if let Some(ref oc) = old_conv { - if old_has_msgs { - save_conversation(&state.ai_session, &state.db, oc.as_str(), None, None).await; - } - } - - let mut session = state.ai_session.lock().await; - session.active_conversation_id = Some(id.clone()); - session.active_conv_created_at = Some(now); - // 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。 - // 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留 - // (switch 路径会 retain 清目标 conv 的,create 不动他人)。 - let _ = session.conv(&id); // 惰性建立空 PerConvState(字段全默认值) - - Ok(serde_json::json!({ "id": id })) -} - -/// 列出对话(仅摘要,不含 messages 全文) -/// -/// limit 默认 50 防数据膨胀;include_archived 默认 false(归档对话默认隐藏)。 -#[tauri::command] -pub async fn ai_conversation_list( - state: State<'_, AppState>, - limit: Option, - include_archived: Option, -) -> Result, String> { - let limit = limit.unwrap_or(50); - let include_archived = include_archived.unwrap_or(false); - let records = state.ai_conversations.list_all().await.map_err(err_str)?; - // list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit - let summaries: Vec = records.iter() - .filter(|r| include_archived || !r.archived) - .take(limit) - .map(|r| { - // 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组 - let models: Vec = r.models.as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_default(); - serde_json::json!({ - "id": r.id, - "title": r.title, - "provider_id": r.provider_id, - "model": r.model, - "models": models, - "archived": r.archived, - "pinned": r.pinned, - "prompt_tokens": r.prompt_tokens, - "completion_tokens": r.completion_tokens, - "created_at": r.created_at, - "updated_at": r.updated_at, - }) - }).collect(); - Ok(summaries) -} - -/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端) -#[tauri::command] -pub async fn ai_conversation_switch( - state: State<'_, AppState>, - app_handle: AppHandle, - conversation_id: String, -) -> Result { - let record = state.ai_conversations.get_by_id(&conversation_id).await - .map_err(err_str)? - .ok_or_else(|| format!("对话不存在: {}", conversation_id))?; - - let messages: Vec = serde_json::from_str(&record.messages) - .map_err(|e| format!("解析消息失败: {}", e))?; - - let messages_json = record.messages.clone(); - let title = record.title.clone(); - // B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求) - let need_title_regen = record.title.is_none(); - - let mut session = state.ai_session.lock().await; - // F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。 - // 旧实现:active conv 生成中 → 只读切换(不改 session 状态),因单例 messages 会被新 conv 覆盖。 - // 决策 e:per_conv 已隔离,切走直接改 active_conversation_id + 新 conv per_conv 惰性建/从 DB reload。 - // **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages - // (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。 - session.active_conversation_id = Some(conversation_id.clone()); - let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); - if !already_live { - // 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。 - // 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。 - let conv = session.conv(&conversation_id); - conv.messages.restore_from_messages(messages); - conv.model_override = None; - conv.session_trust.clear(); - conv.agent_language = None; - conv.iteration_used = 0; - conv.stop_flag.store(false, Ordering::SeqCst); - } - // 仅清空目标对话自身的 pending_approvals,保留其他对话的(防 init 重建的内存 HashMap 被清空, - // 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的) - // → ai_pending_tool_calls 查询 → ai_approve 落库) - session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); - // 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB) - drop(session); - - // 历史会话切入且 title 空 → 后台触发标题重新生成(extract 即时兜底 + LLM 优化)。 - // ensure 内 get_by_id title Some 判断防重复;无可用 provider 静默跳过(增强不阻塞切换)。 - if need_title_regen { - match super::prompt::get_active_provider(&state).await { - Ok(provider_config) => { - super::title::spawn_ensure_title( - &provider_config, - &state.db, - &conversation_id, - &app_handle, - &state.ai_session, - &state.llm_concurrency, - ); - } - Err(e) => tracing::warn!( - "切入历史会话触发标题重生成跳过(无可用 provider, conv_id={}): {}", - conversation_id, e - ), - } - } - - Ok(serde_json::json!({ - "id": record.id, - "title": title, - "messages": messages_json, - })) -} - -/// 删除对话 -#[tauri::command] -pub async fn ai_conversation_delete( - state: State<'_, AppState>, - conversation_id: String, -) -> Result<(), String> { - state.ai_conversations.delete(&conversation_id).await.map_err(err_str)?; - - let mut session = state.ai_session.lock().await; - // 删除任意对话(含非活跃)都应清理其积压审批:pending_approvals 是单例 HashMap, - // 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理, - // 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。 - session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); - // F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此, - // 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。 - session.per_conv.remove(&conversation_id); - if session.active_conversation_id.as_deref() == Some(&conversation_id) { - session.active_conversation_id = None; - } - // F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。 - // conv 已删=LlmConcurrency 该条目不再被 acquire(无 conv 则无 loop/标题/提炼/压缩针对它)。 - // 已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),仅阻止新条目累积。 - // 时机:conv 删除即清理(比"loop 结束 + 无 pending"更确定——conv 删了必无 pending, - // 上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。 - drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题) - state.llm_concurrency.release_conv(&conversation_id).await; - Ok(()) -} - -/// 重命名对话标题 -#[tauri::command] -pub async fn ai_conversation_rename( - state: State<'_, AppState>, - conversation_id: String, - title: String, -) -> Result<(), String> { - let title = title.trim().to_string(); - if title.is_empty() { - return Err("标题不能为空".to_string()); - } - state.ai_conversations.update_field(&conversation_id, "title", &title) - .await.map_err(err_str)?; - Ok(()) -} - -/// 归档/取消归档对话(归档后在侧栏折叠分组展示) -#[tauri::command] -pub async fn ai_conversation_archive( - state: State<'_, AppState>, - conversation_id: String, - archived: bool, -) -> Result<(), String> { - state.ai_conversations - .set_archived(&conversation_id, archived) - .await - .map_err(err_str)?; - Ok(()) -} - -/// 置顶/取消置顶对话(UX-17:对话置顶) -/// -/// 置顶后侧栏排序置前(前端按 pinned DESC, updated_at DESC)。 -/// 纯元数据标记(同归档),不改 updated_at(保持相对时间不变)。 -#[tauri::command] -pub async fn ai_conversation_set_pinned( - state: State<'_, AppState>, - conversation_id: String, - pinned: bool, -) -> Result<(), String> { - state.ai_conversations - .set_pinned(&conversation_id, pinned) - .await - .map_err(err_str)?; - Ok(()) -} - -/// 导出对话为指定格式(UX-18:对话导出) -/// -/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库) -/// - markdown: `## 用户` / `## 助手` 交替标题 + content 原样输出 -/// (content 内已有的三反引号代码块围栏原样保留,不做二次转义) -/// - json: 完整 messages 数组(serde 序列化 ChatMessage 列表) -/// - txt: `user: ...` / `assistant: ...` 纯文本拼接,system/tool 附注 -/// -/// 最小化:仅渲染 user/assistant 文本;tool_calls/tool_results 略过(导出给人看的对话)。 -/// 空对话(无 messages)→ 空字符串(对应格式空体)。 -#[tauri::command] -pub async fn ai_conversation_export( - state: State<'_, AppState>, - conversation_id: String, - format: String, -) -> Result { - // format 校验:非法值 Err(不 panic),防止 format! 注入或未处理分支 - let fmt = format.as_str(); - if !matches!(fmt, "markdown" | "json" | "txt") { - return Err(format!("不支持的导出格式: {}", format)); - } - - // 取落库对话(完整历史) - let record = state.ai_conversations.get_by_id(&conversation_id).await - .map_err(err_str)? - .ok_or_else(|| format!("对话不存在: {}", conversation_id))?; - - let messages: Vec = serde_json::from_str(&record.messages) - .map_err(|e| format!("解析消息失败: {}", e))?; - - let body = match fmt { - "markdown" => { - // user/assistant 各起一节标题;system/tool 跳过(导出是给人看的对话流) - let mut parts: Vec = Vec::new(); - for m in &messages { - let title = match m.role { - df_ai::provider::MessageRole::User => Some("## 用户"), - df_ai::provider::MessageRole::Assistant => Some("## 助手"), - df_ai::provider::MessageRole::System => Some("## 系统"), - df_ai::provider::MessageRole::Tool => Some("## 工具结果"), - }; - if let Some(t) = title { - // content 原样输出,内部三反引号围栏保留(Markdown 嵌套代码块,渲染器原生支持) - parts.push(format!("{}\n\n{}", t, m.content)); - } - } - parts.join("\n\n") - } - "json" => { - serde_json::to_string_pretty(&messages) - .map_err(|e| format!("序列化失败: {}", e))? - } - "txt" => { - let mut parts: Vec = Vec::new(); - for m in &messages { - let role_name = match m.role { - df_ai::provider::MessageRole::System => "system", - df_ai::provider::MessageRole::User => "user", - df_ai::provider::MessageRole::Assistant => "assistant", - df_ai::provider::MessageRole::Tool => "tool", - }; - parts.push(format!("{}: {}", role_name, m.content)); - } - parts.join("\n") - } - // 上方 matches! 已校验,理论不可达 - _ => return Err(format!("不支持的导出格式: {}", format)), - }; - - Ok(body) -} - -/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想 -#[tauri::command] -pub async fn ai_list_skills() -> Result, String> { - // 命中进程内缓存,命中后仅 clone,不重复扫盘 - Ok(skills_cached().clone()) -} - -/// 设置 LLM 调用并发上限(运行时调整,立即生效) -/// -/// 软收敛:缩并发时已持有旧 permit 的任务继续执行不受影响,待其释放后新限制完全生效。 -/// None 表示该层不变(前端可单独调一层)。值下限为 1。 -#[tauri::command] -pub async fn ai_set_concurrency_config( - state: State<'_, AppState>, - global_limit: Option, - per_conv_limit: Option, -) -> Result<(), String> { - // 下限 1,无上限;同时给 global 时约束 per-conv 不超过 global - if let Some(g) = global_limit { - state.llm_concurrency.set_global(g.max(1) as usize).await; - } - if let Some(p) = per_conv_limit { - let mut p = p.max(1); - if let Some(g) = global_limit { - p = p.min(g.max(1)); - } - state.llm_concurrency.set_per_conv(p as usize).await; - } - Ok(()) -} - -/// 设置 Agentic 循环最大轮次(运行时调整,立即生效) -/// -/// 与并发配置不同:max_iterations 是 loop 入口 load 快照的值,热改后当前 loop 不受影响 -/// (已锁定边界),下次发消息生效。范围双 clamp(command 端 1-50 + 前端 input min/max), -/// 防越界输入致 loop 过早结束(值过小)或失控(值过大)。 -#[tauri::command] -pub async fn ai_set_agent_max_iterations( - state: State<'_, AppState>, - value: u32, -) -> Result<(), String> { - // clamp 1-50:下限防 agent 失能(一轮即截断无法调任何工具), - // 上限防失控烧 token(50 轮足够覆盖复杂多步任务) - let clamped = value.clamp(1, 50) as usize; - 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/commands/mod.rs b/src-tauri/src/commands/ai/commands/mod.rs new file mode 100644 index 0000000..b04db38 --- /dev/null +++ b/src-tauri/src/commands/ai/commands/mod.rs @@ -0,0 +1,725 @@ +//! 所有 `#[tauri::command]` IPC 函数 — 由 ai/mod.rs 重导出供 invoke_handler 引用 +//! +//! 原单文件 commands.rs(1829 行 God 文件)按域拆分为 `commands/` 子目录: +//! - [`chat`] — chat 域 13 个 IPC(发送/重新生成/编辑/强制发送/停止/审批/上下文分段与压缩/循环控制) +//! + chat 专用 helper(`finalize_pending_placeholders`)+ `PendingToolCallInfo` +//! - 本文件(`mod.rs`)保留其余域 IPC:provider/conversation/skills/config + `mask_api_key` helper +//! (后续批按域继续拆分)。 +//! +//! re-export 链:`commands/mod.rs` → `pub use self::chat::*` 把 chat IPC 拉到 `commands::*` +//! → `ai/mod.rs` 的 `pub use self::commands::*` 透传到 `commands::ai::*` +//! → `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。 + +// chat 域子模块 + glob 重导出(拉到 commands::* 经 ai/mod.rs 透传到 commands::ai::*) +pub mod chat; +#[allow(unused_imports)] +pub use self::chat::*; + +use std::sync::atomic::Ordering; + +use tauri::{AppHandle, State}; + +use df_ai::provider::ChatMessage; +// df-ai 重导出 df_ai_core(供下游直接引用 trait/类型);src-tauri 不直接依赖 df-ai-core crate。 +use df_ai::df_ai_core::model::ModelConfig; +use df_types::types::new_id; +use df_storage::models::AiProviderRecord; + +use crate::state::AppState; +use crate::commands::{err_str, now_millis}; + +// chat 域专用符号(run_agentic_loop/audit_finalize/build_system_prompt/AiChatEvent/ContentPart/ +// read_skill_content/skills_cached 等)已随 chat.rs 搬走,本文件剩余 provider/conversation/ +// skills/config 域不再引用,故不 import。save_conversation 在 conversation 域仍用,保留。 +use super::conversation::save_conversation; +use super::skills::{skills_cached, SkillInfo}; + +// ============================================================ +// 提供商管理 +// ============================================================ + +/// api_key 脱敏:IPC 不传明文给前端(FR-S1),保留首尾各 4 字符便于辨识 +fn mask_api_key(key: &str) -> String { + let chars: Vec = key.chars().collect(); + if chars.len() <= 8 { + return "•".repeat(chars.len()); + } + let prefix: String = chars[..4].iter().collect(); + let suffix: String = chars[chars.len() - 4..].iter().collect(); + format!("{}••••{}", prefix, suffix) +} + +/// 列出所有已配置的 AI 提供商(is_default 真相源为 DB,重启不丢) +#[tauri::command] +pub async fn ai_list_providers(state: State<'_, AppState>) -> Result, String> { + let mut providers = state.ai_providers.list_all().await.map_err(err_str)?; + // IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。 + // 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文) + for p in &mut providers { + let real = if !p.api_key.is_empty() { + p.api_key.clone() // 未迁移(老明文) + } else { + super::secret::get_provider_secret(&p.id).unwrap_or_default() // 迁移后从 keyring + }; + p.api_key = if real.is_empty() { String::new() } else { mask_api_key(&real) }; + } + Ok(providers) +} + +/// 保存/更新 AI 提供商配置 +#[tauri::command] +pub async fn ai_save_provider( + state: State<'_, AppState>, + id: Option, + name: String, + base_url: String, + api_key: String, + default_model: String, + provider_type: String, + model_configs: Vec, +) -> Result { + // F-260618-06:接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label), + // 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。 + // 编辑已有提供商时保留原 created_at,避免被覆盖 + // F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。 + // 前端经此 IPC 改 enabled/weight 落库;新建走默认 enabled=true/weight=50。 + let existing = match &id { + Some(pid) => state.ai_providers.get_by_id(pid).await.map_err(err_str)?, + None => None, + }; + let created_at = existing.as_ref().map(|p| p.created_at.clone()).unwrap_or_else(now_millis); + // is_default:编辑保留原值;新建时若全表尚无默认则设为默认(首个自动默认,避免无默认可用) + let is_default = match &existing { + Some(p) => p.is_default, + None => !state.ai_providers.list_all().await + .map_err(err_str)? + .iter().any(|p| p.is_default), + }; + // F-260614-04c: enabled/weight 编辑保留原值(前端 Settings 改值经此落库); + // 新建默认进池(enabled=true,weight=50)。 + let (enabled, weight) = match &existing { + Some(p) => (p.enabled, p.weight), + None => (true, 50), + }; + // FR-S1:密钥存 OS keyring,DB api_key 列恒空(不入明文)。 + // api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。 + let provider_id = id.clone().unwrap_or_else(new_id); + if !api_key.is_empty() { + // 显式改/填密钥 → 写 keyring(现状不变) + if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) { + return Err(format!("密钥保存到系统钥匙串失败: {}", e)); + } + } else if let Some(pid) = &id { + // 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1)。 + // 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key, + // 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。 + // 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态); + // 迁移失败则 Err 阻断保存且 INSERT OR REPLACE 不执行 → DB 明文保留,绝不劣化现状。 + let old = state.ai_providers.get_by_id(pid).await + .map_err(err_str)?; + if let Some(old) = old { + if !old.api_key.is_empty() + && super::secret::get_provider_secret(pid).is_none() + { + // DB 有明文 且 keyring 无 → 即时迁移补密钥 + if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) { + return Err(format!( + "检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\ + 已保留原密钥未改动——请检查系统钥匙串权限后再次保存。", + e + )); + } + tracing::info!( + "[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring(R-PD-1 兜底)", + pid + ); + } + // else: keyring 已有 / DB 已空 → 下方 INSERT OR REPLACE 清 DB 明文安全 + } + } + let api_key = String::new(); // DB 恒空(真实密钥在 keyring) + let record = AiProviderRecord { + id: provider_id, + name, + provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type }, + api_key, + base_url, + default_model, + models: None, + model_configs, + is_default, + config: None, + created_at, + updated_at: now_millis(), + // F-260614-04c: enabled/weight 编辑保留原值(负载均衡池可编辑层),新建走默认。 + enabled, + weight, + }; + let id = record.id.clone(); + state + .ai_providers + .insert(record) + .await + .map_err(err_str)?; + Ok(id) +} + +/// F-260614-04c: 轻量更新 provider 池配置(enabled/weight),不改其它字段、不触密钥迁移。 +/// +/// 与 `ai_save_provider` 的区别: +/// - ai_save_provider 是全量保存(name/base_url/api_key/model...),编辑路径会走 R-PD-1 密钥 +/// 迁移 + INSERT OR REPLACE 全字段;前端 Settings「负载均衡池」开关/权重滑块仅需改这俩字段, +/// 不应重发整张表(尤其避免空 api_key 触发密钥迁移分支)。 +/// - 本 IPC 仅 UPDATE enabled/weight(经 update_field 或 update_full),重建 caps 表。 +/// +/// 落库后立即重建 per_provider caps(set_provider_caps),保证开关/权重变更对 agentic loop +/// 即时生效(下条消息即按新配置 acquire)。caps 重建非强一致(软收敛:已持 permit 不回收)。 +#[tauri::command] +pub async fn ai_update_provider_pool( + state: State<'_, AppState>, + provider_id: String, + enabled: bool, + weight: u32, +) -> Result<(), String> { + // 验证 provider 存在(防前端传错 id 静默无操作) + let mut record = state + .ai_providers + .get_by_id(&provider_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; + // weight 落库前 clamp 到 [0,100](对齐 crud update 的 weight.min(100),防越界)。 + let weight = weight.min(100); + if record.enabled == enabled && record.weight == weight { + // 无变化:跳过 DB 写 + caps 重建(幂等,防前端重复点击触发不必要的 IO)。 + return Ok(()); + } + record.enabled = enabled; + record.weight = weight; + record.updated_at = now_millis(); + // update_full 走 UPDATE 全字段(含 enabled/weight,波12已加);api_key 不变(DB 恒空)。 + state + .ai_providers + .update_full(&record) + .await + .map_err(err_str)?; + // 重建 per_provider caps:本 provider 被禁用/weight=0 → 不入新表 → acquire_for_provider + // 对其返 None(无限流,但 provider_pool::select 已把它移出候选,实际不会被 acquire)。 + // caps 重建逻辑收敛到 AppState::reload_provider_caps(单点真理,启动 + 变更共用)。 + state.reload_provider_caps().await; + Ok(()) +} + +#[tauri::command] +pub async fn ai_set_provider( + state: State<'_, AppState>, + provider_id: String, +) -> Result<(), String> { + // 验证提供商存在 + let provider = state + .ai_providers + .get_by_id(&provider_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; + + // 互斥写 DB:目标 is_default=true,其余=false。仅写变化的记录。 + let providers = state.ai_providers.list_all().await.map_err(err_str)?; + for p in &providers { + let should = p.id == provider_id; + if p.is_default != should { + let mut updated = p.clone(); + updated.is_default = should; + updated.updated_at = now_millis(); + state.ai_providers.update_full(&updated).await.map_err(err_str)?; + } + } + + let mut session = state.ai_session.lock().await; + session.active_provider_id = Some(provider.id); + Ok(()) +} + +/// 删除 AI 提供商 +#[tauri::command] +pub async fn ai_delete_provider( + state: State<'_, AppState>, + provider_id: String, +) -> Result<(), String> { + state.ai_providers.delete(&provider_id).await.map_err(err_str)?; + // CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方, + // 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写) + if let Err(e) = super::secret::delete_provider_secret(&provider_id) { + tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e); + } + // 删除的若是当前默认,清空 active 指向,避免悬空 + let mut session = state.ai_session.lock().await; + if session.active_provider_id.as_deref() == Some(&provider_id) { + session.active_provider_id = None; + } + Ok(()) +} + +// ============================================================ +// 模型列表拉取 + 单模型探测(F-01 阶段5 IPC) +// ============================================================ + +/// 将前端 provider_type 规范化为 fetch_and_probe 接受的类型。 +/// +/// Settings.vue 存的 provider_type 是 "openai_compat" / "anthropic"(对齐 build_provider 工厂), +/// 而 model_fetch::fetch_and_probe 分派用 "openai_compat" / "anthropic_compat"。 +/// 两个工厂入口类型语义一致(anthropic 协议),仅命名不同,这里收敛归一。 +fn normalize_provider_type_for_fetch(provider_type: &str) -> String { + match provider_type { + "anthropic" => "anthropic_compat".to_string(), + other => other.to_string(), + } +} + +/// 测试连接并拉取厂商模型列表(F-01 阶段5) +/// +/// 流程: +/// 1. DB 取 AiProviderRecord(get_by_id) +/// 2. FR-S1:经 resolve_provider_secret 内存解析真实 api_key(keyring 优先 fallback DB, +/// 绝不进日志/返回值/错误信息) +/// 3. provider_type 归一(anthropic→anthropic_compat)+ base_url + api_key 调 +/// df_ai::model_fetch::fetch_and_probe → Vec(每个模型名已探测出 4 维度) +/// 4. 写回 AiProviderRecord.model_configs(update_full)→ 返回 Vec +/// +/// ModelConfig 本就无 api_key 字段,返回值天然不含密钥(FR-S1 闭环)。 +#[tauri::command] +pub async fn ai_fetch_models( + state: State<'_, AppState>, + provider_id: String, +) -> Result, String> { + let provider = state + .ai_providers + .get_by_id(&provider_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("提供商不存在: {}", provider_id))?; + + // FR-S1:内存解析密钥,绝不外泄(不入日志/返回值/错误信息) + let api_key = df_storage::secret::resolve_provider_secret(&provider); + let fetch_type = normalize_provider_type_for_fetch(&provider.provider_type); + + let probed = df_ai::model_fetch::fetch_and_probe(&fetch_type, &provider.base_url, &api_key) + .await + .map_err(err_str)?; + + // 合并:新探测 configs 为主(更新能力维度 modalities/capabilities/cost_tier/intelligence/ + // context_window/probe_source),按 model_id 保留用户在 Settings 调过的 weight/enabled/label。 + // 否则每次「测试连接/拉取模型」覆盖回探测默认 weight(50),权重失效致 ProviderPool/router + // 排序摇摆。对齐 provider 级 enabled/weight 编辑保留逻辑(commands.rs:1041 match existing)。 + // 新模型(旧池无同 model_id)用探测默认值。 + let merged: Vec = probed + .iter() + .map(|c| match provider.model_configs.iter().find(|o| o.model_id == c.model_id) { + Some(old) => ModelConfig { + weight: old.weight, + enabled: old.enabled, + label: old.label.clone(), + ..c.clone() + }, + None => c.clone(), + }) + .collect(); + + // 写回 model_configs(更新 updated_at) + let mut updated = provider.clone(); + updated.model_configs = merged.clone(); + updated.updated_at = now_millis(); + state + .ai_providers + .update_full(&updated) + .await + .map_err(err_str)?; + + Ok(merged) +} + +/// 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。 +/// +/// 用途:拉取后用户手动补一个模型名、或想重探某模型的能力维度。 +/// 直接调 df_ai::model_probe::probe(&model_id),返回填充了 probe_source 的 ModelConfig。 +#[tauri::command] +pub async fn ai_probe_model( + _state: State<'_, AppState>, + model_id: String, +) -> Result { + Ok(df_ai::model_probe::probe(&model_id)) +} + +// ============================================================ +// 对话管理 +// ============================================================ + +/// 创建新对话 +#[tauri::command] +pub async fn ai_conversation_create( + _app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // 懒创建:仅生成 id 存内存,不落库;避免新建后不发消息产生空记录。 + // 首条消息发送后由 save_conversation upsert 写入。 + let id = new_id(); + let now = now_millis(); + + // F-260616-09 B 批4(决策 e 真并发上线):新建会话**不杀旧 loop**。 + // 旧实现(B-260615-10)生成中强制结束旧 conv 的 loop + clear 其 per_conv,在真并发下会误杀 + // 后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv), + // 仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。 + // + // B-260618-06 保留:切换 active 前先持久化旧 active conv(防其内存 messages 因后续操作丢失)。 + // 注意:旧 conv 若有后台 loop 在跑,loop 自身的 save_conversation 也会持久化,此处 save 幂等。 + let old_conv = { + let session = state.ai_session.lock().await; + session.active_conversation_id.clone() + }; + let old_has_msgs = { + let session = state.ai_session.lock().await; + old_conv.as_deref() + .and_then(|oc| session.conv_read(oc)) + .map(|c| !c.messages.is_empty()) + .unwrap_or(false) + }; + if let Some(ref oc) = old_conv { + if old_has_msgs { + save_conversation(&state.ai_session, &state.db, oc.as_str(), None, None).await; + } + } + + let mut session = state.ai_session.lock().await; + session.active_conversation_id = Some(id.clone()); + session.active_conv_created_at = Some(now); + // 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。 + // 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留 + // (switch 路径会 retain 清目标 conv 的,create 不动他人)。 + let _ = session.conv(&id); // 惰性建立空 PerConvState(字段全默认值) + + Ok(serde_json::json!({ "id": id })) +} + +/// 列出对话(仅摘要,不含 messages 全文) +/// +/// limit 默认 50 防数据膨胀;include_archived 默认 false(归档对话默认隐藏)。 +#[tauri::command] +pub async fn ai_conversation_list( + state: State<'_, AppState>, + limit: Option, + include_archived: Option, +) -> Result, String> { + let limit = limit.unwrap_or(50); + let include_archived = include_archived.unwrap_or(false); + let records = state.ai_conversations.list_all().await.map_err(err_str)?; + // list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit + let summaries: Vec = records.iter() + .filter(|r| include_archived || !r.archived) + .take(limit) + .map(|r| { + // 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组 + let models: Vec = r.models.as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + serde_json::json!({ + "id": r.id, + "title": r.title, + "provider_id": r.provider_id, + "model": r.model, + "models": models, + "archived": r.archived, + "pinned": r.pinned, + "prompt_tokens": r.prompt_tokens, + "completion_tokens": r.completion_tokens, + "created_at": r.created_at, + "updated_at": r.updated_at, + }) + }).collect(); + Ok(summaries) +} + +/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端) +#[tauri::command] +pub async fn ai_conversation_switch( + state: State<'_, AppState>, + app_handle: AppHandle, + conversation_id: String, +) -> Result { + let record = state.ai_conversations.get_by_id(&conversation_id).await + .map_err(err_str)? + .ok_or_else(|| format!("对话不存在: {}", conversation_id))?; + + let messages: Vec = serde_json::from_str(&record.messages) + .map_err(|e| format!("解析消息失败: {}", e))?; + + let messages_json = record.messages.clone(); + let title = record.title.clone(); + // B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求) + let need_title_regen = record.title.is_none(); + + let mut session = state.ai_session.lock().await; + // F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。 + // 旧实现:active conv 生成中 → 只读切换(不改 session 状态),因单例 messages 会被新 conv 覆盖。 + // 决策 e:per_conv 已隔离,切走直接改 active_conversation_id + 新 conv per_conv 惰性建/从 DB reload。 + // **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages + // (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。 + session.active_conversation_id = Some(conversation_id.clone()); + let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false); + if !already_live { + // 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。 + // 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。 + let conv = session.conv(&conversation_id); + conv.messages.restore_from_messages(messages); + conv.model_override = None; + conv.session_trust.clear(); + conv.agent_language = None; + conv.iteration_used = 0; + conv.stop_flag.store(false, Ordering::SeqCst); + } + // 仅清空目标对话自身的 pending_approvals,保留其他对话的(防 init 重建的内存 HashMap 被清空, + // 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的) + // → ai_pending_tool_calls 查询 → ai_approve 落库) + session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); + // 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB) + drop(session); + + // 历史会话切入且 title 空 → 后台触发标题重新生成(extract 即时兜底 + LLM 优化)。 + // ensure 内 get_by_id title Some 判断防重复;无可用 provider 静默跳过(增强不阻塞切换)。 + if need_title_regen { + match super::prompt::get_active_provider(&state).await { + Ok(provider_config) => { + super::title::spawn_ensure_title( + &provider_config, + &state.db, + &conversation_id, + &app_handle, + &state.ai_session, + &state.llm_concurrency, + ); + } + Err(e) => tracing::warn!( + "切入历史会话触发标题重生成跳过(无可用 provider, conv_id={}): {}", + conversation_id, e + ), + } + } + + Ok(serde_json::json!({ + "id": record.id, + "title": title, + "messages": messages_json, + })) +} + +/// 删除对话 +#[tauri::command] +pub async fn ai_conversation_delete( + state: State<'_, AppState>, + conversation_id: String, +) -> Result<(), String> { + state.ai_conversations.delete(&conversation_id).await.map_err(err_str)?; + + let mut session = state.ai_session.lock().await; + // 删除任意对话(含非活跃)都应清理其积压审批:pending_approvals 是单例 HashMap, + // 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理, + // 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。 + session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); + // F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此, + // 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。 + session.per_conv.remove(&conversation_id); + if session.active_conversation_id.as_deref() == Some(&conversation_id) { + session.active_conversation_id = None; + } + // F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。 + // conv 已删=LlmConcurrency 该条目不再被 acquire(无 conv 则无 loop/标题/提炼/压缩针对它)。 + // 已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),仅阻止新条目累积。 + // 时机:conv 删除即清理(比"loop 结束 + 无 pending"更确定——conv 删了必无 pending, + // 上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。 + drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题) + state.llm_concurrency.release_conv(&conversation_id).await; + Ok(()) +} + +/// 重命名对话标题 +#[tauri::command] +pub async fn ai_conversation_rename( + state: State<'_, AppState>, + conversation_id: String, + title: String, +) -> Result<(), String> { + let title = title.trim().to_string(); + if title.is_empty() { + return Err("标题不能为空".to_string()); + } + state.ai_conversations.update_field(&conversation_id, "title", &title) + .await.map_err(err_str)?; + Ok(()) +} + +/// 归档/取消归档对话(归档后在侧栏折叠分组展示) +#[tauri::command] +pub async fn ai_conversation_archive( + state: State<'_, AppState>, + conversation_id: String, + archived: bool, +) -> Result<(), String> { + state.ai_conversations + .set_archived(&conversation_id, archived) + .await + .map_err(err_str)?; + Ok(()) +} + +/// 置顶/取消置顶对话(UX-17:对话置顶) +/// +/// 置顶后侧栏排序置前(前端按 pinned DESC, updated_at DESC)。 +/// 纯元数据标记(同归档),不改 updated_at(保持相对时间不变)。 +#[tauri::command] +pub async fn ai_conversation_set_pinned( + state: State<'_, AppState>, + conversation_id: String, + pinned: bool, +) -> Result<(), String> { + state.ai_conversations + .set_pinned(&conversation_id, pinned) + .await + .map_err(err_str)?; + Ok(()) +} + +/// 导出对话为指定格式(UX-18:对话导出) +/// +/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库) +/// - markdown: `## 用户` / `## 助手` 交替标题 + content 原样输出 +/// (content 内已有的三反引号代码块围栏原样保留,不做二次转义) +/// - json: 完整 messages 数组(serde 序列化 ChatMessage 列表) +/// - txt: `user: ...` / `assistant: ...` 纯文本拼接,system/tool 附注 +/// +/// 最小化:仅渲染 user/assistant 文本;tool_calls/tool_results 略过(导出给人看的对话)。 +/// 空对话(无 messages)→ 空字符串(对应格式空体)。 +#[tauri::command] +pub async fn ai_conversation_export( + state: State<'_, AppState>, + conversation_id: String, + format: String, +) -> Result { + // format 校验:非法值 Err(不 panic),防止 format! 注入或未处理分支 + let fmt = format.as_str(); + if !matches!(fmt, "markdown" | "json" | "txt") { + return Err(format!("不支持的导出格式: {}", format)); + } + + // 取落库对话(完整历史) + let record = state.ai_conversations.get_by_id(&conversation_id).await + .map_err(err_str)? + .ok_or_else(|| format!("对话不存在: {}", conversation_id))?; + + let messages: Vec = serde_json::from_str(&record.messages) + .map_err(|e| format!("解析消息失败: {}", e))?; + + let body = match fmt { + "markdown" => { + // user/assistant 各起一节标题;system/tool 跳过(导出是给人看的对话流) + let mut parts: Vec = Vec::new(); + for m in &messages { + let title = match m.role { + df_ai::provider::MessageRole::User => Some("## 用户"), + df_ai::provider::MessageRole::Assistant => Some("## 助手"), + df_ai::provider::MessageRole::System => Some("## 系统"), + df_ai::provider::MessageRole::Tool => Some("## 工具结果"), + }; + if let Some(t) = title { + // content 原样输出,内部三反引号围栏保留(Markdown 嵌套代码块,渲染器原生支持) + parts.push(format!("{}\n\n{}", t, m.content)); + } + } + parts.join("\n\n") + } + "json" => { + serde_json::to_string_pretty(&messages) + .map_err(|e| format!("序列化失败: {}", e))? + } + "txt" => { + let mut parts: Vec = Vec::new(); + for m in &messages { + let role_name = match m.role { + df_ai::provider::MessageRole::System => "system", + df_ai::provider::MessageRole::User => "user", + df_ai::provider::MessageRole::Assistant => "assistant", + df_ai::provider::MessageRole::Tool => "tool", + }; + parts.push(format!("{}: {}", role_name, m.content)); + } + parts.join("\n") + } + // 上方 matches! 已校验,理论不可达 + _ => return Err(format!("不支持的导出格式: {}", format)), + }; + + Ok(body) +} + +/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想 +#[tauri::command] +pub async fn ai_list_skills() -> Result, String> { + // 命中进程内缓存,命中后仅 clone,不重复扫盘 + Ok(skills_cached().clone()) +} + +/// 设置 LLM 调用并发上限(运行时调整,立即生效) +/// +/// 软收敛:缩并发时已持有旧 permit 的任务继续执行不受影响,待其释放后新限制完全生效。 +/// None 表示该层不变(前端可单独调一层)。值下限为 1。 +#[tauri::command] +pub async fn ai_set_concurrency_config( + state: State<'_, AppState>, + global_limit: Option, + per_conv_limit: Option, +) -> Result<(), String> { + // 下限 1,无上限;同时给 global 时约束 per-conv 不超过 global + if let Some(g) = global_limit { + state.llm_concurrency.set_global(g.max(1) as usize).await; + } + if let Some(p) = per_conv_limit { + let mut p = p.max(1); + if let Some(g) = global_limit { + p = p.min(g.max(1)); + } + state.llm_concurrency.set_per_conv(p as usize).await; + } + Ok(()) +} + +/// 设置 Agentic 循环最大轮次(运行时调整,立即生效) +/// +/// 与并发配置不同:max_iterations 是 loop 入口 load 快照的值,热改后当前 loop 不受影响 +/// (已锁定边界),下次发消息生效。范围双 clamp(command 端 1-50 + 前端 input min/max), +/// 防越界输入致 loop 过早结束(值过小)或失控(值过大)。 +#[tauri::command] +pub async fn ai_set_agent_max_iterations( + state: State<'_, AppState>, + value: u32, +) -> Result<(), String> { + // clamp 1-50:下限防 agent 失能(一轮即截断无法调任何工具), + // 上限防失控烧 token(50 轮足够覆盖复杂多步任务) + let clamped = value.clamp(1, 50) as usize; + 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(()) +}