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

638 lines
44 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",
),
}
}
/// 文档探索策略段(中/英):引导 AI 收到 URL 时走「嗅探→识别→提取→验证→应用」,
/// 而非盲目对 URL 发 http_request 瞎试。
///
/// 机制治症状:实测 AI 收到产品/文档 URL 时,会直接 http_request 打原始 URL(HTML 噪声爆 token、
/// 也调不出真正 API 端点),应先用 fetch_url 转成可读 markdown 理解文档,提取 base_url/鉴权/
/// 端点路径后,再用 http_request 打正确端点验证。
///
/// - fetch_url = 读文档(URL→markdown,去 HTML 噪声);http_request = 调 API(POST/鉴权/原始响应)
/// - 不瞎试 URL:先读文档提取端点,再验证;杜绝未读文档即对原始 URL 盲发请求
///
/// 末尾追加 obscura 安装引导子段:fetch_url render=true 未装 obscura 时,结果含
/// `render_mode=static_fallback` + `hint` 字段(引导链接 github.com/h4ckf0r0day/obscura)。
/// AI 看到 hint 即自主提示用户「装了能 JS 渲染 SPA/反爬」,征得同意后用 run_command 帮装。
///
/// 仅段加在 prefix 末尾(聚焦段后),不破坏现有结构。
fn doc_exploration_strategy_section(lang: &str) -> &'static str {
match lang {
"en" => "\n\
## Documentation Exploration Strategy\n\
### Pick the right URL tool first (do NOT mix them up)\n\
Three URL tools exist — using the wrong one fails and wastes round-trips:\n\
- `fetch_url`: read a URL and convert to markdown (**HTML docs only**; refuses binary/images/files). For web pages, API docs, blog posts.\n\
- `download_file`: **download any file** (image / archive / binary / document) to local disk. Streaming, binary-safe, never goes into the prompt.\n\
- `http_request`: call an API (POST / auth / raw JSON response). For REST endpoints, webhooks, structured payloads.\n\
Hard rule by URL kind:\n\
1. **Image / archive / binary file URL** (e.g. `.png` `.jpg` `.zip` `.pdf` `.exe` `.tar.gz`) → use `download_file`. Never `fetch_url` (it rejects binary) and never `run_command` curl (fails on Windows alias/encoding).\n\
2. **Web page / doc URL** (HTML the browser would render) → use `fetch_url`.\n\
3. **API endpoint URL** (returns JSON / needs auth / POST body) → use `http_request`.\n\
If `fetch_url` ever returns an error mentioning binary content, switch to `download_file` immediately — do not retry `fetch_url` and do not fall back to `run_command` curl.\n\
\n\
When the user gives you a URL (product page / API docs / SDK guide), follow this flow instead of blindly firing http_request at the raw URL:\n\
1. **Sniff**: call `fetch_url` to turn the URL into clean markdown (strips HTML noise) and read what the page actually says.\n\
2. **Identify intent**: is it an API doc (endpoints/auth), a product capability page, a config spec, or a code sample?\n\
3. **Extract keys**: pull out base_url, provider_type, auth scheme, model list, endpoint paths — write them down explicitly before calling anything.\n\
4. **Verify**: only now use `http_request` against the correct endpoint you extracted (e.g. `GET {base_url}/v1/models` with the auth header) to confirm it works.\n\
5. **Apply**: configure the Provider / write code / transcribe into a file via `write_file`.\n\
Rule: `fetch_url` is for *reading* a URL (docs/web/API description, markdown output); `http_request` is for *calling* an API (POST/auth/raw response). Never guess an endpoint — read the doc first, extract, then verify.\n\
\n\
### Extract real links — NEVER fabricate URLs\n\
After a `fetch_url`/`http_request` returns content, do **not** invent URLs from memory or convention (no guessing `#hash` anchors, no speculating `/path` routes, no trying 'what the URL probably is'). Guessed URLs are ~99% dead (404/403) and waste round-trips. Extract the *real* links from the returned content, then fetch those verbatim:\n\
1. **Pull real links out of the response**: markdown `[text](url)`, HTML `<a href>` / `<script src>` / `<link href>`, JSON `url` / `endpoint` / `href` fields — these are the only URLs you may fire. A URL that does not appear in the returned content does not exist for you.\n\
2. **Fetch extracted URLs verbatim**: use the extracted URL exactly as-is — keep query params, do not strip the path, do not add a `#hash`, do not 'correct' the route. The link in the content is the link you fetch.\n\
3. **No explicit link → do not blind-fire**: never assemble a URL from hunches like 'it should be /api/xxx' or 'probably #download'. Instead, find an entry in the returned content (navigation / index page / official doc root), or ask the user for the exact link.\n\
4. **API endpoints are the same**: read base_url / path precisely from the doc or response fields (`base_url` / `endpoint` / `api_base`). Do not speculate routes like `/v1/chat/completions` — different vendors use different paths, and a guess is almost always wrong.\n\
Rule: send a request only when there is a sourced link; never fabricate a URL out of thin air.\n\
\n\
### render=true failure-retry rule (stop the AI hammering one URL)\n\
`fetch_url render=true` (JS render) failure signals: `html=0` (empty content), timeout, or an `obscura_failed` response. Observed: the AI repeatedly re-fires render=true at the same URL (each call times out at 30s; 8 retries = 4 minutes wasted, 8% success rate) — it **refuses to give up or switch tactics**. Mandatory rules:\n\
1. **Switch after 2 failures**: if `render=true` fails twice in a row (any failure signal) → **immediately stop retrying that URL**. Pick an alternative: (a) use `http_request` to hit the API endpoint directly; (b) ask the user to provide content/screenshots manually; (c) work with the data you already have and produce output now — don't wait for a perfect render.\n\
2. **Stop after the first success**: if the first `render=true` succeeds and returns markdown → **use the data you have immediately**; do NOT re-render the same URL to top up more content (what you got is what you use).\n\
3. **Never re-render the same URL**: once a URL's render result (success or failure) is known, never fire render=true at it again (history/cache is enough); for new content, switch URL or switch tactic.\n\
\n\
### JS-rendered docs & the obscura install hint\n\
`fetch_url` defaults to a static fetch (no JS). For SPA / anti-scrape / JS-rendered docs, the user (or you) can pass `render=true`. When obscura (the JS-rendering backend, github.com/h4ckf0r0day/obscura) is **not installed**, the result silently falls back to static and the returned JSON carries `render_mode: \"static_fallback\"` plus a `hint` field pointing to the install page. Watch the **`hint` field** specifically (not just `render_mode`) — it is the install signal:\n\
1. **Notice**: if the `fetch_url` result has `hint` set (or `render_mode` is `static_fallback`), obscura is missing and the page may be under-rendered.\n\
2. **Offer**: proactively tell the user — \"obscura is not installed; installing it lets fetch_url render JS-heavy SPA / anti-scrape docs. Want me to install it for you?\" Don't silently continue with the static fallback when the user clearly needs JS rendering.\n\
3. **Install on consent**: once the user agrees, run the install yourself via `run_command` and stream the output so the user sees progress — pick whichever path fits the user's environment:\n\
- Rust toolchain present: `cargo install obscura`\n\
- otherwise: download the matching prebuilt binary from github.com/h4ckf0r0day/obscura releases and place it on PATH\n\
(`run_command` is high-risk and requires user approval — the user's \"yes\" above is that approval; still announce the exact command before running.)\n\
4. **Re-fetch**: after install succeeds, tell the user \"obscura is ready\" and retry `fetch_url` with `render=true` to get the properly rendered markdown.\n\
This is a pure conversational guide — there is no UI button for it; you detect the hint and drive the install through dialogue.\n",
_ => "\n\
## 文档探索策略\n\
### 先选对 URL 工具(三者别混用)\n\
有三个 URL 工具——用错就失败、纯浪费往返:\n\
- `fetch_url`:读 URL 转 markdown(**仅 HTML 文档**;二进制/图片/文件一律拒)。用于网页、API 文档、博客。\n\
- `download_file`:**下载任意文件**(图片/压缩包/二进制/文档)到本地。流式写,二进制安全,内容不进 prompt。\n\
- `http_request`:调 API(POST/鉴权/原始 JSON 响应)。用于 REST 端点、webhook、结构化请求体。\n\
按 URL 类型选工具的铁律:\n\
1. **图片/压缩包/二进制文件 URL**(如 `.png` `.jpg` `.zip` `.pdf` `.exe` `.tar.gz`)→ 用 `download_file`。绝不用 `fetch_url`(它拒二进制),也不用 `run_command` curl(Windows 上 alias/编码会失败)。\n\
2. **网页/文档 URL**(浏览器会渲染的 HTML)→ 用 `fetch_url`。\n\
3. **API 端点 URL**(返回 JSON/需鉴权/POST body)→ 用 `http_request`。\n\
若 `fetch_url` 返回的错误信息提到二进制内容,立即改用 `download_file`——不要重试 `fetch_url`,也不要回退到 `run_command` curl。\n\
\n\
收到用户给的 URL(产品页/API 文档/SDK 说明)时,按以下流程走,不要盲目对原始 URL 发 http_request 瞎试:\n\
1. **嗅探**:先调 `fetch_url` 把 URL 转成干净的 markdown(剥离 HTML 噪声),读明白页面到底写了什么。\n\
2. **识别意图**:是 API 文档(端点/鉴权)、产品能力页、配置规格,还是代码示例?\n\
3. **提取关键**:把 base_url、provider_type、鉴权方式、模型清单、端点路径显式列出来,再决定调什么。\n\
4. **验证**:此时才用 `http_request` 打你提取出的正确端点(如带鉴权头 `GET {base_url}/v1/models`)确认可用。\n\
5. **应用**:配置 Provider / 编码 / 转写到文件(write_file)。\n\
铁律:`fetch_url` 用于**读** URL(文档/网页/API 说明,输出 markdown);`http_request` 用于**调** API(POST/鉴权/原始响应)。严禁猜端点——先读文档提取,再验证。\n\
\n\
### 精确提取链接,严禁臆造 URL\n\
`fetch_url`/`http_request` 拿到返回内容后,**不要**凭印象/惯例臆造 URL(不猜 `#hash` 锚点、不推测 `/path` 路由、不试「这 URL 大概是啥」)。猜出来的 URL 99% 是死链(404/403),纯浪费往返。要从返回内容里提取**真实链接**,再原样 fetch:\n\
1. **从返回内容提取真实链接**:markdown `[文本](url)`、HTML `<a href>`/`<script src>`/`<link href>`、JSON 的 `url`/`endpoint`/`href` 字段——这些才是你能发的 URL。返回内容里没出现的 URL,对你而言不存在。\n\
2. **原样 fetch 提取到的 URL**:提取到的 URL 一字不改地用——保留查询参数,不删 path、不补 `#hash`、不「修正」路由。内容里的链接是啥,你就 fetch 啥。\n\
3. **没有明确链接 → 不盲试**:绝不凭「应该是 /api/xxx」「大概是 #download」拼 URL。回内容里找入口(导航/索引页/官方文档根),或直接问用户要准确链接。\n\
4. **API 端点同理**:base_url/path 从文档/响应字段精确读出(`base_url`/`endpoint`/`api_base`)。不推测 `/v1/chat/completions` 之类路由——不同厂商路径不同,猜必错。\n\
铁律:有据可查才发请求,无据绝不臆造 URL。\n\
\n\
### render=true 失败重试铁律(治 AI 死磕同一 URL)\n\
`fetch_url render=true`(JS 渲染)失败信号:`html=0`(空内容)、超时、或返回 `obscura_failed`。实测 AI 会反复对同一 URL 重发 render=true(单次 30s 超时,连发 8 次=浪费 4 分钟,成功率 8%),**不认输、不换方案**。强制规则:\n\
1. **失败 2 次必换方案**:`render=true` 连续 2 次失败(任意失败信号) → **立即停止重试同一 URL**,改走以下任一:(a) 改用 `http_request` 直接打 API 端点;(b) 提示用户手动提供内容/截图;(c) 用已拿到的数据先整理输出,别等完美渲染。\n\
2. **第 1 次成功即收手**:`render=true` 第 1 次成功拿到 markdown → **立即用已有数据整理输出**,不要重复 render 同 URL 去「补充」更多内容(已拿即用)。\n\
3. **同 URL 不重复 render=true**:同一 URL 的 render 结果(成功或失败)已知,后续不再对它重复发 render=true(缓存/历史即用);需要新内容请换 URL 或换方案。\n\
\n\
### JS 渲染文档与 obscura 安装引导\n\
`fetch_url` 默认走静态抓取(不跑 JS)。遇到 SPA / 反爬 / JS 渲染的文档,用户(或你)可传 `render=true`。当 obscura(JS 渲染后端,github.com/h4ckf0r0day/obscura)**未安装**时,结果会静默回退静态,返回的 JSON 带 `render_mode: \"static_fallback\"` 和一个指向安装页的 `hint` 字段。请**重点看 `hint` 字段**(而非只看 `render_mode`)——它就是安装信号:\n\
1. **察觉**:若 `fetch_url` 结果带 `hint`(或 `render_mode` 为 `static_fallback`),说明 obscura 缺失,页面可能没渲染完整。\n\
2. **提议**:主动告诉用户——「obscura 未安装,装了之后 fetch_url 就能渲染 JS 重的 SPA / 反爬文档。要我帮你装吗?」当用户明显需要 JS 渲染时,不要默默用静态回退糊弄过去。\n\
3. **同意即装**:用户同意后,你自己用 `run_command` 执行安装并流式输出(让用户看到进度),按用户环境选合适路径:\n\
- 有 Rust 工具链:`cargo install obscura`\n\
- 否则:从 github.com/h4ckf0r0day/obscura releases 下载对应平台的预编译二进制,放到 PATH 上\n\
(`run_command` 是高风险、需用户批准的工具——上面用户的「同意」即是批准;执行前仍要把确切命令告知用户。)\n\
4. **重试**:装成功后告诉用户「obscura 就绪」,并用 `render=true` 重试 `fetch_url` 拿到渲染完整的 markdown。\n\
这是纯对话式引导——没有专门的 UI 按钮,靠你识别 hint 字段、在对话中驱动安装。\n",
}
}
/// 查 DevFlow 自身配置引导段(中/英):教 AI 查 provider/model/agent 设置时用 get_app_config
/// 工具,不要 run_command 执行 PowerShell 内联 node/python 脚本查 db。
///
/// 治症状:实测 AI 想看「当前 provider/model/agent 配置」时,因没原生查配置工具,只能
/// run_command 执行 PowerShell 内联 node 脚本查 db,命令行引号三重嵌套(PowerShell 单引号
/// 包 node -e 内含 JS 模板字符串/双引号/db 路径)必失败,复盘实证反复踩坑。get_app_config
/// 工具一调即得(只读 + api_key 脱敏),根治绕行。
///
/// 与 doc_exploration_strategy_section 同款「机制治症状」风格:正向指令(用 X)+ 禁令
/// (不要 Y)+ 原因(引号嵌套易失败),而非空泛说教。
fn app_config_query_guidance_section(lang: &str) -> &'static str {
match lang {
"en" => "\n\
## Querying DevFlow's Own Configuration\n\
When you need to inspect DevFlow's own current configuration (which AI provider/model is active, agent iteration/retry limits, concurrency, approval timeout, whether the API key is set), use the `get_app_config` tool — it returns the live config as JSON in one call (read-only, api_key masked). Do **NOT** use `run_command` to run an inline PowerShell node/python script against the database: triple-nested quotes (PowerShell single-quote wrapping node `-e` containing JS template strings / double quotes / db paths) consistently fail. `get_app_config` is the correct path; `run_command` for config inspection is a known failure mode.\n",
_ => "\n\
## 查询 DevFlow 自身配置\n\
需要查看 DevFlow 自身当前配置(当前用哪个 AI provider/model、agent 迭代/重试上限、并发数、审批超时、api_key 是否配置)时,用 `get_app_config` 工具——一次调用即返当前生效配置 JSON(只读,api_key 已脱敏)。**严禁**用 `run_command` 执行 PowerShell 内联 node/python 脚本查数据库:命令行引号三重嵌套(PowerShell 单引号包 node `-e` 内含 JS 模板字符串/双引号/db 路径)必失败。`get_app_config` 是查配置的正解;`run_command` 查配置是已知的反复失败模式。\n",
}
}
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**)
///
/// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 id/name/status/description(无 path),
/// 供 LLM 知道当前存在哪些实体并拿到各自 id(清单含 id,引用时用 id 而非名称)。
///
/// **被@实体的精准投影**(含 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);
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
prompt.push_str(doc_exploration_strategy_section(lang));
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
prompt.push_str(app_config_query_guidance_section(lang));
// 附加当前数据上下文
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!("- {} (id: {}) ({}): {}\n", p.name, p.id, 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!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, 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. Each entry carries its id — reference projects by id, not by name.)\n",
shown
),
_ => format!(
"(以上为活跃项目清单,无需重复调用 list_projects;每条已含 id,引用项目时用 id 而非名称)\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. Each entry carries its id — reference tasks by id, not by title/name.)\n".to_string(),
_ => "(仅显示最近 20 条任务,全量或按项目查询请用 list_tasks 工具;每条已含 id,引用任务时用 id 而非名称)\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 doc_exploration_strategy_section_present_zh_en() {
let zh = doc_exploration_strategy_section("zh-CN");
assert!(zh.contains("## 文档探索策略"));
assert!(zh.contains("fetch_url"));
assert!(zh.contains("http_request"));
assert!(zh.contains("嗅探"));
assert!(zh.contains("识别意图"));
assert!(zh.contains("提取关键"));
assert!(zh.contains("验证"));
assert!(zh.contains("应用"));
let en = doc_exploration_strategy_section("en");
assert!(en.contains("## Documentation Exploration Strategy"));
assert!(en.contains("Sniff"));
assert!(en.contains("Identify intent"));
assert!(en.contains("Extract keys"));
assert!(en.contains("Verify"));
assert!(en.contains("Apply"));
// 工具职责区分子段(中/英)——图片/二进制走 download_file 非 fetch_url(治会话 01f05167 卡死)
assert!(zh.contains("先选对 URL 工具"));
assert!(zh.contains("download_file"));
assert!(zh.contains("仅 HTML 文档"));
assert!(zh.contains("下载任意文件"));
assert!(zh.contains("图片/压缩包/二进制文件 URL"));
assert!(zh.contains("立即改用 `download_file`"));
assert!(en.contains("Pick the right URL tool first"));
assert!(en.contains("download_file"));
assert!(en.contains("HTML docs only"));
assert!(en.contains("download any file"));
assert!(en.contains("Image / archive / binary file URL"));
assert!(en.contains("switch to `download_file` immediately"));
// obscura 安装引导子段(中/英)——看 hint 字段自主提示 + 帮装 + 重试
// 核心:hint 字段是安装信号(非只看 render_mode),同意即用 run_command 装,装完重试 render=true。
assert!(zh.contains("obscura"));
assert!(zh.contains("hint"));
assert!(zh.contains("static_fallback"));
assert!(zh.contains("render_mode"));
assert!(zh.contains("run_command"));
assert!(zh.contains("cargo install obscura"));
assert!(zh.contains("github.com/h4ckf0r0day/obscura"));
assert!(zh.contains("render=true"));
assert!(en.contains("obscura"));
assert!(en.contains("hint"));
assert!(en.contains("static_fallback"));
assert!(en.contains("render_mode"));
assert!(en.contains("run_command"));
assert!(en.contains("cargo install obscura"));
assert!(en.contains("github.com/h4ckf0r0day/obscura"));
assert!(en.contains("render=true"));
// render=true 失败重试铁律(中/英)——失败 2 次换方案 + 成功即收手 + 不重复 render
assert!(zh.contains("render=true 失败重试铁律"));
assert!(zh.contains("失败 2 次必换方案"));
assert!(zh.contains("第 1 次成功即收手"));
assert!(zh.contains("同 URL 不重复 render=true"));
assert!(zh.contains("html=0"));
assert!(zh.contains("obscura_failed"));
assert!(en.contains("render=true failure-retry rule"));
assert!(en.contains("Switch after 2 failures"));
assert!(en.contains("Stop after the first success"));
assert!(en.contains("Never re-render the same URL"));
assert!(en.contains("html=0"));
assert!(en.contains("obscura_failed"));
// 精确提取链接子段(中/英)——治 AI 凭印象臆造 URL(猜 #hash/推测路由)
// 核心:从返回内容提取真实链接原样 fetch,无据不臆造,有据才发请求。
assert!(zh.contains("精确提取链接,严禁臆造 URL"));
assert!(zh.contains("从返回内容提取真实链接"));
assert!(zh.contains("原样 fetch 提取到的 URL"));
assert!(zh.contains("没有明确链接 → 不盲试"));
assert!(zh.contains("API 端点同理"));
assert!(zh.contains("有据可查才发请求,无据绝不臆造 URL"));
assert!(en.contains("Extract real links"));
assert!(en.contains("NEVER fabricate URLs"));
assert!(en.contains("Pull real links out of the response"));
assert!(en.contains("Fetch extracted URLs verbatim"));
assert!(en.contains("No explicit link"));
assert!(en.contains("API endpoints are the same"));
assert!(en.contains("never fabricate a URL"));
}
#[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"));
}
// 查 DevFlow 自身配置引导段(中/英)存在且含关键引导
#[test]
fn app_config_query_guidance_section_present_zh_en() {
let zh = app_config_query_guidance_section("zh-CN");
assert!(zh.contains("查询 DevFlow 自身配置"));
assert!(zh.contains("get_app_config"));
assert!(zh.contains("run_command"));
assert!(zh.contains("引号三重嵌套"));
assert!(zh.contains("严禁"));
let en = app_config_query_guidance_section("en");
assert!(en.contains("Querying DevFlow's Own Configuration"));
assert!(en.contains("get_app_config"));
assert!(en.contains("run_command"));
assert!(en.contains("triple-nested quotes"));
assert!(en.contains("Do **NOT**"));
}
// 项目/任务清单注明语(中/英)存在
#[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"));
}
}