diff --git a/crates/df-ai/src/context_helpers.rs b/crates/df-ai/src/context_helpers.rs index dee4715..ed5e808 100644 --- a/crates/df-ai/src/context_helpers.rs +++ b/crates/df-ai/src/context_helpers.rs @@ -343,6 +343,12 @@ pub const TOOL_RESULT_HEAD_LINES: usize = 5; pub const TOOL_RESULT_TAIL_LINES: usize = 5; /// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。 pub const TOOL_RESULT_MAX_ARRAY: usize = 10; +/// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因: +/// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。 +pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024; +/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断, +/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。 +pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512; /// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。 /// @@ -376,11 +382,12 @@ pub fn should_summarize_tool_result( /// /// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。 pub fn extract_key_info(content: &str, tool_name: &str) -> String { - // JSON 感知压缩:识别对象中的大数组并截断 + // JSON 感知压缩:识别对象中的大数组/大字符串并截断 if let Ok(mut val) = serde_json::from_str::(content) { if let Some(obj) = val.as_object_mut() { let mut truncated = false; for (_key, field) in obj.iter_mut() { + // 数组截断 if let Some(arr) = field.as_array() { if arr.len() > TOOL_RESULT_MAX_ARRAY { *field = serde_json::Value::Array( @@ -389,16 +396,40 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String { truncated = true; } } + // 字符串字段:先按行数截断,若不足再按字符数截断 + if let Some(s) = field.as_str() { + let lines: Vec<&str> = s.lines().collect(); + let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES; + if lines.len() > kept { + let mut out: Vec<&str> = Vec::new(); + out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]); + out.push("... (压缩中间内容) ..."); + out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]); + *field = serde_json::Value::String(out.join("\n")); + truncated = true; + } else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX { + // BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。 + // 按字符数截断保留头尾,保证压缩至少生效。 + let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect(); + let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect(); + *field = serde_json::Value::String(format!( + "{}...(截断,原始 {} 字符)...{}", + head, s.chars().count(), tail + )); + truncated = true; + } + } } if truncated { obj.insert("_truncated".into(), serde_json::Value::Bool(true)); return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string()); } } - // JSON 解析成功但无数组超限 → 原样返回(非 JSON 的走下行行数截断) - // 注意:不 return,继续行数判断(如 read_file 的 content 字段虽在 JSON 内但实际包含文件全文,行数压缩仍有价值) + // JSON 解析成功但无需截断 → 原样返回 + return content.to_string(); } + // 非 JSON 纯文本:按行数截断 let lines: Vec<&str> = content.lines().collect(); if lines.is_empty() { return String::new(); @@ -408,6 +439,17 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String { let total = lines.len(); let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES; if total <= kept_boundary { + // BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。 + // 按字符数截断保证压缩至少生效。 + let char_count = content.chars().count(); + if char_count > TOOL_RESULT_CHAR_LIMIT { + let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect(); + let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect(); + return format!( + "[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}", + tool_name, char_count, head, tail + ); + } return content.to_string(); } @@ -902,18 +944,35 @@ mod tests { } #[test] - fn extract_key_info_single_line_no_newline_unchanged() { - // 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回 + fn extract_key_info_single_line_short_no_newline_unchanged() { + // 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回 let content = "single line no newline"; assert_eq!(extract_key_info(content, "read_file"), content); } #[test] - fn extract_key_info_single_huge_line_no_newline_unchanged() { - // 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分) + fn extract_key_info_single_huge_line_no_newline_compressed() { + // BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。 let content = "x".repeat(50_000); let result = extract_key_info(&content, "read_file"); - assert_eq!(result, content, "单行无换行应原样返回(即使超长)"); + assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len()); + assert!(result.contains("已压缩"), "应含压缩标记"); + assert!(result.starts_with("[工具 read_file"), "应以工具名开头"); + assert!(result.contains("原始 50000 字符"), "应报告原始字符数"); + assert!(result.contains("(截断)"), "应含截断标记"); + } + + #[test] + fn extract_key_info_json_huge_string_field_truncated() { + // BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。 + // 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。 + let large = "z".repeat(10_000); + let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large); + let result = extract_key_info(&content, "read_file"); + assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len()); + assert!(result.contains("_truncated"), "应含 _truncated 标记"); + assert!(result.contains("src/main.rs"), "应保留 path 字段"); + assert!(result.contains("(截断)"), "应含截断标记"); } #[test] diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index 682d6b9..8024451 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -84,6 +84,7 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result("pinned")? != 0, prompt_tokens: row.get("prompt_tokens")?, completion_tokens: row.get("completion_tokens")?, + pinned_goals: row.get("pinned_goals")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, }) @@ -159,24 +160,24 @@ impl_repo!( from_row => |row| ai_conversation_from_row(row), insert => |conn, rec| { conn.execute( - "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, if rec.pinned { 1i32 } else { 0i32 }, rec.prompt_tokens, rec.completion_tokens, - rec.created_at, rec.updated_at + rec.pinned_goals, rec.created_at, rec.updated_at ], ) }, update => |conn, rec| { conn.execute( - "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, updated_at = ?10 WHERE id = ?11", + "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, updated_at = ?11 WHERE id = ?12", params![ rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, if rec.pinned { 1i32 } else { 0i32 }, rec.prompt_tokens, rec.completion_tokens, - rec.updated_at, rec.id + rec.pinned_goals, rec.updated_at, rec.id ], ) } diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 3933ae2..06a7b62 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -43,7 +43,7 @@ pub fn run(conn: &Connection) -> Result<()> { // V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表, // 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了 // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 - let steps: [(i32, fn(&Connection) -> Result<()>); 31] = [ + let steps: [(i32, fn(&Connection) -> Result<()>); 32] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -75,6 +75,7 @@ pub fn run(conn: &Connection) -> Result<()> { (29, migrate_v29), (30, migrate_v30), (31, migrate_v31), + (32, migrate_v32), ]; for (version, migrate_fn) in steps { @@ -902,6 +903,20 @@ fn migrate_v31(conn: &Connection) -> Result<()> { Ok(()) } +/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化) +/// +/// 对话目标由 PerConvState.pinned_goals(Vec)管理,原先仅在内存态存在, +/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。 +fn migrate_v32(conn: &Connection) -> Result<()> { + conn.execute_batch( + "ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';" + )?; + tracing::info!("v32: ai_conversations 加 pinned_goals 列"); + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [32])?; + tracing::info!("迁移 v32 完成"); + Ok(()) +} + /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 258ac0b..30aaab9 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -336,6 +336,7 @@ pub struct AiConversationRecord { pub pinned: bool, // 是否置顶(排序置前;UX-17) pub prompt_tokens: Option, // 输入 token 累计(流式 usage 落库) pub completion_tokens: Option, // 输出 token 累计(流式 usage 落库) + pub pinned_goals: Option, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]') pub created_at: String, pub updated_at: String, } diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index e04181c..976c472 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -529,7 +529,6 @@ pub async fn ai_approve( tool_call_id: String, approved: bool, ) -> Result { - authz_debug(&format!("[ai_approve] 入口 tool_call_id={} approved={}", tool_call_id, approved)); let mut session = state.ai_session.lock().await; let approval = match session.pending_approvals.remove(&tool_call_id) { @@ -756,13 +755,7 @@ pub async fn ai_approve( /// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。 /// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。 -fn authz_debug(msg: &str) { - use std::io::Write; - let log_path = std::env::temp_dir().join("devflow-authz-debug.log"); - if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) { - let _ = writeln!(f, "{}", msg); - } -} + /// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。 /// @@ -852,7 +845,6 @@ pub async fn ai_authorize_dir( decision = %decision, "[AI-AUTHZ] ai_authorize_dir 入口" ); - authz_debug(&format!("[ai_authorize_dir] 入口 tool_call_id={} decision={}", tool_call_id, decision)); // 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。 let approval = { let mut session = state.ai_session.lock().await; diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 61a4978..c529e0e 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -221,12 +221,17 @@ pub async fn ai_conversation_list( let models: Vec = r.models.as_deref() .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); + let pinned_goals: Vec = r.pinned_goals + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); serde_json::json!({ "id": r.id, "title": r.title, "provider_id": r.provider_id, "model": r.model, "models": models, + "pinned_goals": pinned_goals, "archived": r.archived, "pinned": r.pinned, "prompt_tokens": r.prompt_tokens, @@ -311,6 +316,12 @@ pub async fn ai_conversation_switch( conv.agent_language = None; conv.iteration_used = 0; conv.stop_flag.store(false, Ordering::SeqCst); + // 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化) + let pinned_goals: Vec = record.pinned_goals + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + conv.pinned_goals = pinned_goals; } // 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空, // 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的) @@ -439,6 +450,30 @@ pub async fn ai_conversation_set_pinned( Ok(()) } +/// 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1) +/// +/// 接收前端 UI 增删后的目标列表,持久化到 DB 并同步更新内存 per_conv 状态。 +/// goals 是完整替换(非增量),前端增/删后传全量 new Vec。 +#[tauri::command] +pub async fn ai_update_conversation_goals( + state: State<'_, AppState>, + conversation_id: String, + goals: Vec, +) -> Result<(), String> { + let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?; + // 写 DB + state.ai_conversations + .update_field(&conversation_id, "pinned_goals", &goals_json) + .await + .map_err(err_str)?; + // 同步内存 per_conv(若已加载) + let mut session = state.ai_session.lock().await; + if let Some(conv) = session.per_conv.get_mut(&conversation_id) { + conv.pinned_goals = goals; + } + Ok(()) +} + /// 导出对话为指定格式(UX-18:对话导出) /// /// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库) diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index e92b8f1..5389c4c 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -164,7 +164,7 @@ pub(crate) async fn save_conversation( // loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。 // conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端 // 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。 - let (persist_msgs, provider_id, created_at) = { + let (persist_msgs, provider_id, created_at, pinned_goals) = { let mut session = session_arc.lock().await; let mut msgs = session.conv(conv_id).messages.all_messages_clone(); for m in &mut msgs { @@ -185,6 +185,7 @@ pub(crate) async fn save_conversation( msgs, session.active_provider_id.clone(), session.active_conv_created_at.clone(), + session.conv(conv_id).pinned_goals.clone(), ) }; @@ -224,6 +225,8 @@ pub(crate) async fn save_conversation( if !list.iter().any(|x| x == m) { list.push(m.to_string()); } rec.models = Some(serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string())); } + // G1 目标钉扎持久化:将 per_conv.pinned_goals 写入 DB + rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())); // update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at if let Err(e) = conv_repo.update_full(&rec).await { tracing::warn!("更新对话元数据失败 {conv_id}: {e}"); @@ -250,6 +253,7 @@ pub(crate) async fn save_conversation( pinned: false, prompt_tokens: usage.map(|u| u.prompt_tokens as i64), completion_tokens: usage.map(|u| u.completion_tokens as i64), + pinned_goals: Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())), created_at: conv_created.clone(), updated_at: now, }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 24632cc..958deae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -339,6 +339,7 @@ pub fn run() { commands::ai::ai_conversation_rename, commands::ai::ai_conversation_archive, commands::ai::ai_conversation_set_pinned, + commands::ai::ai_update_conversation_goals, commands::ai::ai_conversation_export, commands::ai::ai_list_skills, // 核心设计6: 热重载技能(invalidate + 重扫,不重启生效) diff --git a/src/api/ai.ts b/src/api/ai.ts index 3e5cda1..7eec5ec 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -301,11 +301,19 @@ export const aiApi = { }, /** - * 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。 - * 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。 - * @param format 'markdown' | 'json' | 'txt' - */ - exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise { - return invoke('ai_conversation_export', { conversationId, format }) - }, -} + /** 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。 + * 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。 + * @param format 'markdown' | 'json' | 'txt' + */ + exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise { + return invoke('ai_conversation_export', { conversationId, format }) + }, + + /** + * 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。 + * 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。 + */ + updateConversationGoals(conversationId: string, goals: string[]): Promise { + return invoke('ai_update_conversation_goals', { conversationId, goals }) + }, + } diff --git a/src/api/types.ts b/src/api/types.ts index 72f9618..383c97c 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -572,6 +572,7 @@ export interface AiConversationSummary { title: string | null provider_id: string | null model: string | null + pinned_goals?: string[] archived: boolean pinned?: boolean prompt_tokens?: number | null diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index 8a93622..ffa9816 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -54,21 +54,21 @@
+ +
{{ $t('taskDetail.priority') }} @@ -169,6 +171,7 @@ import { import type { TaskRecord, ProjectRecord, IdeaRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types' import TaskOutputCard from '@/components/task/TaskOutputCard.vue' import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue' +import type { NodeStatus } from '@/components/workflow/WorkflowDagDisplay.vue' const { t } = useI18n() const route = useRoute() @@ -202,7 +205,9 @@ const wfFailedHint = computed(() => wfResult.value === 'failed') // 工作流 DAG 结构展示 const wfDagJson = ref('') +const wfNodeStatuses = ref>({}) async function refreshWorkflowDag(execId: string) { + wfNodeStatuses.value = {} try { const record = await workflowApi.getExecution(execId) if (record?.dag_json) wfDagJson.value = record.dag_json @@ -365,12 +370,16 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) { // 用「已启动 + 已完成」近似 total,显示已完成/已启动进度) const node = String(evt.node_id ?? '') wfRunningNode.value = node + wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'running' } if (wfTotalNodes.value === 0) wfTotalNodes.value = 1 else wfTotalNodes.value = Math.max(wfTotalNodes.value, wfDoneCount.value + 1) break } case 'node_completed': { wfDoneCount.value += 1 + if (wfRunningNode.value) { + wfNodeStatuses.value = { ...wfNodeStatuses.value, [wfRunningNode.value]: 'completed' } + } wfRunningNode.value = '' break } @@ -387,7 +396,12 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) { break } case 'workflow_failed': - case 'node_failed': { + case 'node_failed': + case 'workflow_failed': { + if (evt?.node_id) { + const node = String(evt.node_id) + wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'failed' } + } if (evt?.type === 'workflow_failed') { wfAdvancing.value = false wfRunningNode.value = ''