新增: 上下文管理演进 Wave2(T3结构化摘要 + T4工作流DAG占位)
T3: 压缩摘要双格式(向后兼容) - compress_via_llm 返回类型 Result<String> → Result<CompressedSummary> - 新增 parse_sections_to_json: 从四段式 Markdown 摘要解析 JSON 卡片 - context_lifecycle: 插入 NL 摘要(向后兼容),预留 WorkingContext 联动 - manual compress IPC (chat.rs): 适配新返回类型 T4: 工作流 DAG 注入(占位) - workflow_context.rs: 空壳函数,待 PerConvState.workflow_id 落地后接入 - mod.rs: 注入点已就位(日志占位) - 后续需补: DAG 加载 + 活跃路径裁剪 + system_prompt 注入
This commit is contained in:
@@ -28,7 +28,7 @@ use std::sync::Arc;
|
|||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
use tokio::sync::Mutex;
|
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_ai::provider::{ChatMessage, LlmProvider};
|
||||||
use df_storage::models::AiProviderRecord;
|
use df_storage::models::AiProviderRecord;
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
|
|
||||||
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
|
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
|
||||||
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
|
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
|
||||||
let compress_outcome: Result<Option<String>, String> = if active_msgs.is_empty() {
|
let compress_outcome: Result<Option<CompressedSummary>, String> = if active_msgs.is_empty() {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
} else {
|
} else {
|
||||||
compress_via_llm(
|
compress_via_llm(
|
||||||
@@ -145,15 +145,14 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
match compress_outcome {
|
match compress_outcome {
|
||||||
Ok(Some(summary)) => {
|
Ok(Some(summary)) => {
|
||||||
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
|
// 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 mut session = session_arc.lock().await;
|
||||||
let conv = session.conv(&conv_id);
|
let conv = session.conv(&conv_id);
|
||||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
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);
|
conv.messages.set_compressing(false);
|
||||||
|
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn)
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
@@ -166,7 +165,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
||||||
let ev = AiChatEvent::AiAutoCompressed {
|
let ev = AiChatEvent::AiAutoCompressed {
|
||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
summary,
|
summary: summary.nl_summary,
|
||||||
};
|
};
|
||||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ use context_lifecycle::maybe_auto_compress;
|
|||||||
mod title_lifecycle;
|
mod title_lifecycle;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
mod knowledge_lifecycle;
|
mod knowledge_lifecycle;
|
||||||
|
mod workflow_context;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。
|
// 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 反馈以开发新工具";
|
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);
|
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 时) ──
|
// ── 多 Agent 并行执行:Coordinator 分解(plan_execution_enabled 时) ──
|
||||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用)
|
// 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用)
|
||||||
// 只取 text(前端不需要状态信息)
|
// 只取 text(前端不需要状态信息)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1358,7 +1358,7 @@ pub async fn ai_chat_compress_context(
|
|||||||
&conv_id,
|
&conv_id,
|
||||||
&llm_concurrency,
|
&llm_concurrency,
|
||||||
).await {
|
).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s.nl_summary,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages)
|
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages)
|
||||||
let __lock_t1295 = std::time::Instant::now();
|
let __lock_t1295 = std::time::Instant::now();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
//! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`,
|
//! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`,
|
||||||
//! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。
|
//! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。
|
||||||
|
|
||||||
|
use df_ai::context_helpers::CompressedSummary;
|
||||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||||
// F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
|
// F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
|
||||||
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
|
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
|
||||||
@@ -49,7 +50,7 @@ pub(crate) async fn compress_via_llm(
|
|||||||
lang: &str,
|
lang: &str,
|
||||||
conv_id: &str,
|
conv_id: &str,
|
||||||
llm_concurrency: &LlmConcurrency,
|
llm_concurrency: &LlmConcurrency,
|
||||||
) -> Result<String, String> {
|
) -> Result<CompressedSummary, String> {
|
||||||
if active_msgs.is_empty() {
|
if active_msgs.is_empty() {
|
||||||
return Err("无可压缩消息(active 消息为空)".to_string());
|
return Err("无可压缩消息(active 消息为空)".to_string());
|
||||||
}
|
}
|
||||||
@@ -115,7 +116,13 @@ pub(crate) async fn compress_via_llm(
|
|||||||
if summary.is_empty() {
|
if summary.is_empty() {
|
||||||
return Err("LLM 压缩返回空摘要".to_string());
|
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。
|
/// 阶段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 代码围栏,空则原样返回
|
/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回
|
||||||
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
|
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user