新增: 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

@@ -22,15 +22,15 @@ use super::stream_recv::stream_llm;
use super::title::{ensure_conversation_title, spawn_ensure_title};
use super::audit::process_tool_calls;
use super::{AiChatEvent, AiSession};
use super::{AiChatEvent, AiSession, ErrorType};
/// Agentic 循环最大迭代次数
/// Agentic 循环默认最大迭代次数(可配置项的默认值)
///
/// 默认 10 轮。未来可配置接入点:接入 AppState(新增 `agent_config` 字段)/df-storage settings KV
/// 表后,改为从配置读(默认值仍为 10)。当前项目无 agent 配置位(AppState/df-storage config 表
/// 均无 agent 配置槽),故暂以常量承载,避免引入 AppState 新字段等大改(违反 P2 零行为变边界)。
/// 接入路径:run_agentic_loop 签名增 `max_iterations: usize` 参数,调用方从 AppState 读取透传
pub const MAX_AGENT_ITERATIONS: usize = 10;
/// 默认 10 轮。F-260616-01 已接入配置:AppState.agent_max_iterations(Arc<AtomicUsize>) +
/// ai_set_agent_max_iterations command + Settings.vue 数字配置。调用方在 loop 入口
/// load AtomicUsize 快照后透传 `max_iterations: usize` 形参,当前 loop 锁定边界,
/// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)
pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
// ============================================================
// B-260615-09: generating 状态 RAII guard
@@ -98,6 +98,7 @@ pub(crate) async fn run_agentic_loop(
conv_id: String,
knowledge_config: crate::state::KnowledgeConfig,
llm_concurrency: LlmConcurrency,
max_iterations: usize,
) {
// B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底)
let mut guard = GeneratingGuard::new(session_arc.clone());
@@ -124,6 +125,8 @@ pub(crate) async fn run_agentic_loop(
guard.reset().await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: msg,
// ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth
error_type: Some(ErrorType::Auth),
conversation_id: Some(conv_id.clone()),
});
return;
@@ -153,7 +156,7 @@ pub(crate) async fn run_agentic_loop(
// 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常
let mut converged = false;
for iteration in 0..MAX_AGENT_ITERATIONS {
for iteration in 0..max_iterations {
// 用户请求停止 → 收尾退出(已生成文本已在上一轮入库)
if stop_flag.load(Ordering::SeqCst) {
let usage = df_ai::provider::TokenUsage {
@@ -316,19 +319,31 @@ pub(crate) async fn run_agentic_loop(
// 全部自动执行完成 → 继续下一轮
}
// 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):异常中断,提示用户
// 与 break 正常收敛(break→converged=true)区分:这里仍走入库+Completed,但前置发 AiError 警示
// 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):转入暂停态询问用户
// F-260616-03:不再 emit AiError + 走完成流程,改为 emit AiMaxRoundsReached + 保持 generating=true
// (仿审批等待 L313-316),等用户点继续(ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮)
// 或点停止(ai_stop_loop → 走完成流程)。try_continue 重新 spawn run_agentic_loop,iteration 从 0 重计,
// 故续跑天然再跑 max_iterations 轮(决策 a),无需 reset 任何计数器。
if !converged {
tracing::warn!(
conv_id = %conv_id,
max_iter = MAX_AGENT_ITERATIONS,
"[ai] agentic 循环达最大轮次(MAX_AGENT_ITERATIONS={})仍未收敛,可能未完成",
MAX_AGENT_ITERATIONS,
max_iter = max_iterations,
"[ai] agentic 循环达最大轮次(max_iterations={})仍未收敛,转暂停态询问用户(F-260616-03)",
max_iterations,
);
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("达到最大轮次({} 轮),Agent 可能未完成(末轮工具结果未回传模型)", MAX_AGENT_ITERATIONS),
// 轮 token 落库(保留末轮已生成内容,续跑/停止都据此累加)
let usage = df_ai::provider::TokenUsage {
prompt_tokens: tokens.prompt(),
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await;
// 暂停态保持 generating=true(防其他 send 抢占,仿审批),disarm guard 跳过 Drop 兜底复位
guard.disarm();
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiMaxRoundsReached {
conversation_id: Some(conv_id.clone()),
});
return; // generating 保持 true,等 ai_continue_loop / ai_stop_loop
}
// 正常完成
@@ -444,6 +459,8 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
tracing::warn!(error = %e, "[ai] try_continue 失败:无可用 provider");
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
error: e,
// 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig
error_type: Some(ErrorType::ProviderConfig),
conversation_id: Some(conv_id),
});
return;
@@ -466,6 +483,8 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
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);
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
@@ -475,6 +494,6 @@ pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) {
});
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;
});
}

View File

@@ -3,7 +3,8 @@
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::provider::ChatMessage;
@@ -15,7 +16,7 @@ use df_core::types::new_id;
use crate::state::AppState;
use crate::commands::now_millis;
use crate::commands::{err_str, now_millis};
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
@@ -38,10 +39,87 @@ pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
}
}
// ============================================================
// 审批历史查询 IPCAE-2025-08
// ============================================================
/// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露)
///
/// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要,
/// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。
#[derive(Debug, Clone, Serialize)]
pub struct ToolExecutionDto {
pub id: String,
pub conversation_id: Option<String>,
pub tool_call_id: String,
pub tool_name: String,
/// 参数摘要(截断 120 字符,完整原值仍留库)
pub arguments_brief: String,
/// 结果摘要(截断 160 字符,None → 空串便于前端展示)
pub result_brief: Option<String>,
/// pending/approved/rejected/executing/completed/failed
pub status: String,
/// low/medium/high
pub risk_level: String,
pub requested_at: String,
pub executed_at: Option<String>,
/// human/auto,None 表示尚未决策
pub decided_by: Option<String>,
}
/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji)
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
///
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
#[tauri::command]
pub async fn list_tool_executions(
state: State<'_, AppState>,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Vec<ToolExecutionDto>, String> {
let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0);
let records = state
.ai_tool_executions
.list_recent(limit, offset)
.await
.map_err(err_str)?;
Ok(records
.into_iter()
.map(|r| ToolExecutionDto {
id: r.id,
conversation_id: r.conversation_id,
tool_call_id: r.tool_call_id,
tool_name: r.tool_name,
arguments_brief: truncate_chars(&r.arguments, 120),
result_brief: r.result.map(|s| truncate_chars(&s, 160)),
status: r.status,
risk_level: r.risk_level,
requested_at: r.requested_at,
executed_at: r.executed_at,
decided_by: r.decided_by,
})
.collect())
}
/// 查项目可读标签id → "「项目名」(id=x)",查不到回退友好提示,空 id 返回空串。
///
/// AR-3审批卡片需显示对象名而非裸 id用户反馈"只返回 ID 不知道是什么数据")。
/// 查不到(项目已被彻底清除/外部 id)时给"项目已不存在"提示而非裸 id,避免用户困惑。
/// 三臂区分:
/// - Ok(Some) → 项目名标签
/// - Ok(None) → "项目已不存在"(真不存在,项目已彻底清除/外部 id
/// - Err → 裸 id 回退 + warn 日志DB 故障/锁/连接断,不误报"已不存在"误导用户)
async fn resolve_project_label(db: &Arc<Database>, id: &str) -> String {
if id.is_empty() {
return String::new();
@@ -49,7 +127,11 @@ async fn resolve_project_label(db: &Arc<Database>, id: &str) -> String {
let repo = ProjectRepo::new(db);
match repo.get_by_id(id).await {
Ok(Some(p)) => format!("{}」(id={})", p.name, id),
_ => format!("(项目已不存在, id={})", id),
Ok(None) => format!("(项目已不存在, id={})", id),
Err(e) => {
tracing::warn!("resolve_project_label: 查询项目 id={} 失败,回退裸 id: {}", id, e);
format!("(id={})", id)
}
}
}
@@ -296,6 +378,119 @@ pub(crate) fn emit_data_changed(app_handle: &AppHandle, tool_name: &str) {
}
}
/// F-260616-05高危工具去重根治 run_command 超时→重试→重新审批循环)。
///
/// **根因链**F-04 batch42 已做超时标注LLM 调 run_command 超时 → F-04 把超时标注成
/// tool_result 回传 LLM → LLM不可靠仍重试同命令 → 新 tool_call_idprovider 每轮新 id
/// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。
///
/// **治本F-05**:即使 LLM 仍重试,同 (tool_name, args) 不重复审批。本函数扫描会话历史,
/// 若发现**已落定**executed/failed/rejected非 pending的同类同参高危调用返回其
/// tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM跳过审批。
///
/// **安全边界**
/// - 仅 High risk循环源头Low/Med 不去重Low 直行无审批Med 重试场景少且去重易误伤)。
/// - 仅匹配**已落定**结果pending 的不命中——pending 已有独立流程,不会触发循环;
/// 且避免两个 pending 互相吞掉审批。LLM 只有在收到 tool_result 后才会重试,
/// 故循环必然是「上一条已落定 → 重试」形态pending 不命中不影响治本。
/// - args 走 JSON 规范化比较(键序无关),避免 LLM 两次生成键序不同误判为不同命令。
/// - 返回 None 表示无缓存命中(走原审批流程)。
///
/// `session` 只读扫描 messages不写调用方据返回值决定是否跳过 insert pending。
fn find_cached_high_risk_result(
session: &AiSession,
tool_name: &str,
args: &serde_json::Value,
) -> Option<String> {
use df_ai::provider::MessageRole;
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
let new_args_key = canonical_args_key(args);
// ContextManager::iter 返回 impl Iterator非 DoubleEndedcollect 成 Vec 再反向遍历。
// 单对话消息量小百级collect 开销可忽略。
let msgs: Vec<&ChatMessage> = session.messages.iter().collect();
// 1) 反向扫描 assistant tool_calls找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id
// 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。
let mut prev_tool_call_id: Option<String> = None;
for msg in msgs.iter().rev() {
if !matches!(msg.role, MessageRole::Assistant) {
continue;
}
let Some(tcs) = msg.tool_calls.as_ref() else { continue };
for tc in tcs {
if tc.function.name != tool_name {
continue;
}
// 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中)
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
continue;
};
if canonical_args_key(&old_args) == new_args_key {
prev_tool_call_id = Some(tc.id.clone());
break;
}
}
if prev_tool_call_id.is_some() {
break;
}
}
// 2) 用旧 tool_call_id 找对应 tool_result。注意审批拒绝/超时失败也属「已落定」,
// 其 tool_result 内容同样回传LLM 看到原反馈自行决定,不再逼用户二次审批)。
// pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。
let old_id = prev_tool_call_id?;
for msg in msgs.iter().rev() {
if !matches!(msg.role, MessageRole::Tool) {
continue;
}
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
continue;
}
// 命中旧 tool_result排除 pending 占位(内容固定为「需要用户审批,等待确认」)
if msg.content == "需要用户审批,等待确认" {
return None;
}
return Some(msg.content.clone());
}
None
}
/// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化,
/// 键序不同的等价参数生成同一 key防 LLM 两次生成键序不同误判为不同命令)。
fn canonical_args_key(args: &serde_json::Value) -> String {
let mut v = args.clone();
sort_object_keys(&mut v);
// 紧凑序列化(无空白),保证稳定可比
serde_json::to_string(&v).unwrap_or_default()
}
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
fn sort_object_keys(v: &mut serde_json::Value) {
match v {
serde_json::Value::Object(map) => {
// BTreeMap 按键排序,重建 Object
let mut entries: Vec<(String, serde_json::Value)> = map
.iter()
.map(|(k, val)| (k.clone(), val.clone()))
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
map.clear();
for (k, mut val) in entries {
sort_object_keys(&mut val);
map.insert(k, val);
}
}
serde_json::Value::Array(arr) => {
for item in arr {
sort_object_keys(item);
}
}
_ => {}
}
}
/// 处理流式接收的工具调用Low 风险并行执行join_allMed/High 进审批门控
/// 返回待审批的工具数量0 = 全部自动执行完成)
pub(crate) async fn process_tool_calls(
@@ -326,12 +521,37 @@ pub(crate) async fn process_tool_calls(
.collect();
// 分类Low 收集并行执行Med/High 立即进审批门控push 占位 tool_result
//
// F-260616-05High risk 在进审批门前先查去重缓存find_cached_high_risk_result
// 若 LLM 重试同命令(同 tool_name + 同 args键序无关命中已落定的旧 tool_result
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM跳过 insert pending + 跳过审批,
// 断「超时→重试→重新审批」循环。Med 不去重去重易误伤Low 无审批本就不进此分支。
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value)> = Vec::new();
for (_, draft, args) in drafts {
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
match risk_level {
RiskLevel::Low => low_risk.push((draft, args)),
RiskLevel::Medium | RiskLevel::High => {
// F-05仅 High 查去重缓存Med 保持原审批流程
if matches!(risk_level, RiskLevel::High) {
if let Some(cached) = find_cached_high_risk_result(session, &draft.name, &args) {
// 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批
tracing::info!(
tool = %draft.name,
new_tool_call_id = %draft.id,
"[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)"
);
session.messages.push(ChatMessage::tool_result(&draft.id, &cached));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(cached.clone()),
conversation_id: Some(conv_id.to_string()),
});
// 审计:去重命中记一条 completed(decided_by=auto_dedup),不进 pending
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level, Some(cached), Some("auto_dedup")).await;
continue;
}
}
pending_count += 1;
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
tool_call_id: draft.id.clone(),

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

View File

@@ -138,6 +138,7 @@ pub(crate) async fn save_conversation(
model: model.map(|m| m.to_string()),
models: model.map(|m| serde_json::to_string(&[m]).unwrap_or_else(|_| "[]".to_string())),
archived: false,
pinned: false,
prompt_tokens: usage.map(|u| u.prompt_tokens as i64),
completion_tokens: usage.map(|u| u.completion_tokens as i64),
created_at: created_at.unwrap_or_else(|| now.clone()),

View File

@@ -4,7 +4,7 @@
//!
//! 模块布局:
//! - [`commands`] — 所有 `#[tauri::command]` IPC 函数
//! - [`agentic`] — run_agentic_loop / try_continue_agent_loop / MAX_AGENT_ITERATIONS
//! - [`agentic`] — run_agentic_loop / try_continue_agent_loop / DEFAULT_MAX_AGENT_ITERATIONS
//! - [`stream_recv`] — stream_llm 流式接收
//! - [`conversation`] — save_conversation / TokenAccumulator / accumulate_tokens
//! - [`title`] — 对话标题生成
@@ -36,7 +36,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use df_ai::ai_tools::RiskLevel;
use df_ai::context::ContextManager;
@@ -65,6 +65,26 @@ pub use self::tool_registry::build_ai_tool_registry;
// 事件载荷类型(放 mod.rs,各子文件经 use super::* 拿到)
// ============================================================
/// AI 错误分类(B-260615-42):供前端按错误源差异化提示(如 auth 引导填 key,
/// network 提示检查连接,provider_config 引导检查 base_url/model)。
///
/// 设计:`Option<ErrorType>` 向后兼容——旧 emit 点若难精确分类填 `None`,
/// 旧前端消费方忽略未填值;新点尽量精确填并在注释标注映射理由。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ErrorType {
/// 鉴权失败:API key 缺失/无效/钥匙串损坏(resolve_provider_secret / ensure_resolved_key 失败)
Auth,
/// 网络错误:连接失败/DNS 解析失败/流中途断(SSE 传输断)
Network,
/// 超时:连接超时/idle timeout(120s 无 chunk)
Timeout,
/// Provider 配置错误:base_url/model/provider_type 错或无可用 provider(配置丢失/全删)
ProviderConfig,
/// 未分类(达最大轮次/provider 流式错误事件等难精确归类的旧点)
Unknown,
}
/// AI 聊天事件(推送到前端)
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
@@ -82,11 +102,24 @@ pub enum AiChatEvent {
/// AI 响应完成
AiCompleted { total_tokens: u32, prompt_tokens: u32, completion_tokens: u32, conversation_id: Option<String> },
/// 错误
AiError { error: String, conversation_id: Option<String> },
///
/// `error_type` 为错误源分类(B-260615-42),`None` 表示未分类(向后兼容旧 emit 点)。
AiError {
error: String,
/// 错误源分类(可选,便于前端差异化提示);旧点/难精确分类填 None
error_type: Option<ErrorType>,
conversation_id: Option<String>,
},
/// Agent 循环新一轮(前端需新建 assistant 消息)
AiAgentRound { round: u32, conversation_id: Option<String> },
/// 流式心跳(静默期报活,前端 watchdog reset区分「LLM 在跑」与「真断」)
AiHeartbeat { conversation_id: Option<String> },
/// 达最大轮次仍未收敛F-260616-03转暂停态询问用户继续/停止)
///
/// 触发时 generating 仍为 true仿审批等待前端展示独立操作卡
/// - 点继续 → ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮
/// - 点停止 → ai_stop_loop → 走完成流程save + AiCompleted + generating=false
AiMaxRoundsReached { conversation_id: Option<String> },
}
// ============================================================

View File

@@ -51,8 +51,8 @@ fn env_info_line() -> String {
format!("当前日期: {today} | 运行环境: {os}\n\n")
}
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题)
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) {
match lang {
"en" => (
"You are DevFlow's AI assistant. You help users manage projects, tasks, ideas, and workflows.\n\
@@ -70,6 +70,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
- Prefer using tools to complete actions rather than just describing steps\n\
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n",
"\n## Current Projects\n",
"\n## Current Tasks\n",
),
_ => (
"你是 DevFlow 的 AI 助手。你帮助用户管理项目、任务、灵感和工作流。\n\
@@ -87,26 +88,42 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str) {
- 优先使用工具完成操作,而不是只描述步骤\n\
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n",
"\n## 当前项目\n",
"\n## 当前任务\n",
),
}
}
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目上下文)
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务上下文)
///
/// UX-10 §1.4: 用户在输入框 @ 引用实体时插入 `[类型: 名]` 标记(项目/任务)。
/// 项目上下文已在下方注入,任务上下文本函数新增注入,使 LLM 能解析用户消息中的
/// `[项目: xxx]` / `[任务: xxx]` 标记并对齐到真实实体(名称+状态+描述)。
/// 注入克制:仅各取最近 20 条,防 context 膨胀。
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
let (prefix, ctx_label) = system_prompt_parts(lang);
let (prefix, proj_label, task_label) = system_prompt_parts(lang);
let mut prompt = env_info_line();
prompt.push_str(prefix);
// 附加当前数据上下文
if let Ok(projects) = state.projects.list_active().await {
if !projects.is_empty() {
prompt.push_str(ctx_label);
prompt.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
for p in projects.iter().take(20) {
prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status, p.description));
}
}
}
// UX-10 §1.4: 任务上下文(供解析用户消息中 [任务: xxx] 标记)
// 仅最近 20 条未删除任务,按 created_at DESC(同 list_active 顺序)。
if let Ok(tasks) = state.tasks.list_active().await {
if !tasks.is_empty() {
prompt.push_str(task_label);
for tk in tasks.iter().take(20) {
prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status, tk.description));
}
}
}
prompt
}

View File

@@ -10,7 +10,7 @@ use tracing::warn;
use df_ai::provider::{CompletionRequest, LlmProvider};
use super::{AiChatEvent, ToolCallDraft};
use super::{AiChatEvent, ErrorType, ToolCallDraft};
/// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。
///
@@ -159,6 +159,8 @@ pub(crate) async fn stream_llm(
Err(_elapsed) => {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应超时120 秒无数据,连接可能已断开)".to_string(),
// 120s 无 chunk = idle timeout,归 Timeout
error_type: Some(ErrorType::Timeout),
conversation_id: Some(conv_id.to_string()),
});
return None;
@@ -200,6 +202,9 @@ pub(crate) async fn stream_llm(
"stream-error",
err_msg,
),
// provider SSE error 事件体可为 401/overloaded 等多种,
// 从文本分类不可靠,归 Unknown(前端可据 error 文本二次判断)
error_type: Some(ErrorType::Unknown),
conversation_id: Some(conv_id.to_string()),
});
return None;
@@ -226,6 +231,10 @@ pub(crate) async fn stream_llm(
&status_or_class,
&raw,
),
// 流已建立后 next() 返 Err = SSE 传输断/解析错,归 Network
// (extract_error_diag 已抠 status_or_class,但混合源难统一归 auth/timeout,
// 主流为传输断,前端可据 error 文本二次判断 HTTP 4xx 等)
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return None;
@@ -265,6 +274,8 @@ pub(crate) async fn stream_llm(
if !finished_received {
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
// 流尽但未收到 finished = 连接异常中断,归 Network
error_type: Some(ErrorType::Network),
conversation_id: Some(conv_id.to_string()),
});
return None;
@@ -290,6 +301,11 @@ pub(crate) async fn stream_llm(
&status_or_class,
&raw,
),
// 建连失败混合源:401(auth)/404(provider_config)/connect(network)/timeout/unknown,
// extract_error_diag 已抠 status_or_class 供前端 error 文本展示,但运行时文本分类
// 归一 error_type 不可靠(同 raw 跨多类型),按任务约定难精确分类的旧点填 None,
// 前端可据 error 文本中的 "HTTP 401" 等自行二次判断。
error_type: None,
conversation_id: Some(conv_id.to_string()),
});
None

View File

@@ -48,6 +48,7 @@ pub(crate) async fn ensure_conversation_title(
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
})
.collect();
(summary, session.messages.all_messages_clone())

View File

@@ -19,6 +19,10 @@ use crate::commands::now_millis;
/// 用于 list_projects / list_tasks / list_ideas / list_trash
const MAX_LIST_RESULTS: usize = 50;
/// run_command 默认超时。LLM 可在 args timeout_secs 覆盖此默认值。
/// 提取为常量便于在超时标注处引用同一来源F-260616-04
const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
/// 生成行级 unified diff无外部依赖基于 LCS
/// 仅标 +/- 前缀,不做 hunk header足够审批卡/审计留痕可读)。
/// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。
@@ -487,8 +491,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
}
None => workspace_root().to_string_lossy().to_string(),
};
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可覆盖
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(60);
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS);
let request = ShellRequest {
command: command.to_string(),
@@ -497,7 +501,23 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
timeout_secs: Some(timeout_secs),
shell_type: Default::default(),
};
let result = execute(request).await?;
// F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
let result = execute(request).await.map_err(|e| {
let msg = e.to_string();
if msg.contains("命令执行超时") {
anyhow::anyhow!(
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。",
timeout_secs
)
} else {
e
}
})?;
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
const MAX_OUT: usize = 10_000;

View File

@@ -68,18 +68,22 @@ fn validate_transition(from: &str, to: &str) -> Result<(), String> {
// CRUD
// ============================================================
/// 列出知识 — 可按 status 筛选,status=None 时默认排除 archived
/// 列出知识 — 可按 status 筛选,status=None 时默认仅返回 published
///
/// 默认范围说明(F-260616-02 决策 a):
/// - status=None → library tab 的数据源,语义为「已发布知识库」,仅 published。
/// 不再混杂 pending_review(pending_review 归 inbox 收件箱,见 knowledge_list_candidates)。
/// - 显式传 status 时按该 status 过滤(含 archived)。
#[tauri::command]
pub async fn knowledge_list(
state: State<'_, AppState>,
status: Option<String>,
) -> Result<Vec<KnowledgeRecord>, String> {
match status {
// 显式查 archived 时原样返回(含归档项)
Some(s) if s == "archived" => state.knowledge.list_by_status("archived").await.map_err(err_str),
// 显式传 status 时按该 status 过滤(含 archived)
Some(s) => state.knowledge.list_by_status(&s).await.map_err(err_str),
// 默认:列出非 archived 的全部(单查询 status != 'archived')
None => state.knowledge.list_non_archived().await.map_err(err_str),
// 默认:library 仅 published(F-260616-02 决策 a,职责清晰:library=published,inbox=待处理)
None => state.knowledge.list_by_status("published").await.map_err(err_str),
}
}
@@ -208,16 +212,64 @@ pub async fn knowledge_record_reuse(
.map_err(err_str)
}
/// 审核收件箱 — 列出 candidate(按 confidence 语义排序)
/// 收件箱 — 列出待处理条目(candidate + pending_review),按 confidence 语义排序
///
/// 语义(F-260616-02 决策 a):inbox = 「待处理」收件箱,聚合 candidate(待评估)
/// 与 pending_review(待发布审核)两种待处理状态。library 仅 published。
///
/// 实现:list_by_status 单状态查询,这里合并 candidate 与 pending_review 两路结果。
/// 两路各自已按 `confidence DESC, created_at DESC` 排序,有序合并保持同一规则。
#[tauri::command]
pub async fn knowledge_list_candidates(
state: State<'_, AppState>,
) -> Result<Vec<KnowledgeRecord>, String> {
state
.knowledge
.list_by_status("candidate")
.await
.map_err(err_str)
let (candidates, pending) = tokio::try_join!(
state.knowledge.list_by_status("candidate"),
state.knowledge.list_by_status("pending_review"),
)
.map_err(err_str)?;
Ok(merge_by_confidence(candidates, pending))
}
/// 有序合并两列(各自已按 confidence DESC, created_at DESC 排序),结果保持同序。
///
/// confidence 排序权重:high=3, medium=2, low=1, 其他=0;同权重按 created_at DESC
/// (字符串毫秒时间戳字典序 = 时间序)。等价 SQL `ORDER BY CASE confidence ... DESC, created_at DESC`。
fn merge_by_confidence(
mut a: Vec<KnowledgeRecord>,
mut b: Vec<KnowledgeRecord>,
) -> Vec<KnowledgeRecord> {
use std::cmp::Ordering;
fn rank(c: &str) -> i8 {
match c {
"high" => 3,
"medium" => 2,
"low" => 1,
_ => 0,
}
}
let cmp = |x: &KnowledgeRecord, y: &KnowledgeRecord| -> Ordering {
let rx = rank(x.confidence.as_deref().unwrap_or(""));
let ry = rank(y.confidence.as_deref().unwrap_or(""));
ry.cmp(&rx) // confidence DESC
.then_with(|| y.created_at.cmp(&x.created_at)) // created_at DESC
};
a.sort_by(cmp);
b.sort_by(cmp);
let mut out = Vec::with_capacity(a.len() + b.len());
let (mut i, mut j) = (0, 0);
while i < a.len() && j < b.len() {
if cmp(&a[i], &b[j]) != Ordering::Greater {
out.push(a[i].clone());
i += 1;
} else {
out.push(b[j].clone());
j += 1;
}
}
out.extend_from_slice(&a[i..]);
out.extend_from_slice(&b[j..]);
out
}
/// 归档(软删除) — UPDATE status='archived'

View File

@@ -8,8 +8,11 @@ use tauri::{AppHandle, Emitter, State};
use tokio::sync::broadcast::error::RecvError;
use df_core::events::{WorkflowEvent, HumanApprovalResponse, SelectType};
use df_core::types::new_id;
use df_storage::crud::WorkflowRepo;
use df_core::types::{new_id, NodeStatus};
use df_nodes::task_advance_node::advance_task_atomic;
// F-260616-06 ①-1: 空 dag + target_status 时按目标态自动选推进链模板
use df_nodes::task_workflow_templates::template_for;
use df_storage::crud::{TaskRepo, WorkflowRepo};
use df_storage::models::WorkflowRecord;
use df_workflow::dag_def::DagDef;
use df_workflow::executor::DagExecutor;
@@ -27,11 +30,36 @@ struct WorkflowEventPayload {
event: WorkflowEvent,
}
/// F-260616-06 ②-4: 工作流失败时按 target_status 推算任务退回态。
///
/// 映射表(失败退一步):
/// - testing → in_review
/// - in_review → in_progress
/// - in_progress → todo
/// - 其他(done/blocked/cancelled/todo 等) → None(无退回映射,跳过回调)
///
/// 设计选择:in_progress → todo(而非 None)。理由:in_progress 是执行态,失败退回 todo
/// 符合"执行未达预期 → 回起点重排"的直觉语义;若选 None 会丢失"执行失败"信号。
/// 起点状态 todo 无可退态 → None。
fn regression_target(target: &str) -> Option<&str> {
match target {
"testing" => Some("in_review"),
"in_review" => Some("in_progress"),
"in_progress" => Some("todo"),
_ => None,
}
}
/// 触发工作流执行(核心命令)
///
/// 流程build_dag 校验 → 写入执行记录(status=running) → 后台异步执行 →
/// 事件经 EventBus 转发到前端 → 完成后更新执行记录状态。
/// 立即返回执行记录 ID前端凭此关联后续事件。
///
/// F-260616-06 阶段2 工作流联动(②-2):
/// - `task_id` / `target_status` 同时 Some 时,完成后按 target_status 推进任务(②-3),
/// 失败时按 regression_target 退回(②-4)。
/// - Option 向后兼容:旧调用方不传即 None,不触发任何任务回调,零行为破坏。
#[tauri::command]
pub async fn run_workflow(
app: AppHandle,
@@ -39,7 +67,27 @@ pub async fn run_workflow(
name: String,
dag: DagDef,
config: serde_json::Value,
// F-260616-06 ②-2: 工作流联动任务的关联 ID 与目标态(同时 Some 才联动)
task_id: Option<String>,
target_status: Option<String>,
) -> Result<String, String> {
// 0. F-260616-06 ①-1: DagDef 来源选模板(方案A·最小)。
// 规则:
// - dag.nodes 非空 → 用传入 dag(向后兼容,旧调用方/编辑器自定义工作流不变)
// - dag.nodes 为空 + target_status Some → 调 template_for 自动选推进链模板;
// 模板不存在(target 非三合法态) → Err「无对应工作流模板」
// - dag.nodes 为空 + target_status None → 原行为(空 dag 让 build_dag 自行报错,零新分支)
// 前端选 target_status 后传空 dag,零模板知识零新 IPC,后端收敛选模板逻辑。
let dag = if dag.nodes.is_empty() {
match target_status.as_deref() {
Some(target) => template_for(target)
.ok_or_else(|| format!("无对应工作流模板: target_status={}", target))?,
None => dag, // 空dag+无target: 走原路径由 build_dag 校验
}
} else {
dag
};
// 1. 先构建运行时 DAG校验失败直接返回不落库
let runtime_dag = state.registry.build_dag(&dag).map_err(err_str)?;
@@ -53,7 +101,7 @@ pub async fn run_workflow(
status: "running".to_string(),
triggered_by: Some("manual".to_string()),
project_id: None,
task_id: None,
task_id: task_id.clone(),
created_at: now_millis(),
completed_at: None,
};
@@ -180,6 +228,9 @@ pub async fn run_workflow(
let db = state.db.clone();
let exec_id = execution_id.clone();
let state_registry = state.workflow_state_registry.clone();
// F-260616-06 ②-2: move task_id / target_status 进闭包供完成/失败回调使用
let cb_task_id = task_id.clone();
let cb_target_status = target_status.clone();
tauri::async_runtime::spawn(async move {
let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone());
// 注册执行器状态机StateMachine 内部 Arc<Mutex>clone 共享底层 HashMap
@@ -207,13 +258,58 @@ pub async fn run_workflow(
tracing::error!("更新工作流完成时间失败: {}", e);
}
// F-260616-06 ②-3 / ②-4: 工作流联动任务推进回调
// - 成功(completed): task_id 与 target_status 都 Some 时推进到 target_status
// - 失败(failed): 按 regression_target 推算退回态推进;无映射则跳过
// task_id / target_status 任一 None → 跳过(向后兼容,旧工作流不联动任务)
// 回调失败仅 tracing::warn!,不回滚(工作流成功语义与任务推进解耦 — 回调失败不撤销
// 已完成工作流,前端可后续手动处理)
match (cb_task_id.as_ref(), cb_target_status.as_deref()) {
(Some(tid), Some(target)) => {
let advance_target = match status {
"completed" => Some(target),
"failed" => regression_target(target),
_ => None,
};
if let Some(to) = advance_target {
let repo = TaskRepo::new(&db);
if let Err(e) = advance_task_atomic(&repo, tid, to).await {
tracing::warn!(
execution_id = %exec_id,
task_id = %tid,
target_status = %to,
workflow_status = %status,
"工作流联动推进任务失败(不回滚): {}",
e
);
} else {
tracing::info!(
execution_id = %exec_id,
task_id = %tid,
target_status = %to,
workflow_status = %status,
"工作流联动推进任务成功"
);
}
} else {
// failed 且无退回映射:warn 跳过(常见:target 是 todo 起点无可退态)
tracing::warn!(
execution_id = %exec_id,
task_id = %tid,
target_status = %target,
"工作流失败但 target_status 无退回映射,跳过任务回调"
);
}
}
_ => {}
}
// 执行结束(成功/失败)清理状态注册表,防内存泄漏
state_registry.lock().await.remove(&exec_id);
// 执行失败时补发 WorkflowFailed执行器内部只发 NodeFailed
// failed_node 从状态机取首个失败/取消节点(R9⑪: 原 String::new() 空值)
if let Some(error) = error {
use df_core::types::NodeStatus;
let failed_node = executor
.state_machine()
.snapshot()
@@ -337,6 +433,11 @@ pub async fn approve_human_approval(
/// 从执行器状态机注册表取出共享引用,调 `set_cancelled` 置目标节点为 Cancelled。
/// StateMachine 内部 Arc<Mutex> 共享底层 HashMapIPC 写入直达运行中阻塞节点HumanNode
/// 的 select! 轮询分支,其 is_cancelled 检测到后返回 Err "人工审批被取消"。
///
/// 终态前置守卫:仅 Pending排队中/ Running执行中含阻塞等审批允许取消
/// 终态节点Completed/Failed/Skipped/Cancelled返 Err避免静默覆盖终态。
/// 守卫放 IPC 层(非 set_cancelled 内):保留 set_cancelled 作为 executor 内部受控旁路语义,
/// 让状态变更的合法入口收敛到 IPC 这一道。
#[tauri::command]
pub async fn cancel_workflow_node(
state: State<'_, AppState>,
@@ -349,11 +450,25 @@ pub async fn cancel_workflow_node(
Some(sm) => sm,
None => return Err(format!("工作流 {} 不存在或已结束", execution_id)),
};
sm.set_cancelled(node_id.clone());
tracing::info!(
"工作流节点取消execution_id={}, node_id={}",
execution_id,
node_id
);
Ok(())
// 终态守卫get() 返回缺失条目默认 Pending尚未执行→ 允许取消
let current = sm.get(&node_id);
match current {
NodeStatus::Pending | NodeStatus::Running | NodeStatus::Waiting => {
sm.set_cancelled(node_id.clone());
tracing::info!(
"工作流节点取消execution_id={}, node_id={}",
execution_id,
node_id
);
Ok(())
}
// 终态:不允许取消(避免静默覆盖终态)
NodeStatus::Completed | NodeStatus::Failed | NodeStatus::Skipped | NodeStatus::Cancelled => {
Err(format!(
"节点 {} 已终态({}),无法取消",
node_id,
current.as_str()
))
}
}
}

View File

@@ -104,9 +104,14 @@ pub fn run() {
commands::workflow::cancel_workflow_node,
// AI 聊天
commands::ai::ai_chat_send,
commands::ai::ai_regenerate,
commands::ai::ai_chat_edit,
commands::ai::ai_chat_force_send,
commands::ai::ai_chat_stop,
commands::ai::ai_approve,
// F-260616-03达 max_iterations 暂停态续/停(消费 AiMaxRoundsReached前端操作卡留 batch45
commands::ai::ai_continue_loop,
commands::ai::ai_stop_loop,
commands::ai::ai_pending_tool_calls,
commands::ai::ai_chat_clear,
commands::ai::ai_is_generating,
@@ -121,8 +126,13 @@ pub fn run() {
commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename,
commands::ai::ai_conversation_archive,
commands::ai::ai_conversation_set_pinned,
commands::ai::ai_conversation_export,
commands::ai::ai_list_skills,
commands::ai::ai_set_concurrency_config,
commands::ai::ai_set_agent_max_iterations,
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
commands::ai::audit::list_tool_executions,
// 知识库
commands::knowledge::knowledge_list,
commands::knowledge::knowledge_get,

View File

@@ -3,6 +3,7 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use anyhow::Result;
use serde::{Deserialize, Serialize};
@@ -174,6 +175,11 @@ pub struct AppState {
// ── LLM 并发控制 ──
/// LLM 调用并发上限(全局 + 单对话双层 Semaphore运行时可调
pub llm_concurrency: LlmConcurrency,
// ── Agentic 循环轮次上限 ──
/// Agentic 循环最大轮次(前端 Settings 数字配置 → AppState 字段 → 热改 command →
/// loop 入口 load 快照透传形参;当前 loop 锁定边界,热改下次发消息生效)。
/// 默认 10与 agentic.rs::DEFAULT_MAX_AGENT_ITERATIONS 对齐。
pub agent_max_iterations: Arc<AtomicUsize>,
// ── 工作流执行状态 ──
/// 工作流执行 → 节点状态机注册表
///
@@ -189,6 +195,9 @@ impl AppState {
pub async fn init(db_path: &Path) -> Result<Self> {
let db = Arc::new(Database::open(db_path).await?);
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db));
// build_registry 需注入 Arc<Database>(TaskAdvanceNode 持 db)。
// 在 struct 字段 `db` move 前 clone,避免 E0382。
let registry = Arc::new(build_registry(db.clone()));
let state = Self {
ideas: IdeaRepo::new(&db),
projects: ProjectRepo::new(&db),
@@ -205,10 +214,13 @@ impl AppState {
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),
settings: SettingsRepo::new(&db),
llm_concurrency: LlmConcurrency::new(3, 2),
agent_max_iterations: Arc::new(AtomicUsize::new(
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
)),
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
db,
event_bus: EventBus::new(),
registry: Arc::new(build_registry()),
registry,
ai_tools,
};
// 启动恢复:重启前卡 pending 的工具审批(内存 pending_approvals 已丢)从审计表重建,
@@ -229,7 +241,7 @@ impl AppState {
/// 需求。需要脚本执行能力时新建独立 BuildNode白名单 + 项目目录锚定 + 复用 AI 工具
/// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。
/// 详见 docs/02-架构设计/工作流脚本执行边界-2026-06-15.md。
fn build_registry() -> NodeRegistry {
fn build_registry(db: Arc<Database>) -> NodeRegistry {
let mut registry = NodeRegistry::new();
registry.register("human", |_config| {
Box::new(df_nodes::human_node::HumanNode)
@@ -237,12 +249,14 @@ fn build_registry() -> NodeRegistry {
registry.register("ai", |_config| {
Box::new(df_nodes::ai_node::AiNode)
});
// 未在此注册的已实现节点:
// - TaskAdvanceNode(df_nodes::task_advance_node, impl Node trait 已就绪):
// 推进链 F-01~04 当前手动推进(IPC 直驱 advance_task),DAG 形态为阶段 2
// 工作流联动(run_workflow task_id + 完成回调 advance_task + DAG 模板)预留
// 届时在此 register("task_advance", ...) 注入 Arc<Database>
// 非遗漏,勿删 task_advance_node.rs。
// TaskAdvanceNode(df_nodes::task_advance_node, impl Node trait):
// 推进链阶段 2 工作流联动入口 — DAG 内触发 advance_task。
// 持有 Arc<Database> 在此构造时注入(NodeRegistry::register 工厂闭包 move 捕获 db,
// Arc clone 廉价),Node::execute 从 NodeContext.config 读 task_id/target_status
// D-260616-03: 推进链/状态机/闸门走 df-nodes Node trait,非复活 df-task
registry.register("task_advance", move |_config| {
Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone()))
});
registry
}