396 lines
21 KiB
Rust
396 lines
21 KiB
Rust
//! 系统提示词构建 + 活跃提供商获取
|
||
|
||
use df_storage::models::AiProviderRecord;
|
||
|
||
use crate::commands::err_str;
|
||
use crate::state::AppState;
|
||
|
||
/// 获取当前活跃提供商配置
|
||
pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> {
|
||
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"));
|
||
}
|
||
}
|