//! 系统提示词构建 + 活跃提供商获取 use df_storage::models::AiProviderRecord; use crate::commands::err_str; use crate::state::AppState; /// 获取当前活跃提供商配置 pub(crate) async fn get_active_provider(state: &AppState) -> Result { let session = state.ai_session.lock().await; if let Some(ref pid) = session.active_provider_id { let provider = state .ai_providers .get_by_id(pid) .await .map_err(err_str)? .ok_or_else(|| format!("活跃提供商不存在: {}", pid))?; Ok(provider) } else { // 查找默认提供商 drop(session); let providers = state .ai_providers .list_all() .await .map_err(err_str)?; let default = providers.iter().find(|p| p.is_default).cloned(); default .or_else(|| providers.into_iter().next()) .ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string()) } } /// 当前运行环境画像(注入 system prompt):日期 + OS + shell 执行姿势 + 可用解释器。 /// L1 环境感知(治 kms seq26-52 引号地狱):教 AI 避命令行 -c 内联,复杂脚本写文件执行。 /// /// - 日期:LLM 无法自行获取,不注入则写文件日期全靠猜 /// - OS + shell:平台对应 shell(Windows=PowerShell / Linux·macOS=bash),AI 写命令需对齐 /// - 执行姿势:复杂脚本(多行/含引号/$变量)写 .ps1/.py 文件用 `powershell -File`/`python` 执行, /// 避免命令行 -c 内联(cmd/PowerShell 引号转义易出错,kms 实证 20+ 次失败) /// - 可用解释器:Windows=python(无 python3);Linux·macOS=python3;node 均有 /// /// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。 fn env_profile_line() -> String { let today = chrono::Local::now().format("%Y-%m-%d"); let (os, shell, interp) = if cfg!(target_os = "windows") { ("Windows", "PowerShell", "python(无 python3)、node") } else if cfg!(target_os = "macos") { ("macOS", "bash/zsh", "python3、node") } else if cfg!(target_os = "linux") { ("Linux", "bash", "python3、node") } else { ("Unknown", "sh", "python3、node") }; format!( "当前日期: {today} | 运行环境: {os} | shell: {shell}\n\ 执行姿势: 复杂脚本(多行/含引号/$变量)写成 .ps1 或 .py 文件,用 `powershell -File x.ps1` 或 `python x.py` 执行,避免命令行 -c 内联(引号转义易出错)。可用解释器: {interp}。\n\n" ) } /// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题) fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) { match lang { "en" => ( "You are DevFlow's AI assistant. You can manage projects, tasks, ideas, and workflows, as well as analyze code, read/write files, and execute commands.\n\ Please respond in English.\n\n\ ## Capabilities\n\ You can perform the following actions via tool calls:\n\ - Create/query projects, tasks, and ideas\n\ - Run workflows\n\ - Read file contents, list directories, create/write files\n\ - To understand/locate a code symbol (function/struct/class) prefer read_symbol(path, symbol): returns a skeleton (signature + line range + internal calls, minimal and high-density), avoiding read_file dumping the whole file into the prompt; pass full=true for the full definition body; read_symbol supports .rs/.ts/.tsx/.js/.jsx/.vue — fall back to read_file only for other languages or when the symbol is not found\n\ - Get file metadata (size, line count, binary detection)\n\ - Append content to files, search files by name pattern\n\n\ ## Guidelines\n\ - Briefly explain your intent before executing actions\n\ - Ask for clarification if the user's intent is unclear\n\ - 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\ - **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\ - **Duplicate call detection**: If a tool (e.g. read_file/search_files) has already been called with identical arguments and returned successfully, do not call it again — the result is already in the conversation history; review the context instead of re-executing\n\ ## Focus\n\ - Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\ - When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\ - Give the conclusion or action first, then add only necessary explanation; omit tangential information unrelated to the current request.\n\ - Do not proactively expand context (files/data) that is irrelevant to the current request.\n", "\n## Current Projects\n", "\n## Current Tasks\n", ), _ => ( "你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\ 必须使用简体中文回复,禁止使用繁体中文字符。\n\n\ ## 当前能力\n\ 你可以通过工具调用执行以下操作:\n\ - 创建/查询项目、任务、灵感\n\ - 运行工作流\n\ - 读取文件内容、列出目录、创建/写入文件\n\ - 理解/定位代码符号(函数/结构体/类等)优先用 read_symbol(path, symbol):返回骨架(签名+行范围+内部调用,极小高密度),避免 read_file 全文回灌撑爆 prompt;需完整定义体传 full=true;read_symbol 支持 .rs/.ts/.tsx/.js/.jsx/.vue,其他语言或未找到符号时再退回 read_file\n\ - 获取文件元信息(大小、行数、二进制检测)\n\ - 追加写入文件、按名称模式搜索文件\n\n\ ## 行为准则\n\ - 执行操作前简要说明你的意图\n\ - 如果不确定用户意图,先提问\n\ - 优先使用工具完成操作,而不是只描述步骤\n\ - 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\ - **宣称任务完成前必须自检**:用 grep/读取关键引用/核对字段值等方式验证结果与原意图一致,发现偏差先修正再宣布完成;严禁不验证就宣称「全部完成」(实测反复出现:漏改引用名、字段被后续操作覆盖未发现,被迫用户人工核对)\n\ - **重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行\n\ ## 聚焦准则\n\ - 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\ - 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\ - 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\ - 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n", "\n## 当前项目\n", "\n## 当前任务\n", ), } } /// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**) /// /// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 name/status/description(无 path), /// 供 LLM 知道当前存在哪些实体(非精准引用)。 /// /// **被@实体的精准投影**(含 path 脱敏、剥 frontmatter 的 skill 正文)由 chat.rs 的 /// augmentation 层(`ResolverRegistry::resolve_all` + `build_augmentation_segment`)处理, /// 非 build_system_prompt 职责 —— 两套并行:全局清单(全貌)+ augmentation(用户本次引用)。 /// /// 注入克制:仅各取最近 20 条,防 context 膨胀。 /// /// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给 /// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。 pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String { build_system_prompt_with_excluded(state, lang, &[], &[]).await } /// 构建系统提示词(可去重版本)——主路径 chat.rs send / force_send 调用。 /// /// 与 [`build_system_prompt`] 的差异(机制而非说教): /// 1. **去重**:被 @ 的 project/task 已由 augmentation 层精准投影(含 path/正文),清单中 /// 再现会造成同一实体两次入 prompt(实测 build f64dee94 会话 list_projects 8 次 / /// list_tasks 20 次)。此处把 `excluded_project_ids`/`excluded_task_ids` 命中的条目 /// 从全局清单循环中跳过,被@实体只保留 augmentation 一次。 /// 2. **注明语**:清单末尾按语言追加一行,告知 LLM 「项目已全部列出无需再调 list_projects」/ /// 「任务仅最近 20 条,全量查询请用 list_tasks」,从机制层降低工具与清单重复调用 /// (而非在行为准则里反复说教)。 /// /// - 空排除列表(无 mention 的调用)与 [`build_system_prompt`] 行为完全等价(仅多一行注明语)。 /// - 注入克制策略不变:仍各 take(20)。 pub(crate) async fn build_system_prompt_with_excluded( state: &AppState, lang: &str, excluded_project_ids: &[String], excluded_task_ids: &[String], ) -> String { let (prefix, proj_label, task_label) = system_prompt_parts(lang); let mut prompt = env_profile_line(); prompt.push_str(prefix); // 附加当前数据上下文 if let Ok(projects) = state.projects.list_active().await { if !projects.is_empty() { prompt.push_str(proj_label); // system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀 // 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt) for p in projects.iter().take(20) { if excluded_project_ids.iter().any(|id| id == &p.id) { continue; } prompt.push_str(&format!("- {} ({}): {}\n", p.name, p.status.as_str(), p.description)); if let Some(ref dir) = p.path { prompt.push_str(&format!(" 目录: {}\n", dir)); } } // 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用 prompt.push_str(&projects_listed_note(lang, 20)); } } // 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层) // 仅最近 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) { if excluded_task_ids.iter().any(|id| id == &tk.id) { continue; } prompt.push_str(&format!("- {} ({}): {}\n", tk.title, tk.status.as_str(), tk.description)); } // 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks prompt.push_str(&tasks_listed_note(lang)); } } // 自定义提示词(设置中配置,追加到系统 prompt 末尾) if let Ok(Some(custom)) = state.settings.get("custom_prompt").await { if !custom.is_empty() { let clean = custom.trim().trim_matches('"'); if !clean.is_empty() { prompt.push_str("\n## 自定义指令\n"); prompt.push_str(clean); prompt.push('\n'); } } } prompt } /// 项目清单尾部注明语(中/英)。 /// /// 机制层降重复:直接告知「已全部列出」,LLM 知道再调 list_projects 是冗余。 /// `shown` 取实际注入条数上限 20(避免传运态 list_active 长度,只描述清单本身)。 fn projects_listed_note(lang: &str, shown: usize) -> String { match lang { "en" => format!( "(Listed {} active projects above; the active set is shown, no need to call list_projects again.)\n", shown ), _ => format!( "(以上为活跃项目清单,无需重复调用 list_projects)\n" ), } } /// 任务清单尾部注明语(中/英)。 /// /// 机制层降重复:明确「仅最近 20 条 + 全量查询走 list_tasks」,避免 LLM 误以为清单即全量 /// 而反复调 list_tasks 校验(实测单会话 20 次)。 fn tasks_listed_note(lang: &str) -> String { match lang { "en" => "(Only the 20 most recent tasks are shown. For the full list or filtered by project, use the list_tasks tool.)\n".to_string(), _ => "(仅显示最近 20 条任务,全量或按项目查询请用 list_tasks 工具)\n".to_string(), } } /// 上下文压缩 system prompt 模板(F-15 §4.2,纯函数无 IO)。 /// /// 引导 LLM 把对话压缩为四段式结构化摘要(意图/决策/文件/约束),最大化保留 /// 旧消息语义。调用方把返回的 system prompt 放在消息列表首位,后接被压缩的 /// active 消息原文(user/assistant/tool)喂给 `provider.complete()`: /// /// ```ignore /// let mut msgs = vec![ChatMessage::system(compress_prompt(lang))]; /// msgs.extend(active_msgs); /// let resp = provider.complete(CompletionRequest { messages: msgs, .. }).await?; /// ``` /// /// `lang` 与系统提示词保持一致取值集合("en" 英文,其余一律中文)。 // // 调用方:compress.rs:85(compress_via_llm 内构造 system 消息)。 pub(crate) fn compress_prompt(lang: &str) -> &'static str { match lang { "en" => "You are a conversation summarizer. Compress the following conversation \ into a structured summary that preserves the essential context for \ continuing the work. Output exactly four sections:\n\ \n\ ## Intent\n\ The user's core intent and requirements (what they are trying to achieve).\n\ \n\ ## Decisions\n\ Key decisions already made (which approach/option was chosen, why alternatives \ were rejected).\n\ \n\ ## Files\n\ Files modified/created/inspected, with a one-line description of the change in each.\n\ \n\ ## Constraints\n\ Important constraints, preferences, or open issues to respect going forward.\n\ \n\ Rules:\n\ - Be concise; prefer bullet points.\n\ - Drop small talk and transient pleasantries; keep only technically load-bearing facts.\n\ - Preserve file paths, identifiers, and error messages verbatim.\n\ - Must preserve core topic words, entity names, and technical terms the user \ repeatedly mentions; they are anchors for continuing the conversation and losing \ them breaks the context thread.\n\ - Do NOT invent facts not present in the conversation.\n\ - Output the four sections only, no preamble or extra commentary.", _ => "你是对话总结器。请把以下对话压缩为结构化摘要,保留继续推进工作所必需的关键上下文。\ 严格按以下四段输出:\n\ \n\ ## 意图\n\ 用户的核心意图和需求(用户想要达成什么)。\n\ \n\ ## 决策\n\ 已做出的关键决策(选了哪种方案/选项,为何排除其他方案)。\n\ \n\ ## 文件\n\ 已修改/创建/查看过的文件,每条一行简述其变更内容。\n\ \n\ ## 约束\n\ 重要约束、偏好或待解决的问题,后续需要继续遵守或留意。\n\ \n\ 要求:\n\ - 简洁,优先用要点。\n\ - 去掉寒暄、过渡性客套,只保留技术上有价值的事实。\n\ - 文件路径、标识符、错误信息等照原样保留。\n\ - 必须保留用户反复提及的核心主题词、实体名、技术名词(它们是对话续接的锚点,丢失会致上下文断裂)。\n\ - 不要编造对话中没有的事实。\n\ - 只输出上述四段内容,不要前言、解释或额外评论。", } } #[cfg(test)] mod tests { use super::*; // 改进1 系统提示聚焦段:中文版含「聚焦准则」独立段 #[test] fn system_prompt_zh_has_focus_section() { let (prefix, _, _) = system_prompt_parts("zh-CN"); assert!( prefix.contains("聚焦准则"), "中文 system prompt 应含聚焦准则段,实际: {}", prefix ); // 独立段标题,非稀释在行为准则里 assert!(prefix.contains("## 聚焦准则")); // 核心条款抽样 assert!(prefix.contains("始终围绕用户当前请求的核心目标")); assert!(prefix.contains("也绝不谎报成功")); } // 改进1 系统提示聚焦段:英文版含「Focus」独立段 #[test] fn system_prompt_en_has_focus_section() { let (prefix, _, _) = system_prompt_parts("en"); assert!( prefix.contains("Focus"), "英文 system prompt 应含 Focus 段,实际: {}", prefix ); assert!(prefix.contains("## Focus")); assert!(prefix.contains("never falsely report success")); assert!(prefix.contains("core goal of the user's current request")); } // 兜底:lang 未匹配回落中文,聚焦段仍存在 #[test] fn system_prompt_unknown_lang_falls_back_zh_with_focus() { let (prefix, _, _) = system_prompt_parts("fr"); assert!(prefix.contains("聚焦准则")); } #[test] fn compress_prompt_unaffected_by_focus_addition() { // 压缩 prompt 是独立函数,聚焦段改动不应波及 let zh = compress_prompt("zh-CN"); assert!(zh.contains("意图")); let en = compress_prompt("en"); assert!(en.contains("Intent")); } // 改进3 A: 压缩 prompt 主题保留段(锚点词防上下文断裂) #[test] fn compress_prompt_zh_keeps_topic_anchor_clause() { let zh = compress_prompt("zh-CN"); assert!( zh.contains("主题词"), "中文 compress_prompt 应含主题词/锚点保留要求,实际: {}", zh ); assert!(zh.contains("锚点")); assert!(zh.contains("上下文断裂")); } #[test] fn compress_prompt_en_keeps_topic_anchor_clause() { let en = compress_prompt("en"); assert!( en.contains("anchors"), "英文 compress_prompt 应含 anchors 保留要求,实际: {}", en ); assert!(en.contains("topic words")); assert!(en.contains("breaks the context")); } // 项目/任务清单注明语(中/英)存在 #[test] fn listed_notes_present_zh_en() { let zh_proj = projects_listed_note("zh-CN", 20); assert!(zh_proj.contains("无需重复调用 list_projects")); let en_proj = projects_listed_note("en", 20); assert!(en_proj.contains("no need to call list_projects")); let zh_task = tasks_listed_note("zh-CN"); assert!(zh_task.contains("仅显示最近 20 条任务")); assert!(zh_task.contains("list_tasks")); let en_task = tasks_listed_note("en"); assert!(en_task.contains("20 most recent tasks")); assert!(en_task.contains("list_tasks")); } }