新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 审批历史查询 IPC(AE-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_id(provider 每轮新 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(非 DoubleEnded),collect 成 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_all),Med/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-05:High 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(),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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> },
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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> 共享底层 HashMap,IPC 写入直达运行中阻塞节点(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()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user