新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地

This commit is contained in:
2026-06-16 12:41:13 +08:00
parent 212a927eee
commit 7d5cd4c89a
62 changed files with 4576 additions and 248 deletions

View File

@@ -25,6 +25,90 @@ use super::AiChatEvent;
// 发送 / 审批 / 控制
// ============================================================
/// 重新生成最后一条 AI 回复(UX-02:消息操作栏「重新生成」)
///
/// 流程:占用 generating → 弹出末尾 AI 回复(pop_last_assistant_round,保留触发它的
/// user 消息)→ save 落库(避免前端切走时残留旧回复)→ spawn run_agentic_loop 重跑
/// (历史末尾是该 user 消息,LLM 据此再生成)。
///
/// 与 ai_chat_send 的区别:不 push 新 user 消息(用户消息已在历史末尾),仅清旧 AI 回复后
/// 复用同一 agentic loop。生成中拦截,与 send 一致防并发双发。
#[tauri::command]
pub async fn ai_regenerate(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
language: Option<String>,
) -> Result<String, String> {
let provider_config = super::prompt::get_active_provider(&state).await?;
// 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息)
{
let mut session = state.ai_session.lock().await;
if session.generating {
return Err("AI 正在生成中,请等待完成".to_string());
}
session.generating = true;
session.stop_flag.store(false, Ordering::SeqCst);
session.agent_language = language.clone();
let popped = session.messages.pop_last_assistant_round();
if !popped {
// 历史末尾无 AI 回复可弹(空对话/末尾是 user 错误态等),复位 generating 报错
session.generating = false;
return Err("没有可重新生成的回复".to_string());
}
// 一致性:regenerate 限定当前活跃对话(避免历史快照陈旧时弹错对话的消息)
if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) {
session.generating = false;
return Err("对话已切换,无法重新生成".to_string());
}
}
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let system_prompt = build_system_prompt(&state, &lang).await;
// 知识注入:取末尾 user 消息文本做检索(与 send 同款,语义命中刷新上下文)
let (conv_id, last_user_text) = {
let session = state.ai_session.lock().await;
let cid = session.active_conversation_id.clone().unwrap_or_default();
// 末尾 user 消息文本(用于知识检索;检索本身失败不阻断重生成)
// iter() 非 DoubleEnded,反向找 user:经 all_messages_clone 正向遍历后取末尾 user
let msgs = session.messages.all_messages_clone();
let last_user = msgs.iter().rev()
.find(|m| matches!(m.role, df_ai::provider::MessageRole::User))
.map(|m| m.content.clone())
.unwrap_or_default();
(cid, last_user)
};
let mut system_prompt = system_prompt;
{
let config = state.knowledge_config.lock().await.clone();
let knowledge_context = build_knowledge_context(&state, &conv_id, &last_user_text, &config).await;
if !knowledge_context.is_empty() {
system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt);
}
}
// 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();
let db = state.db.clone();
let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
});
Ok("ok".to_string())
}
/// 查询后端真实 generating 状态(B-260615-22:方案 A 发送前 IPC 查后端真值)
///
/// 前端 `state.streaming` 与后端 `AiSession.generating` 各自维护:
@@ -109,9 +193,11 @@ pub async fn ai_chat_send(
let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
// F-260616-01: loop 入口 load 快照,当前 loop 锁定边界(热改下次发消息生效)
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency).await;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations).await;
});
Ok("ok".to_string())
@@ -253,6 +339,111 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
Ok(())
}
/// 编辑最后一条 user 消息并重新生成UX-09
///
/// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 →
/// ① 替换末条 active user 消息 content=new_message → ② 其后所有消息标 truncated(软删,
/// 保留 DB 可追溯但不进 LLM 上下文,前端视图过滤)→ save 落库 → spawn run_agentic_loop 重跑。
///
/// 约束:① 只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)② generating 中拦截 ③ 活跃对话一致。
/// 与 ai_regenerate 的区别:不 pop 旧 AI 回复(改标 truncated 保留),且编辑 user 内容后重跑。
#[tauri::command]
pub async fn ai_chat_edit(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
new_message: String,
language: Option<String>,
) -> Result<String, String> {
let provider_config = super::prompt::get_active_provider(&state).await?;
// 原子占用 generating + 替换末条 user content + truncate 其后
{
let mut session = state.ai_session.lock().await;
if session.generating {
return Err("AI 正在生成中,请等待完成".to_string());
}
// 活跃对话一致性(防切走后编辑老快照)
if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) {
return Err("对话已切换,无法编辑".to_string());
}
// ① 替换末条 active user 消息 content(无 active user → Err)
if session
.messages
.replace_last_active_user_content(&new_message)
.is_err()
{
return Err("没有可编辑的用户消息".to_string());
}
// ② 其后所有消息标 truncated(无后续也 OK,返回 0)
let _ = session
.messages
.truncate_after_user_message(&new_message)
.map_err(|_| "定位被编辑消息失败".to_string())?;
// 占用 generating + stop_flag
session.generating = true;
session.stop_flag.store(false, Ordering::SeqCst);
session.agent_language = language.clone();
}
let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let system_prompt = build_system_prompt(&state, &lang).await;
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)
let (conv_id, last_user_text) = {
let session = state.ai_session.lock().await;
let cid = session.active_conversation_id.clone().unwrap_or_default();
let msgs = session.messages.all_messages_clone();
// 取末条 active user 文本(sanitize 前的全量,但 truncated 已标,这里取 active 的末条)
let last_user = msgs
.iter()
.rev()
.find(|m| matches!(m.role, df_ai::provider::MessageRole::User) && m.is_active())
.map(|m| m.content.clone())
.unwrap_or_default();
(cid, last_user)
};
let mut system_prompt = system_prompt;
{
let config = state.knowledge_config.lock().await.clone();
let knowledge_context =
build_knowledge_context(&state, &conv_id, &last_user_text, &config).await;
if !knowledge_context.is_empty() {
system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt);
}
}
// 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();
let db = state.db.clone();
let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move {
run_agentic_loop(
session_arc,
tools_arc,
db,
app_handle,
provider_config,
system_prompt,
conv_id,
knowledge_config,
llm_concurrency,
max_iterations,
)
.await;
});
Ok("ok".to_string())
}
/// 强制发送消息B-260616-02: L2 发送韧性)
///
/// 当后端 generating=true 残留HMR/异常退出等)导致 sendMessage 被拦截时,
@@ -347,6 +538,74 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<
Ok(())
}
/// 续跑 agentic 循环F-260616-03达 max_iterations 暂停态用户点「继续」)
///
/// 场景run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// generating=true 暂停。用户点继续调本命令 → 复位 stop_flag防上一轮残留致续跑入口即退出
/// → 调 try_continue_agent_loop 重新 spawn run_agentic_loopiteration 从 0 重计,天然再跑
/// max_iterations 轮,决策 a
///
/// 校验复用 ai_approve 模式generating 必须为 true暂停态+ active_conversation_id 一致性
/// (防陈旧 loop 续跑污染新对话)。无硬上限防无限续(决策 b用户主动授权 = 同意烧 token
#[tauri::command]
pub async fn ai_continue_loop(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
) -> Result<String, String> {
{
let mut session = state.ai_session.lock().await;
if !session.generating {
return Err("AI 未在暂停态,无需继续".to_string());
}
if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) {
return Err("对话已切换,无法继续".to_string());
}
// 复位停止信号:暂停态可能因上一轮 stop_flag 残留为 true续跑 loop 入口会立即退出走完成流程
session.stop_flag.store(false, Ordering::SeqCst);
}
// 复用审批恢复续 loop 入口(不重写 loop其内部 spawn run_agentic_loop
try_continue_agent_loop(&app, &state).await;
Ok("ok".to_string())
}
/// 停止 agentic 循环并走完成流程F-260616-03达 max_iterations 暂停态用户点「停止」)
///
/// 场景run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// generating=true 暂停。用户点停止调本命令 → 复位 generating + emit AiCompleted标收敛
///
/// 不重复 save 逻辑:暂停态进入前 run_agentic_loop 已 save_conversation 落库agentic.rs
/// 达上限分支),此处仅复位 generating + emit AiCompleted 通知前端收尾。校验复用 ai_approve
/// 模式generating + active_conversation_id 一致性)。
#[tauri::command]
pub async fn ai_stop_loop(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
) -> Result<String, String> {
let conv_id = {
let mut session = state.ai_session.lock().await;
if !session.generating {
return Err("AI 未在暂停态,无需停止".to_string());
}
if session.active_conversation_id.as_deref() != Some(conversation_id.as_str()) {
return Err("对话已切换,无法停止".to_string());
}
// 置 stop_flag 双保险:防 try_continue 误判重启(与 ai_chat_stop 审批分支一致)
session.stop_flag.store(true, Ordering::SeqCst);
session.generating = false;
session.active_conversation_id.clone().unwrap_or_default()
};
// 暂停态进入前已 save_conversation此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
conversation_id: Some(conv_id),
});
Ok("ok".to_string())
}
// ============================================================
// 提供商管理
// ============================================================
@@ -589,6 +848,7 @@ pub async fn ai_conversation_list(
"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,
@@ -686,6 +946,95 @@ pub async fn ai_conversation_archive(
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> {
@@ -716,3 +1065,20 @@ pub async fn ai_set_concurrency_config(
}
Ok(())
}
/// 设置 Agentic 循环最大轮次(运行时调整,立即生效)
///
/// 与并发配置不同max_iterations 是 loop 入口 load 快照的值,热改后当前 loop 不受影响
/// (已锁定边界),下次发消息生效。范围双 clampcommand 端 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 失能(一轮即截断无法调任何工具),
// 上限防失控烧 token50 轮足够覆盖复杂多步任务)
let clamped = value.clamp(1, 50) as usize;
state.agent_max_iterations.store(clamped, Ordering::SeqCst);
Ok(())
}