重构: 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:
2026-06-19 03:19:48 +08:00
parent e6dbb14c5d
commit 86f6c9685b
4 changed files with 430 additions and 320 deletions

View 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)
}

View File

@@ -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> {