From 86f6c9685b7ed64916d29df53ef532d0568b0602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 03:19:48 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20commands=E6=8B=86?= =?UTF-8?q?=E5=88=86conversation=E5=9F=9F(strategy=E5=8D=95=E6=89=B91-2?= =?UTF-8?q?=E6=96=87=E4=BB=B6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 commands/conversation.rs(336行, 8 conv IPC: create/list/switch/delete/rename/archive/set_pinned/export) - commands/mod.rs 725→418行(删conv域 + pub mod conversation re-export三级透传) - lib.rs invoke_handler + 前端零改动;F-09 batch4决策e保留(create不杀旧loop/switch删readonly) 主代兜底: cargo check --workspace 0 + test 98 + grep 8IPC/re-export印证 strategy: 单批1-2文件原子操作;进度 chat✅+conversation✅,余 provider/skills/config --- docs/todo.md | 56 +++ docs/待审查.md | 27 +- .../src/commands/ai/commands/conversation.rs | 336 ++++++++++++++++++ src-tauri/src/commands/ai/commands/mod.rs | 331 +---------------- 4 files changed, 430 insertions(+), 320 deletions(-) create mode 100644 src-tauri/src/commands/ai/commands/conversation.rs diff --git a/docs/todo.md b/docs/todo.md index e14546a..9a4b6c9 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -433,3 +433,59 @@ — `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}` + 前端任务组件 --- + +### 💡 2026-06-19 新需求(MCP Server·灵感来源·待实施) + +> 灵感来源:`9ffa5f15` DevFlow MCP Server(对抗式评估 7.2/10,推荐 with resources)。 +> 评估关键结论:技术切入点和研发性价比极佳(ToolDefinition ↔ MCP schema 几乎一致,转换成本极低),但反方强度 0.78——暴露 delete_project 等破坏性操作缺乏鉴权/确认/回滚,外部 AI 幻觉可能导致不可逆误删。**必须在协议接入的同时做工具集安全降级**。 + +- [ ] **F-260619-02 [P2]** — **DevFlow MCP Server:对外暴露任务/项目/灵感管理能力**。以 MCP(Model Context Protocol)server 形式对外暴露 DevFlow 数据层工具,让外部 AI 客户端(Claude Code / Cursor / Cline)直接读写 DevFlow 的项目、任务、灵感数据。 + + **形态**:独立 stdio MCP server 子命令(方案 A)。`devflow mcp-server` 启动,应用未运行时也能被 Claude Code 拉起。 + + **协议实现**: + - `initialize` → 返回 server info + capabilities(tools) + - `tools/list` → 从 AiToolRegistry::tool_definitions() 转换为 MCP Tool 格式 + - `tools/call` → 路由到 handler,返回 `content: [{ type: "text", text: JSON }]` + - 推荐用 rmcp(官方 Rust SDK)或最小化手写 JSON-RPC 2.0 over stdio + - MCP Tool schema 与现有 ToolDefinition 结构几乎一致(name/description/parameters JSON Schema),写一个 `tool_definition_to_mcp()` 转换函数即可 + + **工具集(首版只暴露数据层,不暴露文件系统工具)**: + - 项目:list_projects / get_project / create_project / update_project / delete_project / bind_directory + - 任务:list_tasks / create_task / update_task / advance_task / delete_task + - 灵感:list_ideas / create_idea / update_idea / delete_idea / evaluate_idea + - 工作流:run_workflow(首版只读/需确认) + - 其他:list_trash / restore_project + + **⚠️ 安全降级(对抗评估反方核心风险,必须同步实施)**: + - **High 风险操作(delete/purge/run_workflow)默认拒绝** + 返回「请在 DevFlow 应用内执行」——防外部 AI 幻觉导致不可逆误删(反方强度 0.78 核心论点) + - Medium 风险操作(create/update)默认允许 + 日志审计(写 ai_tool_executions 表留痕) + - 可选 `--read-only` 标志仅暴露 list/get 工具 + - **不暴露文件系统工具**(read_file/write_file/delete_file 等),避免绕过应用内路径校验 + - 仅 stdio / 127.0.0.1,不暴露网络 + - 不引入 MCP 客户端功能(DevFlow 只做 server) + + **数据库访问**: + - stdio server 启动时打开同一 SQLite(`app_data_dir/devflow.db` 或 `--db` 参数指定) + - **SQLite WAL 模式确认**:需确认 df-storage 已启用 WAL(反方论点:并发锁竞争未处理)。若未启用 WAL,首版要求关闭 DevFlow 再用 MCP server,或 MCP server 只读连接 + + **配置与发现**: + - `devflow mcp-server --print-config` 输出 Claude Code / Cursor 的 MCP 配置 JSON 片段 + - Claude Code 接入:`claude mcp add devflow -- devflow.exe mcp-server` + + **涉及文件**: + - 新建 `crates/df-mcp/`(Cargo.toml / src/{server.rs, tools.rs, protocol.rs}) + - 修改 `src-tauri/src/main.rs`(CLI 参数解析,`mcp-server` 子命令分支) + - 修改 `src-tauri/Cargo.toml`(加 df-mcp 依赖 + clap) + - 可选:`ai_tools.rs` 把 handler 闭包抽为可独立复用函数 + + **验收标准**: + 1. Claude Code 配置后能 `list_projects` / `list_tasks` / `create_task` / `advance_task` 成功 + 2. 应用运行时 MCP server 也能读写(WAL 并发安全) + 3. High 风险操作(delete/purge/run_workflow)被拒绝并返回友好提示 + 4. `cargo check --workspace EXIT 0` + `cargo test -p df-mcp`(基础协议测试) + 5. `devflow mcp-server --print-config` 输出正确配置片段 + + — 新建 `crates/df-mcp/` + `src-tauri/src/main.rs` + `src-tauri/Cargo.toml` + 可选 `crates/df-ai/src/ai_tools.rs` + +--- diff --git a/docs/待审查.md b/docs/待审查.md index 8bbeb05..9fd6a4e 100644 --- a/docs/待审查.md +++ b/docs/待审查.md @@ -875,7 +875,7 @@ - **待修项回流 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) — 🟡 待审 +### CR-260619-07 F-09 batch4 启用多会话上线(决策e真并发·前后端·agent f09-batch4·commit d899c58) — ✅ 已审(PASS·⚪2·巡检 2026-06-19 独立 grep/read 核验) - **范围**(前后端大改,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)合理(零调用方预留按用户指导保留)。 @@ -883,6 +883,31 @@ - **⚠️ 双会话回归留用户验收**:开 A 跑→切 B 发→A 后台不退出不污染 B;审批/max 续跑跨会话。后端 per_conv 隔离 + 事件 conversation_id 路由保障。 - **关联**:F-09 完整(batch1-8+batch4 上线)。 +**复审结论(2026-06-19·巡检独立 grep/read 核验源码当前形态 + 决策e 真并发对抗分析·commit d899c58)**: ✅ **PASS** — 🔴0 🟡0 ⚪2 + +**7 审查要点逐项核验(file:line 佐证)**: + +| # | 要点 | 佐证 | 判定 | +|---|---|---|---| +| ① | IPC conv_id 前端传参 | `api/ai.ts`:sendMessage/forceSend/regenerate/edit/continue/stop/ai_is_generating 全加 conversationId;`useAiSend.ts`:doSend :125/127 传 `state.activeConversationId`、regenerate :174-183/edit :244-252 有 `if(!convId)` null 保护 | ✅ | +| ② | 删 readonly 后台 conv 可切 | `agentic.rs:546` loop 退出判据 `!per_conv.contains_key(&conv_id)`(conv 存在性,非 active 一致性);:539-541 注释「旧 loop 跑自己 conv 不污染他人,active 切换不让退出」 | ✅ | +| ③ | 顶层字段全删 + 双写清理 | grep `session.generating/messages/stop_flag/notify/iteration_used/agent_language/model_override/session_trust` 全命中**注释**(agentic.rs:400/993、mod.rs:474 等),无实际顶层读写代码(cargo check 0 印证) | ✅ | +| ④ | conv_id 上下文一致 | `run_agentic_loop:338` conv_id 入参快照,全程 `session.conv(&conv_id)`;`try_continue_agent_loop:1075` 显式 conv_id 入参(:1214 注释「active_conversation_id 字段移除,改入参 conv_id」);loop 内 grep active_conversation_id **无实际读写** | ✅ | +| ⑤ | pending_approvals retain 目标 conv | switchConversation(`mod.rs:482`)/deleteConversation(:526)/ai_chat_clear(`chat.rs:536`)/ai_chat_stop(:895/1015)全 `retain(... != target)`,不误杀他 conv,保护 restore 重建链路(注释 :480-481) | ✅ | +| ⑥ | conv_id=null fallback | `ai_is_generating:178-182`(null→fallback active→空返 false)/`ai_chat_send:218-233`(null→active→懒创建 new_id)/`ai_chat_stop:994-998`(空返 Ok) | ✅ | +| ⑦ | ai_continue/stop_loop 移除 active 校验 | `chat.rs:1094/1129` 实际只 `conv_read(&conv_id).generating` 校验,**无 active 一致性**(决策e 落地) | ✅ | + +**对抗核验印证(防污染)**: +- **CR-06 修复延续 ✅(关键)**:`lib.rs:62 session.pending_approvals.retain(|_,a| !a.recovered)` 在 batch4 后**仍保留**(:59-62 注释「BUG-260619-06 修复」)。batch4 删顶层 generating 双写复位(:45)+ 删 active 兜底(顶层退役后无意义),但 retain 修复完整保留 → 上轮审 CR-06 的 🔴 未被新批破坏。 +- **决策e 真并发核心 ✅**:loop 退出判据 conv 存在性(:546)+ loop conv_id 快照不读 active(:348/368)+ guard/stop_flag/notify/messages 全 per_conv(:97/1100/1135)+ 事件 emit 全带 conversation_id → A 后台 loop 切 B 不退出不污染,设计契约与 switchConversation retain 一致。 +- 主代自审 PASS + cargo check 0 + test 98,独立核验源码形态印证(非信声明)。决策e 真并发时序保障经多维度交叉(前端传参/后端 fallback/loop 快照/退出判据/retain 路由)成立。 + +**⚪ low(2)**: +1. **注释过时(6 处,功能正确但误导维护)**:`agentic.rs:400`(model_override「入参←session」→ 实为函数入参)/`:993`(session.iteration_used→ 实为 per_conv)/`chat.rs:1079`(session.iteration_used→per_conv)/`:1082-1083`&`:1117-1118`(ai_continue/stop_loop 文档注释「校验 active 一致性」→ batch4 已移除)/`mod.rs:474`(批2 迁移说明残留)。建议批量更新对齐 batch4 per_conv 化 + 移除 active 校验。 +2. **双会话实机验收(CR-07 :883 已声明)**:开 A 跑→切 B 发→A 后台不退出不污染 B;审批/max 续跑跨会话。后端 per_conv 隔离 + 事件 conversation_id 路由 + loop 退出判据 conv 存在性已核验保障,真并发时序(两 conv 同时 loop + provider pool/llm_concurrency 竞争)需实机验收。 + +- **待修项回流 todo**: **无** 🔴/🟡 项 + --- ## 已审归档 diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs new file mode 100644 index 0000000..1a8b706 --- /dev/null +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -0,0 +1,336 @@ +//! conversation 域 IPC — 对话 CRUD / 切换 / 归档 / 置顶 / 导出 +//! +//! 由原 `commands.rs`(单文件 1829 行 God 文件)按域拆分,本文件聚焦 conversation 域 8 个 IPC。 +//! 其他域(provider/skills/config)仍留在 `commands/mod.rs`,后续批拆分。 +//! +//! re-export:由 `commands/mod.rs` 经 `pub use self::conversation::*;` 拉到 `commands::*`, +//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`, +//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。 +//! +//! F-09 batch4 conv_id 签名(决策 e 真并发)原样保留,不改 IPC 签名/行为(纯搬迁)。 +//! - create:不杀旧 loop(决策 e 真并发上线),仅切 active + 建独立 per_conv +//! - switch:删 readonly 分支(决策 e per_conv 隔离) + +use std::sync::atomic::Ordering; + +use tauri::{AppHandle, State}; + +use df_ai::provider::ChatMessage; +use df_types::types::new_id; + +use crate::state::AppState; +use crate::commands::{err_str, now_millis}; + +// conversation.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 +use super::super::conversation::save_conversation; +use super::super::prompt::get_active_provider; +use super::super::title::spawn_ensure_title; + +// ============================================================ +// 对话管理 +// ============================================================ + +/// 创建新对话 +#[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 get_active_provider(&state).await { + Ok(provider_config) => { + 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) +} diff --git a/src-tauri/src/commands/ai/commands/mod.rs b/src-tauri/src/commands/ai/commands/mod.rs index b04db38..3ed75f0 100644 --- a/src-tauri/src/commands/ai/commands/mod.rs +++ b/src-tauri/src/commands/ai/commands/mod.rs @@ -3,23 +3,26 @@ //! 原单文件 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 +//! - [`conversation`] — conversation 域 8 个 IPC(创建/列出/切换/删除/重命名/归档/置顶/导出) +//! - 本文件(`mod.rs`)保留其余域 IPC:provider/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::*` +//! re-export 链:`commands/mod.rs` → `pub use self::{chat,conversation}::*` 把各域 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::*) +// 各域子模块 + glob 重导出(拉到 commands::* 经 ai/mod.rs 透传到 commands::ai::*) pub mod chat; #[allow(unused_imports)] pub use self::chat::*; +pub mod conversation; +#[allow(unused_imports)] +pub use self::conversation::*; use std::sync::atomic::Ordering; -use tauri::{AppHandle, State}; +use tauri::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; @@ -28,10 +31,9 @@ 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; +// chat/conversation 域专用符号(run_agentic_loop/save_conversation/AiChatEvent/ChatMessage 等) +// 已随 chat.rs/conversation.rs 搬走,本文件剩余 provider/skills/config 域不再引用,故不 import。 +// skills 域(ai_list_skills)仍用 skills_cached/SkillInfo,保留。 use super::skills::{skills_cached, SkillInfo}; // ============================================================ @@ -350,315 +352,6 @@ pub async fn ai_probe_model( 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> {