重构: commands拆分conversation域(strategy单批1-2文件)
- 新建 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
This commit is contained in:
56
docs/todo.md
56
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`
|
||||
|
||||
---
|
||||
|
||||
27
docs/待审查.md
27
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**: **无** 🔴/🟡 项
|
||||
|
||||
---
|
||||
|
||||
## 已审归档
|
||||
|
||||
336
src-tauri/src/commands/ai/commands/conversation.rs
Normal file
336
src-tauri/src/commands/ai/commands/conversation.rs
Normal file
@@ -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<serde_json::Value, String> {
|
||||
// 懒创建:仅生成 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<usize>,
|
||||
include_archived: Option<bool>,
|
||||
) -> Result<Vec<serde_json::Value>, 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<serde_json::Value> = records.iter()
|
||||
.filter(|r| include_archived || !r.archived)
|
||||
.take(limit)
|
||||
.map(|r| {
|
||||
// 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组
|
||||
let models: Vec<String> = 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<serde_json::Value, String> {
|
||||
let record = state.ai_conversations.get_by_id(&conversation_id).await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
|
||||
|
||||
let messages: Vec<ChatMessage> = 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<String, String> {
|
||||
// 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<ChatMessage> = serde_json::from_str(&record.messages)
|
||||
.map_err(|e| format!("解析消息失败: {}", e))?;
|
||||
|
||||
let body = match fmt {
|
||||
"markdown" => {
|
||||
// user/assistant 各起一节标题;system/tool 跳过(导出是给人看的对话流)
|
||||
let mut parts: Vec<String> = 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<String> = 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)
|
||||
}
|
||||
@@ -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<serde_json::Value, String> {
|
||||
// 懒创建:仅生成 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<usize>,
|
||||
include_archived: Option<bool>,
|
||||
) -> Result<Vec<serde_json::Value>, 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<serde_json::Value> = records.iter()
|
||||
.filter(|r| include_archived || !r.archived)
|
||||
.take(limit)
|
||||
.map(|r| {
|
||||
// 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组
|
||||
let models: Vec<String> = 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<serde_json::Value, String> {
|
||||
let record = state.ai_conversations.get_by_id(&conversation_id).await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
|
||||
|
||||
let messages: Vec<ChatMessage> = 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<String, String> {
|
||||
// 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<ChatMessage> = serde_json::from_str(&record.messages)
|
||||
.map_err(|e| format!("解析消息失败: {}", e))?;
|
||||
|
||||
let body = match fmt {
|
||||
"markdown" => {
|
||||
// user/assistant 各起一节标题;system/tool 跳过(导出是给人看的对话流)
|
||||
let mut parts: Vec<String> = 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<String> = 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<Vec<SkillInfo>, String> {
|
||||
|
||||
Reference in New Issue
Block a user