新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地
This commit is contained in:
@@ -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_loop(iteration 从 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 不受影响
|
||||
/// (已锁定边界),下次发消息生效。范围双 clamp(command 端 1-50 + 前端 input min/max),
|
||||
/// 防越界输入致 loop 过早结束(值过小)或失控(值过大)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_set_agent_max_iterations(
|
||||
state: State<'_, AppState>,
|
||||
value: u32,
|
||||
) -> Result<(), String> {
|
||||
// clamp 1-50:下限防 agent 失能(一轮即截断无法调任何工具),
|
||||
// 上限防失控烧 token(50 轮足够覆盖复杂多步任务)
|
||||
let clamped = value.clamp(1, 50) as usize;
|
||||
state.agent_max_iterations.store(clamped, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user