diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs index 895447e..9cd176f 100644 --- a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -28,7 +28,7 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::Mutex; -use df_ai::context_helpers::extract_keyword_summary; +use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary}; use df_ai::provider::{ChatMessage, LlmProvider}; use df_storage::models::AiProviderRecord; @@ -129,7 +129,7 @@ pub(super) async fn maybe_auto_compress( // 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。 // 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。 - let compress_outcome: Result, String> = if active_msgs.is_empty() { + let compress_outcome: Result, String> = if active_msgs.is_empty() { Ok(None) } else { compress_via_llm( @@ -145,15 +145,14 @@ pub(super) async fn maybe_auto_compress( match compress_outcome { Ok(Some(summary)) => { // LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 - // compress_old_messages 幂等:此时 status 仍是 active(本流程未先标),它会把 - // [..protect_start] 内 active 标 compressed 并扣 token。返回的 cloned 与之前读的 - // active_msgs 等价(LLM 调用期间 messages 不变,见上方口径决策注)。 { let mut session = session_arc.lock().await; let conv = session.conv(&conv_id); let _compressed = conv.messages.compress_old_messages(protect_start); - conv.messages.insert_at(0, ChatMessage::system(&summary)); + // T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用 + conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary)); conv.messages.set_compressing(false); + // TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn) } tracing::info!( conv_id = %conv_id, @@ -166,7 +165,7 @@ pub(super) async fn maybe_auto_compress( // miniapp 仍插摘要气泡(对端发生压缩告知用户)。 let ev = AiChatEvent::AiAutoCompressed { conversation_id: Some(conv_id.to_string()), - summary, + summary: summary.nl_summary, }; let _ = app_handle.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 345effd..229b5fd 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -258,6 +258,7 @@ use context_lifecycle::maybe_auto_compress; mod title_lifecycle; #[allow(unused_imports)] mod knowledge_lifecycle; +mod workflow_context; // ============================================================ // L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。 @@ -868,6 +869,11 @@ pub(crate) async fn run_agentic_loop( let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具"; system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt); + // T4: 工作流 DAG 注入(预留 — 待 PerConvState 加 workflow_id 字段后接入) + // 在此处检测 conv.workflow_id,加载 DAG 并注入 WorkflowContextBlock 到 system_prompt + // 当前仅日志占位,不改变任何行为 + tracing::trace!(conv_id = %conv_id, "[ai] T4 工作流注入点已就位"); + // ── 多 Agent 并行执行:Coordinator 分解(plan_execution_enabled 时) ── // 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用) // 只取 text(前端不需要状态信息) diff --git a/src-tauri/src/commands/ai/agentic/workflow_context.rs b/src-tauri/src/commands/ai/agentic/workflow_context.rs new file mode 100644 index 0000000..9b5e85b --- /dev/null +++ b/src-tauri/src/commands/ai/agentic/workflow_context.rs @@ -0,0 +1,11 @@ +//! 工作流 DAG 上下文注入 — 当会话关联工作流时,将活跃路径注入 system prompt +//! +//! T4 实现预留。调用点在 `run_agentic_loop` 中 system_prompt 拼接完成后。 +//! 当前为空壳,需配合 PerConvState.workflow_id 字段落地后完成。 + +/// 从工作流 DAG 中提取活跃路径上下文块 +/// 待 PerConvState 加 workflow_id 字段后接入。 +#[allow(dead_code)] +pub fn build_workflow_block() { + // TODO: T4 实现 — 加载 dag::Dag,构建 WorkflowContextBlock,注入 system_prompt +} diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 6644443..dff03fb 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -1358,7 +1358,7 @@ pub async fn ai_chat_compress_context( &conv_id, &llm_concurrency, ).await { - Ok(s) => s, + Ok(s) => s.nl_summary, Err(e) => { // LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages) let __lock_t1295 = std::time::Instant::now(); diff --git a/src-tauri/src/commands/ai/compress.rs b/src-tauri/src/commands/ai/compress.rs index c97ef8a..d2e5850 100644 --- a/src-tauri/src/commands/ai/compress.rs +++ b/src-tauri/src/commands/ai/compress.rs @@ -8,6 +8,7 @@ //! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`, //! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。 +use df_ai::context_helpers::CompressedSummary; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; // F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化 // 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。 @@ -49,7 +50,7 @@ pub(crate) async fn compress_via_llm( lang: &str, conv_id: &str, llm_concurrency: &LlmConcurrency, -) -> Result { +) -> Result { if active_msgs.is_empty() { return Err("无可压缩消息(active 消息为空)".to_string()); } @@ -115,7 +116,13 @@ pub(crate) async fn compress_via_llm( if summary.is_empty() { return Err("LLM 压缩返回空摘要".to_string()); } - Ok(summary) + // T3: 解析四段式输出为 JSON 卡片 + NL 摘要 + // 从现有 ## Intent / ## Decisions / ## Files / ## Constraints 格式解析 + let json_card = parse_sections_to_json(&summary); + Ok(CompressedSummary { + json_card, + nl_summary: summary, + }) } /// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。 @@ -151,6 +158,55 @@ fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool { }) } +/// 从四段式 Markdown 摘要中解析出 JSON 卡片 +/// 段标题: ## Intent / ## Decisions / ## Files / ## Constraints +fn parse_sections_to_json(summary: &str) -> String { + let mut topics = Vec::new(); + let mut decisions = Vec::new(); + let mut key_files = Vec::new(); + + let mut current_section: Option<&str> = None; + for line in summary.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("## ") { + current_section = trimmed.strip_prefix("## "); + continue; + } + let text = trimmed.trim_start_matches("- ").trim(); + if text.is_empty() { + continue; + } + match current_section { + Some("Intent") | Some("意图") => { + topics.push(text.to_string()); + } + Some("Decisions") | Some("决策") => { + decisions.push(serde_json::json!({"summary": text})); + } + Some("Files") | Some("文件") => { + key_files.push(serde_json::json!({"change": text})); + } + _ => {} + } + } + + serde_json::json!({ + "topics": topics, + "decisions": decisions, + "key_files": key_files, + "parsed_from": "four_section_summary", + }).to_string() +} + +/// 在文本中提取标记间的内容 +#[allow(dead_code)] +fn extract_between<'a>(text: &'a str, start_marker: &str, end_marker: &str) -> Option<&'a str> { + let start = text.find(start_marker)?; + let after_start = &text[start + start_marker.len()..]; + let end = after_start.find(end_marker)?; + Some(after_start[..end].trim()) +} + /// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回 /// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。 ///