Files
DevFlow/src-tauri/src/commands/ai/prompt.rs
T

396 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 系统提示词构建 + 活跃提供商获取
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"));
}
}