重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
532 lines
20 KiB
Rust
532 lines
20 KiB
Rust
//! 所有 `#[tauri::command]` IPC 函数 — 由 mod.rs 重导出供 invoke_handler 引用
|
||
|
||
use std::sync::atomic::Ordering;
|
||
|
||
use serde::Serialize;
|
||
use tauri::{AppHandle, Emitter, State};
|
||
|
||
use df_ai::provider::ChatMessage;
|
||
use df_core::types::new_id;
|
||
use df_storage::models::AiProviderRecord;
|
||
|
||
use crate::state::AppState;
|
||
use crate::commands::now_millis;
|
||
|
||
use super::agentic::{run_agentic_loop, try_continue_agent_loop};
|
||
use super::audit::audit_finalize;
|
||
use super::conversation::save_conversation;
|
||
use super::knowledge_inject::build_knowledge_context;
|
||
use super::prompt::build_system_prompt;
|
||
use super::skills::{read_skill_content, SkillInfo, skills_cached};
|
||
|
||
use super::AiChatEvent;
|
||
|
||
// ============================================================
|
||
// 发送 / 审批 / 控制
|
||
// ============================================================
|
||
|
||
/// 发送消息并获取流式 AI 响应
|
||
///
|
||
/// 非阻塞:立即返回 "ok",通过 ai-chat-event 事件流式推送
|
||
#[tauri::command]
|
||
pub async fn ai_chat_send(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
message: String,
|
||
language: Option<String>,
|
||
skill: Option<String>,
|
||
) -> Result<String, String> {
|
||
// 获取活跃提供商(只读,失败可直接返回,不影响生成标志)
|
||
let provider_config = super::prompt::get_active_provider(&state).await?;
|
||
|
||
// 原子检查并占用生成标志,防止并发双发;同步追加用户消息,按需自动创建对话
|
||
{
|
||
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();
|
||
session.messages.push(ChatMessage::user(&message));
|
||
|
||
// 首次发送时生成对话 id(懒创建:不立即落库,避免空对话残留;
|
||
// 实际记录由 save_conversation 在生成内容后 upsert 写入)
|
||
if session.active_conversation_id.is_none() {
|
||
let conv_id = new_id();
|
||
session.active_conversation_id = Some(conv_id);
|
||
session.active_conv_created_at = Some(now_millis());
|
||
}
|
||
}
|
||
|
||
// 获取工具定义(预取仅用于触发注册表初始化,实际 tool_defs 在 agentic loop 内部按需获取)
|
||
let _tool_defs = state.ai_tools.tool_definitions();
|
||
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
|
||
let mut system_prompt = build_system_prompt(&state, &lang).await;
|
||
// 技能注入:读 SKILL.md 全文拼到 system prompt 前作为指令
|
||
if let Some(ref skill_name) = skill {
|
||
if let Some(content) = read_skill_content(skill_name) {
|
||
system_prompt = format!("# 技能指令: {}\n\n{}\n\n---\n{}", skill_name, content, system_prompt);
|
||
}
|
||
}
|
||
// 快照当前对话 ID,供知识注入溯源 + spawn 后台 loop(不受切换影响)
|
||
let conv_id = {
|
||
let session = state.ai_session.lock().await;
|
||
session.active_conversation_id.clone().unwrap_or_default()
|
||
};
|
||
|
||
// 知识注入:检索相关知识拼到 system prompt 前(可配置开关 auto_inject,默认开)
|
||
// 最终顺序:[知识库上下文] --- [技能指令] --- [原始 system prompt]
|
||
{
|
||
let config = state.knowledge_config.lock().await.clone();
|
||
let knowledge_context = build_knowledge_context(&state, &conv_id, &message, &config).await;
|
||
if !knowledge_context.is_empty() {
|
||
system_prompt = format!("{}\n\n---\n{}", knowledge_context, system_prompt);
|
||
}
|
||
}
|
||
|
||
// 在后台任务中执行流式调用
|
||
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();
|
||
|
||
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;
|
||
});
|
||
|
||
Ok("ok".to_string())
|
||
}
|
||
|
||
/// 批准/拒绝挂起的工具调用
|
||
#[tauri::command]
|
||
pub async fn ai_approve(
|
||
app: AppHandle,
|
||
state: State<'_, AppState>,
|
||
tool_call_id: String,
|
||
approved: bool,
|
||
) -> Result<String, String> {
|
||
let mut session = state.ai_session.lock().await;
|
||
|
||
let approval = session
|
||
.pending_approvals
|
||
.remove(&tool_call_id)
|
||
.ok_or_else(|| format!("未找到挂起的审批: {}", tool_call_id))?;
|
||
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
|
||
let _recovered = approval.recovered;
|
||
|
||
if !approved {
|
||
// 替换占位 tool_result 为拒绝结果
|
||
session.messages.replace_tool_result_content(&tool_call_id, "用户拒绝了此操作");
|
||
let conv_id = approval.conversation_id.clone();
|
||
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
|
||
id: tool_call_id.clone(),
|
||
approved: false,
|
||
conversation_id: conv_id.clone(),
|
||
});
|
||
drop(session);
|
||
// 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史,
|
||
// messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。
|
||
if let Some(ref cid) = conv_id {
|
||
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
|
||
}
|
||
// 审计:拒绝(决策者=human)
|
||
audit_finalize(&state, &tool_call_id, "rejected", None).await;
|
||
// 所有待审批处理完毕后恢复 agentic 循环
|
||
try_continue_agent_loop(&app, &state).await;
|
||
return Ok("rejected".to_string());
|
||
}
|
||
|
||
// 执行工具(通过真实 repo 调用)
|
||
let args = approval.arguments.clone();
|
||
let id = tool_call_id.clone();
|
||
let conv_id = approval.conversation_id.clone();
|
||
drop(session); // 释放锁后再执行
|
||
|
||
let exec_result = state.ai_tools.execute(&approval.tool_name, args.clone()).await;
|
||
// 工具失败不 return Err:把错误包成 tool_result,落库 + emit completed + 续循环全走通。
|
||
// 否则前端 approveToolCall 的 catch 会回滚 pending_approval,审批按钮卡死无法消除。
|
||
let (audit_status, result_val) = match &exec_result {
|
||
Ok(val) => ("executed", val.clone()),
|
||
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
|
||
};
|
||
// 审计:人工审批后无论成败回填(决策者=human)
|
||
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
|
||
|
||
// 重新获取锁,替换占位 tool_result 为真实结果(失败时为错误信息,LLM 据此决定下一步)
|
||
let mut session = state.ai_session.lock().await;
|
||
session.messages.replace_tool_result_content(&id, &result_val.to_string());
|
||
|
||
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||
id: id.clone(),
|
||
result: result_val.clone(),
|
||
conversation_id: conv_id.clone(),
|
||
});
|
||
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
|
||
id,
|
||
approved: true,
|
||
conversation_id: conv_id.clone(),
|
||
});
|
||
drop(session);
|
||
|
||
// 审批执行结果立即落库,不依赖后续 agentic loop(避免 loop 异常退出时丢失真实结果)
|
||
// 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空,
|
||
// save 不污染老对话;原 if !recovered 守卫前提不成立已移除。
|
||
if let Some(ref cid) = conv_id {
|
||
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
|
||
}
|
||
|
||
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
|
||
try_continue_agent_loop(&app, &state).await;
|
||
|
||
Ok("executed".to_string())
|
||
}
|
||
|
||
/// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用)
|
||
#[derive(Debug, Serialize)]
|
||
pub struct PendingToolCallInfo {
|
||
pub tool_call_id: String,
|
||
pub conversation_id: Option<String>,
|
||
}
|
||
|
||
/// 查询某对话积压的待审批工具(前端 switchConversation 后恢复 toolCard 的 pending_approval 态)
|
||
#[tauri::command]
|
||
pub async fn ai_pending_tool_calls(
|
||
state: State<'_, AppState>,
|
||
conv_id: String,
|
||
) -> Result<Vec<PendingToolCallInfo>, String> {
|
||
let session = state.ai_session.lock().await;
|
||
let list = session
|
||
.pending_approvals
|
||
.values()
|
||
.filter(|a| a.conversation_id.as_deref() == Some(conv_id.as_str()))
|
||
.map(|a| PendingToolCallInfo {
|
||
tool_call_id: a.tool_call_id.clone(),
|
||
conversation_id: a.conversation_id.clone(),
|
||
})
|
||
.collect();
|
||
Ok(list)
|
||
}
|
||
|
||
/// 清空对话历史
|
||
#[tauri::command]
|
||
pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||
let mut session = state.ai_session.lock().await;
|
||
session.messages.clear();
|
||
session.pending_approvals.clear();
|
||
Ok(())
|
||
}
|
||
|
||
/// 停止当前 AI 生成
|
||
///
|
||
/// 两种场景:
|
||
/// - loop 正在流式生成:置 stop_flag,stream_llm / 循环检查点尽快退出并 emit AiCompleted
|
||
/// - 有挂起审批(loop 已 return 等待中):stop_flag 无人读取,直接清审批 + 复位 generating,
|
||
/// 否则停止按钮表面无反应、会话卡在 generating=true
|
||
#[tauri::command]
|
||
pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result<(), String> {
|
||
let mut session = state.ai_session.lock().await;
|
||
if !session.generating {
|
||
return Ok(());
|
||
}
|
||
if !session.pending_approvals.is_empty() {
|
||
// 审批等待态:loop 已退出,直接清理让会话立即可用
|
||
session.pending_approvals.clear();
|
||
session.generating = false;
|
||
session.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
|
||
let conv_id = session.active_conversation_id.clone();
|
||
drop(session);
|
||
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, conversation_id: conv_id });
|
||
return Ok(());
|
||
}
|
||
// 流式生成中:置位让 loop 自行收尾
|
||
session.stop_flag.store(true, Ordering::SeqCst);
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================
|
||
// 提供商管理
|
||
// ============================================================
|
||
|
||
/// 列出所有已配置的 AI 提供商(is_default 真相源为 DB,重启不丢)
|
||
#[tauri::command]
|
||
pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProviderRecord>, String> {
|
||
state.ai_providers.list_all().await.map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// 保存/更新 AI 提供商配置
|
||
#[tauri::command]
|
||
pub async fn ai_save_provider(
|
||
state: State<'_, AppState>,
|
||
id: Option<String>,
|
||
name: String,
|
||
base_url: String,
|
||
api_key: String,
|
||
default_model: String,
|
||
provider_type: String,
|
||
) -> Result<String, String> {
|
||
// 编辑已有提供商时保留原 created_at,避免被覆盖
|
||
let created_at = match &id {
|
||
Some(pid) => state.ai_providers.get_by_id(pid).await
|
||
.map_err(|e| e.to_string())?
|
||
.map(|p| p.created_at)
|
||
.unwrap_or_else(now_millis),
|
||
None => now_millis(),
|
||
};
|
||
// is_default:编辑保留原值;新建时若全表尚无默认则设为默认(首个自动默认,避免无默认可用)
|
||
let is_default = match &id {
|
||
Some(pid) => state.ai_providers.get_by_id(pid).await
|
||
.map_err(|e| e.to_string())?
|
||
.map(|p| p.is_default)
|
||
.unwrap_or(false),
|
||
None => !state.ai_providers.list_all().await
|
||
.map_err(|e| e.to_string())?
|
||
.iter().any(|p| p.is_default),
|
||
};
|
||
let record = AiProviderRecord {
|
||
id: id.unwrap_or_else(new_id),
|
||
name,
|
||
provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type },
|
||
api_key,
|
||
base_url,
|
||
default_model,
|
||
models: None,
|
||
is_default,
|
||
config: None,
|
||
created_at,
|
||
updated_at: now_millis(),
|
||
};
|
||
let id = record.id.clone();
|
||
state
|
||
.ai_providers
|
||
.insert(record)
|
||
.await
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(id)
|
||
}
|
||
|
||
/// 设置活跃提供商(互斥落库:目标置默认、其余清默认,重启不丢)
|
||
#[tauri::command]
|
||
pub async fn ai_set_provider(
|
||
state: State<'_, AppState>,
|
||
provider_id: String,
|
||
) -> Result<(), String> {
|
||
// 验证提供商存在
|
||
let provider = state
|
||
.ai_providers
|
||
.get_by_id(&provider_id)
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
.ok_or_else(|| format!("提供商不存在: {}", provider_id))?;
|
||
|
||
// 互斥写 DB:目标 is_default=true,其余=false。仅写变化的记录。
|
||
let providers = state.ai_providers.list_all().await.map_err(|e| e.to_string())?;
|
||
for p in &providers {
|
||
let should = p.id == provider_id;
|
||
if p.is_default != should {
|
||
let mut updated = p.clone();
|
||
updated.is_default = should;
|
||
updated.updated_at = now_millis();
|
||
state.ai_providers.update_full(&updated).await.map_err(|e| e.to_string())?;
|
||
}
|
||
}
|
||
|
||
let mut session = state.ai_session.lock().await;
|
||
session.active_provider_id = Some(provider.id);
|
||
Ok(())
|
||
}
|
||
|
||
/// 删除 AI 提供商
|
||
#[tauri::command]
|
||
pub async fn ai_delete_provider(
|
||
state: State<'_, AppState>,
|
||
provider_id: String,
|
||
) -> Result<(), String> {
|
||
state.ai_providers.delete(&provider_id).await.map_err(|e| e.to_string())?;
|
||
// 删除的若是当前默认,清空 active 指向,避免悬空
|
||
let mut session = state.ai_session.lock().await;
|
||
if session.active_provider_id.as_deref() == Some(&provider_id) {
|
||
session.active_provider_id = None;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================
|
||
// 对话管理
|
||
// ============================================================
|
||
|
||
/// 创建新对话
|
||
#[tauri::command]
|
||
pub async fn ai_conversation_create(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||
// 懒创建:仅生成 id 存内存,不落库;避免新建后不发消息产生空记录。
|
||
// 首条消息发送后由 save_conversation upsert 写入。
|
||
let id = new_id();
|
||
let now = now_millis();
|
||
|
||
let mut session = state.ai_session.lock().await;
|
||
session.active_conversation_id = Some(id.clone());
|
||
session.active_conv_created_at = Some(now);
|
||
session.messages.clear();
|
||
session.pending_approvals.clear();
|
||
|
||
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(|e| e.to_string())?;
|
||
// 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,
|
||
"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>,
|
||
conversation_id: String,
|
||
) -> Result<serde_json::Value, String> {
|
||
let record = state.ai_conversations.get_by_id(&conversation_id).await
|
||
.map_err(|e| e.to_string())?
|
||
.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();
|
||
|
||
let mut session = state.ai_session.lock().await;
|
||
// 生成中允许只读切换:返回目标对话的 messages 供前端展示,但不修改 session 状态
|
||
// 后台 loop 持有快照的 conv_id,不受 active_conversation_id 变更影响
|
||
if session.generating {
|
||
return Ok(serde_json::json!({
|
||
"id": record.id,
|
||
"title": title,
|
||
"messages": messages_json,
|
||
"readonly": true,
|
||
}));
|
||
}
|
||
session.active_conversation_id = Some(conversation_id.clone());
|
||
session.messages.restore_from_messages(messages);
|
||
// 仅清空目标对话自身的 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));
|
||
|
||
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(|e| e.to_string())?;
|
||
|
||
let mut session = state.ai_session.lock().await;
|
||
if session.active_conversation_id.as_deref() == Some(&conversation_id) {
|
||
session.active_conversation_id = None;
|
||
session.messages.clear();
|
||
session.pending_approvals.clear();
|
||
}
|
||
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(|e| e.to_string())?;
|
||
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(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想
|
||
#[tauri::command]
|
||
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
|
||
// 命中进程内缓存,命中后仅 clone,不重复扫盘
|
||
Ok(skills_cached().clone())
|
||
}
|
||
|
||
/// 设置 LLM 调用并发上限(运行时调整,立即生效)
|
||
///
|
||
/// 软收敛:缩并发时已持有旧 permit 的任务继续执行不受影响,待其释放后新限制完全生效。
|
||
/// None 表示该层不变(前端可单独调一层)。值下限为 1。
|
||
#[tauri::command]
|
||
pub async fn ai_set_concurrency_config(
|
||
state: State<'_, AppState>,
|
||
global_limit: Option<u32>,
|
||
per_conv_limit: Option<u32>,
|
||
) -> Result<(), String> {
|
||
// 下限 1,无上限;同时给 global 时约束 per-conv 不超过 global
|
||
if let Some(g) = global_limit {
|
||
state.llm_concurrency.set_global(g.max(1) as usize).await;
|
||
}
|
||
if let Some(p) = per_conv_limit {
|
||
let mut p = p.max(1);
|
||
if let Some(g) = global_limit {
|
||
p = p.min(g.max(1));
|
||
}
|
||
state.llm_concurrency.set_per_conv(p as usize).await;
|
||
}
|
||
Ok(())
|
||
}
|