Files
DevFlow/src-tauri/src/commands/ai/commands.rs
绝尘 cf017f81e2 新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 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/儿童每日打卡应用/ 与本项目无关,已排除。
2026-06-14 14:08:20 +08:00

532 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 所有 `#[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_flagstream_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(())
}