From dedb9a88624bf4cf5a3e1c1c8d655194d0b9ad1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 10 Aug 2026 08:04:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E6=8E=A8=E8=BF=9B=E9=93=BE=E8=B7=AF=E4=B8=8E=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=85=A5=E5=8F=A3=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/df-nodes/src/ai_node_helpers.rs | 63 +++- src-tauri/src/commands/ai/agentic/mod.rs | 369 +++++++++++------------ src-tauri/src/commands/workflow.rs | 10 + 3 files changed, 244 insertions(+), 198 deletions(-) diff --git a/crates/df-nodes/src/ai_node_helpers.rs b/crates/df-nodes/src/ai_node_helpers.rs index b980c8b..7f37bf7 100644 --- a/crates/df-nodes/src/ai_node_helpers.rs +++ b/crates/df-nodes/src/ai_node_helpers.rs @@ -13,7 +13,7 @@ use df_ai::provider::LlmProvider; // AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由 // (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。 use df_ai::router::{select_model_id, TaskRequirements}; -use df_storage::crud::AiProviderRepo; +use df_storage::crud::{AiProviderRepo, TaskRepo}; use df_storage::db::Database; use df_storage::models::AiProviderRecord; use df_storage::secret::{ensure_resolved_key, resolve_provider_secret}; @@ -72,7 +72,66 @@ pub(crate) async fn resolve_and_parse( inputs: &HashMap, ) -> anyhow::Result { let provider_cfg = resolve_provider(db, config).await?; - parse_params(config, inputs, provider_cfg) + // P0-B: parse_params 缺 prompt 报错时兜底 —— 工作流推进链模板 AiNode config 无 prompt + // (in_progress/testing/done 三链首节点 AiNode 仅带 provider_id, prompt 留空), + // 原行为直接 Err 致整链必失败。兜底:从 config.task_id 读 TaskRecord 生成基于 + // title/description 的执行 prompt;无 task_id 用通用默认。不改 LLM 调用结构, + // 仅 parse_params 失败时补 prompt 重解析(provider_cfg 已 resolve 不重复)。 + // 实现:parse_params 按值吃 provider_cfg,失败需重建 → 先 clone 一份供兜底重解析用, + // 避免 ResolvedProvider(api_key 等)无法从 Err 取回(原签名为 by-value 消费)。 + match parse_params(config, inputs, provider_cfg.clone()) { + Ok(p) => Ok(p), + Err(e) => { + let prompt = resolve_fallback_prompt(db, config) + .await + .unwrap_or_else(|| default_fallback_prompt()); + // 把兜底 prompt 注入 config 后重解析(prompt 字段补齐后 parse_params 不再缺 prompt)。 + // 用 mutable config copy 避免改原 config 借用(config: &serde_json::Value 不可变借)。 + let mut cfg = config.clone(); + if let Some(obj) = cfg.as_object_mut() { + obj.insert("prompt".to_string(), serde_json::Value::String(prompt)); + } else { + // config 非 object(理论不会,防御):构造 {prompt: ...} 替代。 + cfg = serde_json::json!({ "prompt": prompt }); + } + // tracing 兜底可见性(用户/排查:看到 AiNode 用了默认 prompt 而非报错)。 + tracing::warn!( + error = %e, + "AiNode 缺 prompt 兜底: 从 task_id/通用默认生成执行 prompt" + ); + parse_params(&cfg, inputs, provider_cfg) + } + } +} + +/// P0-B 兜底 prompt 构建:从 config.task_id 读 TaskRecord.title/description 生成执行 prompt。 +/// +/// 无 task_id / 查询失败 / task 不存在 → None,调用方降级到 `default_fallback_prompt`。 +/// description 空只取 title;非空拼「标题(描述)」。truncate 防 token 滥用。 +async fn resolve_fallback_prompt(db: &Arc, config: &serde_json::Value) -> Option { + let task_id = config.get("task_id").and_then(|v| v.as_str())?; + if task_id.is_empty() { + return None; + } + let repo = TaskRepo::new(db); + let task = repo.get_by_id(task_id).await.ok()??; + let title = task.title.trim(); + let desc = task.description.trim(); + let body = if desc.is_empty() { + title.to_string() + } else { + format!("{}({})", title, truncate_for_summary(desc)) + }; + Some(format!( + "根据任务「{}」完成开发,产出可执行代码/方案并自检。要求:逐步推理、\ + 关键决策给出依据、产出可直接落地。", + body + )) +} + +/// P0-B 通用兜底 prompt:无 task_id 时用(独立 AiNode 调用 / config 无 task 关联)。 +fn default_fallback_prompt() -> String { + "请根据上下文完成当前任务,产出可执行结果并自检。若需更多信息请说明。".to_string() } /// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。 diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index bc9e107..0b0e0dc 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -1021,146 +1021,14 @@ pub(crate) async fn run_agentic_loop( // 不再从用户消息规则提取,也不依赖 LLM 输出结构化标记。 // 推理结果存入 pinned_goals: Vec,含 text+status 状态追踪。 // 新目标加入时自动标记之前的 active 为 completed。 - let mut system_prompt = system_prompt; - if GOAL_PIN_ENABLED { - let goals: Vec = { - let session = session_arc.lock().await; - session - .conv_read(&conv_id) - .map(|c| c.pinned_goals.clone()) - .unwrap_or_default() - }; - // 只把 active 目标注入 system_prompt(completed 不干扰 LLM 注意力) - let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect(); - if !active_goals.is_empty() { - let goal_lines: Vec = active_goals.iter().enumerate().map(|(i, g)| { - let trimmed = g.text.trim(); - let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect(); - let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS { - format!("{}…", truncated) - } else { - truncated - }; - format!("{}. {}", i + 1, truncated) - }).collect(); - let goals_text = goal_lines.join("\n"); - system_prompt = if GOAL_INJECT_BANNER { - format!( - "{}\n\n## 当前目标(全程锚定,所有动作须服务于它们)\n{}", - system_prompt, goals_text - ) - } else { - format!("{}\n\n{}", system_prompt, goals_text) - }; - tracing::info!( - conv_id = %conv_id, - count = goals.len(), - first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""), - "[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt", - goals.len() - ); - } - } - - // T5: WorkingContext L1 注入(L1a 常驻 + L1b 条件注入) - // 读取工作上下文,构建 L1 注入文本并拼入 system_prompt - { - let session = session_arc.lock().await; - if let Some(conv) = session.conv_read(&conv_id) { - let injection = conv.working_context.build_injection(3); // dirty_window=3 - if !injection.is_empty() { - system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, injection); - } - } - } - - // EnvSnapshot 环境感知:入口把当前平台的真实环境(OS/shell/工具版本)拼进 system_prompt 尾部。 // - // 治「LLM 跨平台命令幻觉」:LLM 训练数据 Unix 多,易生成 macOS/Linux 语法命令(PowerShell 5 - // 不支持 `&&`、Windows 路径分隔符 `\`、GBK 终端中文乱码),把真实环境塞 prompt 即可锚定 - // 输出平台一致性。detect() 是 OnceLock 全局缓存(启动时探一次,后续零开销),与 G1 一样 - // 是 loop 不变量(整个会话不重探),与目标钉扎拼接次序无强约束(放其后,语义自然)。 - let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt(); - let behavior_prompt = concat!( - "\n## AI 定位\n", - "你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,", - "所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。", - "**绝不输出请在终端执行以下命令这类指令——你自己用 run_command 工具执行即可。**\n", - "\n## 行为准则\n", - "- 所有操作都通过工具完成,用户不参与执行\n", - "- 优先使用开发工具 IPC,非必要不写独立脚本\n", - "- 脚本需要审批通过才执行,会拖慢工作流\n", - "- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n", - "- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具\n", - "- **直接回答用户问题,不要以评论开头**——严禁用好问题等无信息量的开场白,直接输出答案或执行操作\n", - "- **及时收敛**:工具执行完目标达成后立即输出结果,不要继续调工具做无关操作", - ); - system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt); - - // 技能清单注入:把本机扫描到的技能(名称+描述)追加到 system_prompt,让 LLM 感知可用技能。 - // - // 治「AI 不知道有技能可引导」:skills.rs 联想已做(前端 / 浮层),但 AI 本身对技能无感知, - // 用户需求命中某技能(如"发布flux")时 AI 不会引导。机制化——只注入清单(不执行技能逻辑, - // 执行仍由用户在 Claude Code 侧 / 技能名 触发),LLM 据此提示用户可用技能。 - // 清单来自 skills_cached() 进程内缓存(懒扫描一次,后续零开销);截断条数+描述防占预算。 - if SKILL_LIST_INJECT_ENABLED { - let skills = crate::commands::ai::skills::skills_cached().await; - if !skills.is_empty() { - let lines: Vec = skills - .iter() - .take(SKILL_LIST_MAX_ITEMS) - .map(|s| { - let desc_flat = s.description.replace('\n', " "); - let desc: String = desc_flat.chars().take(SKILL_DESC_MAX_CHARS).collect(); - let desc = if desc_flat.chars().count() > SKILL_DESC_MAX_CHARS { - format!("{}…", desc) - } else { - desc - }; - format!("- {}: {}", s.name, desc) - }) - .collect(); - if !lines.is_empty() { - system_prompt = format!( - "{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述)。用户请求命中某技能用途时,提示用户输入 /技能名 触发,或引导其按需使用:\n{}", - system_prompt, - lines.join("\n") - ); - tracing::debug!( - conv_id = %conv_id, - count = lines.len(), - "[ai] 技能清单注入:已追加 {} 条技能到 system_prompt", - lines.len() - ); - } - } - } - - // T4: 工作流 DAG 注入 — 当会话关联工作流时,将活跃路径注入 system prompt - { - let session = session_arc.lock().await; - if let Some(conv) = session.conv_read(&conv_id) { - if let Some(ref dag_summary) = conv.workflow_dag_summary { - system_prompt = format!("{}[工作流]\n{}\n", system_prompt, dag_summary); - tracing::debug!( - conv_id = %conv_id, - workflow_id = ?conv.workflow_id, - "[ai] T4: 已注入工作流 DAG 上下文" - ); - } - } - } - - // ── 多 Agent 并行执行:Coordinator 分解(plan_execution_enabled 时) ── - // 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用) - // 只取 text(前端不需要状态信息) - let pinned_goals_snapshot: Vec = { - let session = session_arc.lock().await; - session - .conv_read(&conv_id) - .map(|c| c.pinned_goals.clone()) - .unwrap_or_default() - }; + // 入口 system_prompt 五段拼接(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / + // T4 工作流 DAG)收敛至 build_runtime_prompt helper:一次锁读 per_conv 三字段 + // (pinned_goals / working_context / workflow_dag_summary)消除原 G1/T5/T4 三处独立 lock, + // 且返回 pinned_goals 复用作 AiCompleted 快照(消除原 L1157 二次读)。拼接顺序+内容逐字节等价。 + let (system_prompt, pinned_goals_snapshot) = build_runtime_prompt( + &session_arc, &conv_id, system_prompt, "", + ).await; // ── Plan-driven Phase 1:LLM 规划端(AICHAT_PLAN_ENABLED 门控) ── // @@ -1312,14 +1180,7 @@ pub(crate) async fn run_agentic_loop( // 落库 + generating 复位 + emit AiCompleted(统一走 finish_round_exit,行为零变更) // Coordinator 路径:save(None,None) + 不 spawn_title + emit(None,None,publish=true) // emit_usage=tokens 快照(本路径无 round, tokens 为空, total=0 对齐原 tokens.total()) - let coord_usage = df_ai::provider::TokenUsage { - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - total_tokens: tokens.total(), - prompt_cache_hit_tokens: tokens.cache_hit(), - prompt_cache_miss_tokens: tokens.cache_miss(), - reasoning_tokens: tokens.reasoning(), - }; + let coord_usage = tokens_snapshot(&tokens); finish_round_exit( &session_arc, &db, &conv_id, None, None, @@ -1364,14 +1225,7 @@ pub(crate) async fn run_agentic_loop( if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { // save_usage 增量(自上次 save 后新增,首轮即停为 0);emit_usage 累计快照 let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot); - let emit_usage = df_ai::provider::TokenUsage { - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - total_tokens: tokens.total(), - prompt_cache_hit_tokens: tokens.cache_hit(), - prompt_cache_miss_tokens: tokens.cache_miss(), - reasoning_tokens: tokens.reasoning(), - }; + let emit_usage = tokens_snapshot(&tokens); // 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组 // 统一走 finish_round_exit:save(Some usage, None model) + spawn_title + emit(None,None,publish=true) // (stale 时 finish_round_exit 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop) @@ -1884,14 +1738,7 @@ pub(crate) async fn run_agentic_loop( // MidStream 分叉:emit_incomplete=Some(true)(前端标不完整),publish_incomplete=None(总线消费方), // do_publish=true(publish 走总线)。spawn_title=true(后台标题,失败 extract 兜底)。 let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot); - let emit_usage = df_ai::provider::TokenUsage { - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - total_tokens: tokens.total(), - prompt_cache_hit_tokens: tokens.cache_hit(), - prompt_cache_miss_tokens: tokens.cache_miss(), - reasoning_tokens: tokens.reasoning(), - }; + let emit_usage = tokens_snapshot(&tokens); finish_round_exit( &session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), @@ -1968,14 +1815,7 @@ pub(crate) async fn run_agentic_loop( if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { // save_usage 增量(自上次 save 后新增);emit_usage 累计快照(前端展示本 loop 总量) let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot); - let emit_usage = df_ai::provider::TokenUsage { - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - total_tokens: tokens.total(), - prompt_cache_hit_tokens: tokens.cache_hit(), - prompt_cache_miss_tokens: tokens.cache_miss(), - reasoning_tokens: tokens.reasoning(), - }; + let emit_usage = tokens_snapshot(&tokens); // 统一走 finish_round_exit:save(Some usage, Some model) + spawn_title + emit(None,None,publish=true) finish_round_exit( &session_arc, &db, &conv_id, @@ -2094,14 +1934,7 @@ pub(crate) async fn run_agentic_loop( ); // save_usage 增量;emit_usage 累计快照(前端展示本 loop 总量) let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot); - let emit_usage = df_ai::provider::TokenUsage { - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - total_tokens: tokens.total(), - prompt_cache_hit_tokens: tokens.cache_hit(), - prompt_cache_miss_tokens: tokens.cache_miss(), - reasoning_tokens: tokens.reasoning(), - }; + let emit_usage = tokens_snapshot(&tokens); // 统一走 finish_round_exit:save(Some usage, Some model) + 不 spawn_title(对齐原无 title) + // emit(Some(true), publish=false)。**do_publish=false 保留原 max_iterations 不 publish 行为** // (与其他 5 路径不一致是历史现状,本次仅收敛重复代码不改 publish 策略,语义零变更)。 @@ -2131,7 +1964,6 @@ pub(crate) async fn run_agentic_loop( // save_usage 增量(自上次 save 后新增,正常收敛时上轮 save 已落,此处通常为 0); // emit_usage 用 tokens 累计快照。normal_usage.total 直接取 tokens.total() 保持三字段一致。 let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot); - let usage_total = tokens.total(); { let session_arc = session_arc.clone(); let db = db.clone(); @@ -2161,14 +1993,7 @@ pub(crate) async fn run_agentic_loop( // generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成 // (正常完成路径 save+extract+title 已在上方 spawn 异步,此处仅 emit,故直接调 emit_ai_completed_once) // emit_usage=tokens 快照(prompt/completion/total 全从 tokens 取,对齐原 usage_total=tokens.total()) - let normal_usage = df_ai::provider::TokenUsage { - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - total_tokens: usage_total, - prompt_cache_hit_tokens: tokens.cache_hit(), - prompt_cache_miss_tokens: tokens.cache_miss(), - reasoning_tokens: tokens.reasoning(), - }; + let normal_usage = tokens_snapshot(&tokens); // F1 并发 epoch:emit 前最后一次 stale 检查(guard.reset 已幂等,此处防 emit 重复 AiCompleted)。 if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { return; @@ -2704,6 +2529,165 @@ async fn emit_ai_completed_once( } } +// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 五段拼接 ── +// +// 抽自原 L1024-1152(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / T4 工作流 DAG)。 +// **行为逐字节等价**:拼接顺序与内容不变,开关(GOAL_PIN_ENABLED / SKILL_LIST_INJECT_ENABLED / +// GOAL_INJECT_BANNER)与截断(GOAL_MAX_CHARS / SKILL_LIST_MAX_ITEMS / SKILL_DESC_MAX_CHARS)常量原样引用。 +// +// **关键重构(消除 pinned_goals 二次读)**:原代码 G1/T5/T4 各自独立加锁读 per_conv,且 +// pinned_goals 被 G1(L1026 拼接)与调用方 snapshot(L1157 AiCompleted 携带)读两遍。本 helper +// 一次锁内取 pinned_goals / working_context.build_injection / workflow_dag_summary / workflow_id, +// 释放锁后再做 Env(.await OnceLock)/技能(.await 进程缓存)拼接——后两者无锁需求,不在锁内 await +// (避免持锁 await 死锁/长持锁)。返回 (拼好的 system_prompt, pinned_goals),调用方复用 pinned_goals +// 做 snapshot,消除原 L1157 第二次 lock 读。 +// +// 注:`_lang` 参数为未来 i18n 行为准则预留(当前 system_prompt 拼接未用 lang),保持调用点签名稳定。 +async fn build_runtime_prompt( + session_arc: &Arc>, + conv_id: &str, + base_prompt: String, + _lang: &str, +) -> (String, Vec) { + // 一次锁读四字段(pinned_goals / working_injection / workflow_dag_summary / workflow_id)。 + // conv 已删(None)→ goals=空 / injection=空 / dag=None,等价原各块 conv_read.unwrap_or_default 短路。 + let (goals, working_injection, workflow_dag_summary, workflow_id) = { + let session = session_arc.lock().await; + match session.conv_read(conv_id) { + Some(conv) => ( + conv.pinned_goals.clone(), + conv.working_context.build_injection(3), + conv.workflow_dag_summary.clone(), + conv.workflow_id.clone(), + ), + None => (Vec::new(), String::new(), None, None), + } + }; + + let mut system_prompt = base_prompt; + + // G1 目标钉扎:仅 active 目标拼进 system_prompt(completed 不干扰 LLM 注意力)。 + if GOAL_PIN_ENABLED { + let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect(); + if !active_goals.is_empty() { + let goal_lines: Vec = active_goals.iter().enumerate().map(|(i, g)| { + let trimmed = g.text.trim(); + let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect(); + let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS { + format!("{}…", truncated) + } else { + truncated + }; + format!("{}. {}", i + 1, truncated) + }).collect(); + let goals_text = goal_lines.join("\n"); + system_prompt = if GOAL_INJECT_BANNER { + format!( + "{}\n\n## 当前目标(全程锚定,所有动作须服务于它们)\n{}", + system_prompt, goals_text + ) + } else { + format!("{}\n\n{}", system_prompt, goals_text) + }; + tracing::info!( + conv_id = %conv_id, + count = goals.len(), + first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""), + "[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt", + goals.len() + ); + } + } + + // T5: WorkingContext L1 注入(dirty_window=3,空 injection 跳过)。 + if !working_injection.is_empty() { + system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, working_injection); + } + + // EnvSnapshot 环境感知:detect() 是 OnceLock 全局缓存(启动探一次,后续零开销),与 G1 一样是 + // loop 不变量,与目标钉扎拼接次序无强约束(放其后,语义自然)。无锁需求。 + let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt(); + let behavior_prompt = concat!( + "\n## AI 定位\n", + "你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,", + "所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。", + "**绝不输出请在终端执行以下命令这类指令——你自己用 run_command 工具执行即可。**\n", + "\n## 行为准则\n", + "- 所有操作都通过工具完成,用户不参与执行\n", + "- 优先使用开发工具 IPC,非必要不写独立脚本\n", + "- 脚本需要审批通过才执行,会拖慢工作流\n", + "- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n", + "- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具\n", + "- **直接回答用户问题,不要以评论开头**——严禁用好问题等无信息量的开场白,直接输出答案或执行操作\n", + "- **及时收敛**:工具执行完目标达成后立即输出结果,不要继续调工具做无关操作", + ); + system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt); + + // 技能清单注入:skills_cached() 进程内缓存(懒扫描一次,后续零开销),无锁需求。 + if SKILL_LIST_INJECT_ENABLED { + let skills = crate::commands::ai::skills::skills_cached().await; + if !skills.is_empty() { + let lines: Vec = skills + .iter() + .take(SKILL_LIST_MAX_ITEMS) + .map(|s| { + let desc_flat = s.description.replace('\n', " "); + let desc: String = desc_flat.chars().take(SKILL_DESC_MAX_CHARS).collect(); + let desc = if desc_flat.chars().count() > SKILL_DESC_MAX_CHARS { + format!("{}…", desc) + } else { + desc + }; + format!("- {}: {}", s.name, desc) + }) + .collect(); + if !lines.is_empty() { + system_prompt = format!( + "{}\n\n## 可用技能\n以下为本机 Claude 技能/命令(名称: 描述)。用户请求命中某技能用途时,提示用户输入 /技能名 触发,或引导其按需使用:\n{}", + system_prompt, + lines.join("\n") + ); + tracing::debug!( + conv_id = %conv_id, + count = lines.len(), + "[ai] 技能清单注入:已追加 {} 条技能到 system_prompt", + lines.len() + ); + } + } + } + + // T4: 工作流 DAG 注入 — 当会话关联工作流时,将活跃路径注入 system prompt。 + if let Some(ref dag_summary) = workflow_dag_summary { + system_prompt = format!("{}[工作流]\n{}\n", system_prompt, dag_summary); + tracing::debug!( + conv_id = %conv_id, + workflow_id = ?workflow_id, + "[ai] T4: 已注入工作流 DAG 上下文" + ); + } + + (system_prompt, goals) +} + +// ── tokens_snapshot: TokenAccumulator → TokenUsage 累计快照 helper ── +// +// 抽自 run_agentic_loop 内 6 处(Coordinator 路径/入口 stop/MidStream/push 后 stop/ +// max_iterations/正常完成)重复的 TokenUsage{prompt/completion/total/cache_hit/cache_miss/ +// reasoning} 字面量组装。total = tokens.total() = prompt.saturating_add(completion) +// (TokenAccumulator::total 语义),与原 6 处逐字节等价。usage_delta_since 内部「取 cur 快照」 +// 也复用本 helper(cur 字段同构)。 +fn tokens_snapshot(tokens: &TokenAccumulator) -> df_ai::provider::TokenUsage { + df_ai::provider::TokenUsage { + prompt_tokens: tokens.prompt(), + completion_tokens: tokens.completion(), + total_tokens: tokens.total(), + prompt_cache_hit_tokens: tokens.cache_hit(), + prompt_cache_miss_tokens: tokens.cache_miss(), + reasoning_tokens: tokens.reasoning(), + } +} + // ── usage_delta_since: 会话级 token 落库「增量」口径 helper ── // // save_conversation 对 usage 做 old+add(accumulate_tokens),故每次 save 只能传 @@ -2714,14 +2698,7 @@ fn usage_delta_since( tokens: &TokenAccumulator, last_saved: &mut df_ai::provider::TokenUsage, ) -> df_ai::provider::TokenUsage { - let cur = df_ai::provider::TokenUsage { - prompt_tokens: tokens.prompt(), - completion_tokens: tokens.completion(), - total_tokens: tokens.total(), - prompt_cache_hit_tokens: tokens.cache_hit(), - prompt_cache_miss_tokens: tokens.cache_miss(), - reasoning_tokens: tokens.reasoning(), - }; + let cur = tokens_snapshot(tokens); let delta = df_ai::provider::TokenUsage { prompt_tokens: cur.prompt_tokens.saturating_sub(last_saved.prompt_tokens), completion_tokens: cur.completion_tokens.saturating_sub(last_saved.completion_tokens), diff --git a/src-tauri/src/commands/workflow.rs b/src-tauri/src/commands/workflow.rs index 506d099..a45a401 100644 --- a/src-tauri/src/commands/workflow.rs +++ b/src-tauri/src/commands/workflow.rs @@ -292,6 +292,16 @@ pub async fn run_workflow_inner( .lock() .await .insert(exec_id.clone(), executor.state_machine()); + // P0-A: 把 task_id 注入 config,使 AiSelfReviewNode(ctx.config.get("task_id")) 与 + // AiNode parse_params 兜底(ctx.config.get("task_id") 读 task 生成默认 prompt) 可读。 + // config 是 serde_json::Value,仅 object 时 insert 安全;task_id None 跳过(向后兼容)。 + // executor.run 之前的 config 是 move 进 run 的,此处 mutate 后整体 move,零额外开销。 + let mut config = config; + if let Some(tid) = cb_task_id.as_ref() { + if let Some(obj) = config.as_object_mut() { + obj.insert("task_id".to_string(), serde_json::Value::String(tid.clone())); + } + } let result = executor.run(&runtime_dag, config).await; let (status, error) = match &result {