966 lines
58 KiB
Rust
966 lines
58 KiB
Rust
//! 系统提示词构建 + 活跃提供商获取
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::{Arc, Mutex, OnceLock};
|
||
|
||
use df_storage::db::Database;
|
||
use df_storage::models::AiProviderRecord;
|
||
|
||
use crate::commands::err_str;
|
||
use crate::state::{AppState, SettingsNorm};
|
||
|
||
/// 获取当前活跃提供商配置
|
||
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");
|
||
format!("当前日期: {today} | {}", env_profile_stable_body())
|
||
}
|
||
|
||
/// env_profile_line 的静态稳定段(OS + shell + 执行姿势,不含日期)。
|
||
///
|
||
/// Anthropic prompt caching 模式把日期挪到易变段(env_profile_date_volatile,拼首条 user 消息),
|
||
/// 本稳定段留在 system 供缓存命中——日期每日变,留 system 会每日失效整段缓存。
|
||
fn env_profile_stable_body() -> String {
|
||
let (os, shell, interp) = env_os_shell_interp();
|
||
format!(
|
||
"运行环境: {os} | shell: {shell}\n\
|
||
执行姿势: 复杂脚本(多行/含引号/$变量)写成 .ps1 或 .py 文件,用 `powershell -File x.ps1` 或 `python x.py` 执行,避免命令行 -c 内联(引号转义易出错)。可用解释器: {interp}。\n\n"
|
||
)
|
||
}
|
||
|
||
/// 平台对应 (os, shell, 可用解释器) 三元组(cfg! 分支,见 env_profile_line 文档)。
|
||
fn env_os_shell_interp() -> (&'static str, &'static str, &'static str) {
|
||
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")
|
||
}
|
||
}
|
||
|
||
/// Anthropic prompt caching 模式:日期易变段(拼到首条 user 消息,不进 system)。
|
||
fn env_profile_date_volatile() -> String {
|
||
let today = chrono::Local::now().format("%Y-%m-%d");
|
||
format!("今天是 {today}")
|
||
}
|
||
|
||
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)。
|
||
///
|
||
/// prefix 内的 read_symbol 优先与重复调用检测两条经 [`read_symbol_guidance`] /
|
||
/// [`duplicate_call_guidance`] 注入(与子代理工具纪律段同源,零复制)。
|
||
fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
||
match lang {
|
||
"en" => (
|
||
format!(
|
||
"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\
|
||
- {}\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\
|
||
- {}\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",
|
||
read_symbol_guidance(lang),
|
||
duplicate_call_guidance(lang),
|
||
),
|
||
"\n## Current Projects\n",
|
||
"\n## Current Tasks\n",
|
||
),
|
||
_ => (
|
||
format!(
|
||
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
|
||
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
|
||
## 当前能力\n\
|
||
你可以通过工具调用执行以下操作:\n\
|
||
- 创建/查询项目、任务、灵感\n\
|
||
- 运行工作流\n\
|
||
- 读取文件内容、列出目录、创建/写入文件\n\
|
||
- {}\n\
|
||
- 获取文件元信息(大小、行数、二进制检测)\n\
|
||
- 追加写入文件、按名称模式搜索文件\n\n\
|
||
## 行为准则\n\
|
||
- 执行操作前简要说明你的意图\n\
|
||
- 如果不确定用户意图,先提问\n\
|
||
- 优先使用工具完成操作,而不是只描述步骤\n\
|
||
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
|
||
- {}\n\
|
||
## 聚焦准则\n\
|
||
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
|
||
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
|
||
- 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\
|
||
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
|
||
read_symbol_guidance(lang),
|
||
duplicate_call_guidance(lang),
|
||
),
|
||
"\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 末尾(聚焦段后),不破坏现有结构。
|
||
///
|
||
/// 段体拆为两块:核心纪律(URL 三工具分工/探索流程/禁臆造 URL/render=true 铁律)与
|
||
/// obscura 安装引导(涉用户交互与 run_command,父侧专用)。子代理工具纪律段
|
||
/// ([`sub_agent_tool_discipline_section`])只拼核心块。
|
||
fn doc_exploration_strategy_section(lang: &str) -> String {
|
||
format!(
|
||
"{}{}",
|
||
doc_strategy_core_section(lang),
|
||
doc_strategy_obscura_install_section(lang)
|
||
)
|
||
}
|
||
|
||
/// read_symbol 优先指引(中/英,不含行首 "- ")。父会话能力清单与子代理工具纪律段
|
||
/// 同源拼接(DRY,治指引真空:工具下发了纪律没下发,实测翻车坑在子代理原样复发)。
|
||
fn read_symbol_guidance(lang: &str) -> &'static str {
|
||
match lang {
|
||
"en" => "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",
|
||
_ => "理解/定位代码符号(函数/结构体/类等)优先用 read_symbol(path, symbol):返回骨架(签名+行范围+内部调用,极小高密度),避免 read_file 全文回灌撑爆 prompt;需完整定义体传 full=true;read_symbol 支持 .rs/.ts/.tsx/.js/.jsx/.vue,其他语言或未找到符号时再退回 read_file",
|
||
}
|
||
}
|
||
|
||
/// 重复调用检测指引(中/英,不含行首 "- ")。同源拼接见 read_symbol_guidance。
|
||
fn duplicate_call_guidance(lang: &str) -> &'static str {
|
||
match lang {
|
||
"en" => "**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",
|
||
_ => "**重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行",
|
||
}
|
||
}
|
||
|
||
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
|
||
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
|
||
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具,
|
||
/// 纪律必须随工具同源下发,否则父会话实测蒸馏的防坑经验在子循环原样复发。
|
||
/// 裁剪不适用条款:obscura 安装引导(涉用户交互,子代理无交互面)、聚焦准则与
|
||
/// 用户交互类行为准则(执行前说明意图/向用户提问)。
|
||
pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String {
|
||
format!(
|
||
"\n## 工具纪律\n\
|
||
- {}\n\
|
||
- {}\n{}{}",
|
||
read_symbol_guidance(lang),
|
||
duplicate_call_guidance(lang),
|
||
doc_strategy_core_section(lang),
|
||
app_config_query_guidance_section(lang),
|
||
)
|
||
}
|
||
|
||
/// 文档探索策略核心段(中/英):URL 三工具分工 + 嗅探→识别→提取→验证→应用流程 +
|
||
/// 禁臆造 URL + render=true 失败重试铁律。父会话与子代理同源拼接块。
|
||
fn doc_strategy_core_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",
|
||
_ => "\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",
|
||
}
|
||
}
|
||
|
||
/// obscura 安装引导段(中/英,父侧专用):涉用户交互(征得同意帮装)与 run_command 高风险
|
||
/// 工具,子代理无用户交互面,不进子代理工具纪律段([`sub_agent_tool_discipline_section`])。
|
||
/// 与核心段拼接 = 原 [`doc_exploration_strategy_section`] 完整段(逐字节等价)。
|
||
fn doc_strategy_obscura_install_section(lang: &str) -> &'static str {
|
||
match lang {
|
||
// 注:核心段末尾已保留空行(…tactic.\n\n / …换方案。\n\n),本段自标题起拼,
|
||
// 拼接 = 原完整段逐字节等价,不再补前导换行。
|
||
"en" => "### 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",
|
||
_ => "### 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",
|
||
}
|
||
}
|
||
|
||
// ── BE3system prompt 会话内缓存 ──
|
||
//
|
||
// 背景:同一会话连续轮次(每次 send)都全量重建 system prompt——查活跃 project/task 清单 +
|
||
// 逐条 format 拼多 KB 字符串,对高频轮次是纯重复成本。
|
||
//
|
||
// 方案:按「数据指纹」缓存已拼好的 system 串(指纹 = 日期 + lang + custom_prompt + 活跃
|
||
// project/task 的 id+updated_at + 去重排除集)。任何依赖数据变更 → 指纹变 → 重建;未变直接
|
||
// 复用缓存串(省 2 次全行物化查询 + 字符串拼接)。
|
||
//
|
||
// 指纹键而非 conv_id:system prompt 内容本就与 conv_id 无关,同数据跨会话共享同一缓存串,
|
||
// 免 per-conv 生命周期清理(换会话/删会话零维护)。模块级静态(OnceLock+Mutex),不新增
|
||
// AppState 字段,进程内单例,懒初始化。
|
||
//
|
||
// 指纹查询只取轻量列(id+updated_at 两列,省 list_active 的全行物化);查询失败回退「强制重建」,
|
||
// 正确性不受损(只是多一次重建)。缓存按指纹封顶,超上限清空防长跑进程内存膨胀。
|
||
static SYSTEM_PROMPT_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||
|
||
/// 缓存上限(条目)。system prompt 内容按指纹去重后通常仅 1~2 条;上限防御性封顶。
|
||
const SYSTEM_PROMPT_CACHE_MAX: usize = 32;
|
||
|
||
fn system_prompt_cache() -> &'static Mutex<HashMap<String, String>> {
|
||
SYSTEM_PROMPT_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||
}
|
||
|
||
/// 活跃实体(project/task)数据指纹:轻量查 id+updated_at 两列,拼成指纹串。
|
||
/// 返回 None = 查询失败(调用方按「已变更」处理强制重建,正确性不受损)。
|
||
async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<String> {
|
||
let conn = db.conn().clone();
|
||
let result = tokio::task::spawn_blocking(move || {
|
||
let guard = conn.blocking_lock();
|
||
let mut stmt = guard.prepare(sql).ok()?;
|
||
let rows = stmt
|
||
.query_map([], |row| {
|
||
let id: String = row.get(0)?;
|
||
let updated: String = row.get(1)?;
|
||
Ok(format!("{}@{};", id, updated))
|
||
})
|
||
.ok()?;
|
||
let mut out = String::new();
|
||
for r in rows {
|
||
out.push_str(&r.ok()?);
|
||
}
|
||
Some(out)
|
||
})
|
||
.await;
|
||
result.ok().flatten()
|
||
}
|
||
|
||
/// 计算 system prompt 缓存指纹(返回 None 表示指纹依赖查询失败,调用方按 miss 处理强制重建)。
|
||
///
|
||
/// 指纹覆盖全部依赖输入:
|
||
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
|
||
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
|
||
/// - custom_prompt:与 build 内读的同一设置,变更即失效。
|
||
/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。
|
||
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
|
||
async fn system_prompt_fingerprint(
|
||
state: &AppState,
|
||
lang: &str,
|
||
excl_proj: &[String],
|
||
excl_task: &[String],
|
||
) -> Option<String> {
|
||
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||
let custom = state
|
||
.settings
|
||
.get("custom_prompt")
|
||
.await
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default();
|
||
let proj_fp = entity_fingerprint(
|
||
&state.db,
|
||
"SELECT id, updated_at FROM projects WHERE deleted_at IS NULL",
|
||
)
|
||
.await?;
|
||
let task_fp = entity_fingerprint(
|
||
&state.db,
|
||
"SELECT id, updated_at FROM tasks WHERE deleted_at IS NULL",
|
||
)
|
||
.await?;
|
||
let mut key = format!("{date}|{lang}|{custom}|{proj_fp}|{task_fp}");
|
||
for id in excl_proj {
|
||
key.push_str("|p:");
|
||
key.push_str(id);
|
||
}
|
||
for id in excl_task {
|
||
key.push_str("|t:");
|
||
key.push_str(id);
|
||
}
|
||
Some(key)
|
||
}
|
||
|
||
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**)
|
||
///
|
||
/// 本函数注入"全貌"清单:最近 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`] 传两个空排除列表(无去重)。
|
||
///
|
||
/// 注:caching 拆分后主链(send/regenerate/edit/force_send/续跑)已改走
|
||
/// [`build_system_prompt_parts`](开关关逐字等价),本 wrapper 暂零调用方——保留作预留 API
|
||
/// (文档/文档注释多处引用,后续非缓存场景调用点回迁即用),标 allow 不盲删。
|
||
#[allow(dead_code)]
|
||
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 {
|
||
// BE3:先算指纹查缓存,命中直接复用已拼好的 system 串(省查库拼清单)。
|
||
let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids).await;
|
||
if let Some(key) = &fp_key {
|
||
let cache = system_prompt_cache();
|
||
if let Some(cached) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(key) {
|
||
return cached.clone();
|
||
}
|
||
}
|
||
let (prefix, _, _) = 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));
|
||
|
||
// 当前数据上下文(项目/任务全局清单)+ 自定义提示词 —— 拆出共用 helper,行为逐字等价。
|
||
prompt.push_str(&build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await);
|
||
prompt.push_str(&build_custom_section(state).await);
|
||
|
||
// BE3:未命中 → 存入缓存供下轮复用(按指纹封顶,超上限清空防长跑进程内存膨胀)。
|
||
if let Some(key) = fp_key {
|
||
let mut cache = system_prompt_cache().lock().unwrap_or_else(|p| p.into_inner());
|
||
if cache.len() >= SYSTEM_PROMPT_CACHE_MAX {
|
||
cache.clear();
|
||
}
|
||
cache.insert(key, prompt.clone());
|
||
}
|
||
prompt
|
||
}
|
||
|
||
/// 项目/任务全局清单段(DRY:主构建 [`build_system_prompt_with_excluded`] 与 caching 易变段
|
||
/// [`build_system_prompt_parts`] 共用)。
|
||
///
|
||
/// 拼接项目段 + 任务段(含机制层注明语);无项目/任务返回空串。
|
||
/// 语义与旧 build_system_prompt_with_excluded 内联块逐字一致(纯提取,零行为变更)。
|
||
async fn build_context_lists(
|
||
state: &AppState,
|
||
excluded_project_ids: &[String],
|
||
excluded_task_ids: &[String],
|
||
lang: &str,
|
||
) -> String {
|
||
let (_, proj_label, task_label) = system_prompt_parts(lang);
|
||
let mut out = String::new();
|
||
// 附加当前数据上下文
|
||
if let Ok(projects) = state.projects.list_active().await {
|
||
if !projects.is_empty() {
|
||
out.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;
|
||
}
|
||
out.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description));
|
||
if let Some(ref dir) = p.path {
|
||
out.push_str(&format!(" 目录: {}\n", dir));
|
||
}
|
||
}
|
||
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
|
||
out.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() {
|
||
out.push_str(task_label);
|
||
// 去重:被 @ 的任务跳过(同项目去重机理)
|
||
for tk in tasks.iter().take(20) {
|
||
if excluded_task_ids.iter().any(|id| id == &tk.id) {
|
||
continue;
|
||
}
|
||
out.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description));
|
||
}
|
||
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
|
||
out.push_str(&tasks_listed_note(lang));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 自定义提示词段(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。
|
||
async fn build_custom_section(state: &AppState) -> String {
|
||
// get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),再 trim 空白;空返回空段。
|
||
let clean = state
|
||
.settings
|
||
.get_string_norm("custom_prompt")
|
||
.await
|
||
.unwrap_or_default();
|
||
if !clean.trim().is_empty() {
|
||
return format!("\n## 自定义指令\n{}\n", clean.trim());
|
||
}
|
||
String::new()
|
||
}
|
||
|
||
/// 当前 provider 是否支持 prompt caching 拆分(同一 settings 开关下的两类受益方):
|
||
/// - anthropic / anthropic_compat:显式缓存(system+消息级 cache_control 断点);
|
||
/// - openai_compat:自动前缀缓存(DeepSeek/GLM/Qwen 等,无需 cache_control 标记,
|
||
/// 只要求前缀字节稳定,拆分后易变段挪到消息流末尾即命中)。
|
||
pub(crate) fn cache_split_provider_type(provider_type: &str) -> bool {
|
||
matches!(
|
||
provider_type,
|
||
"anthropic" | "anthropic_compat" | "openai_compat"
|
||
)
|
||
}
|
||
|
||
/// 读取 prompt caching 拆分开关(AppState settings KV `df-ai-anthropic-cache`,默认关)
|
||
/// 并同步 df-ai 进程级显式缓存开关。
|
||
///
|
||
/// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的端点显式开启。
|
||
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
|
||
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
|
||
/// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。
|
||
pub(crate) async fn cache_enabled(state: &AppState) -> bool {
|
||
let v = state
|
||
.settings
|
||
.get("df-ai-anthropic-cache")
|
||
.await
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default();
|
||
let enabled = v == "true" || v == "1";
|
||
df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled);
|
||
enabled
|
||
}
|
||
|
||
/// 构建 system prompt(Anthropic prompt caching 模式)。
|
||
///
|
||
/// 返回 `(system 稳定段, 易变尾段)`:
|
||
/// - 开关关(`cache_enabled=false`)→ `(完整 system, 空串)`:与 [`build_system_prompt_with_excluded`]
|
||
/// 逐字等价(零行为变更,兼容非官方网关)。
|
||
/// - 开关开 → `(静态稳定段, 易变段)`:日期/项目/任务清单从 system 挪出,由上层把易变段
|
||
/// **追加到消息流末尾(user 消息,不进 system → 不失效 system 缓存)**。放消息流末尾而非
|
||
/// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容
|
||
/// 不会失效已缓存前缀(跨天缓存仍命中)。
|
||
///
|
||
/// 注意:调用方须先按「开关开 AND 当前 provider 支持缓存拆分(anthropic 系/openai_compat)」
|
||
/// gate 再传 `cache_enabled`,保证开关关时全部 provider 零影响(逐字等价旧路径)。
|
||
pub(crate) async fn build_system_prompt_parts(
|
||
state: &AppState,
|
||
lang: &str,
|
||
excluded_project_ids: &[String],
|
||
excluded_task_ids: &[String],
|
||
cache_enabled: bool,
|
||
) -> (String, String) {
|
||
if !cache_enabled {
|
||
let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids).await;
|
||
return (full, String::new());
|
||
}
|
||
// 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索 + 查配置引导 + 自定义),
|
||
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
|
||
let (prefix, _, _) = system_prompt_parts(lang);
|
||
let mut stable = env_profile_stable_body();
|
||
stable.push_str(&prefix);
|
||
stable.push_str(&doc_exploration_strategy_section(lang));
|
||
stable.push_str(app_config_query_guidance_section(lang));
|
||
stable.push_str(&build_custom_section(state).await);
|
||
|
||
let lists = build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await;
|
||
let volatile = if lists.is_empty() {
|
||
env_profile_date_volatile()
|
||
} else {
|
||
format!("{}\n\n{}", env_profile_date_volatile(), lists)
|
||
};
|
||
(stable, volatile)
|
||
}
|
||
|
||
/// 项目清单尾部注明语(中/英)。
|
||
///
|
||
/// 机制层降重复:直接告知「已全部列出」,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**"));
|
||
}
|
||
|
||
// P1-1 子代理工具纪律段:与父会话同源(read_symbol/重复调用检测/URL 纪律/查配置),
|
||
// 且裁剪掉用户交互条款(obscura 安装引导,子代理无交互面)。
|
||
#[test]
|
||
fn sub_agent_tool_discipline_section_shares_parent_text() {
|
||
let zh = sub_agent_tool_discipline_section("zh-CN");
|
||
assert!(zh.contains("## 工具纪律"));
|
||
assert!(zh.contains(read_symbol_guidance("zh-CN")), "read_symbol 指引须与父会话同源");
|
||
assert!(zh.contains(duplicate_call_guidance("zh-CN")), "重复调用检测须与父会话同源");
|
||
assert!(zh.contains("## 文档探索策略"), "URL 纪律核心段须注入");
|
||
assert!(zh.contains("严禁臆造 URL"));
|
||
assert!(zh.contains("失败 2 次必换方案"));
|
||
assert!(zh.contains("get_app_config"));
|
||
assert!(
|
||
!zh.contains("要我帮你装吗"),
|
||
"obscura 安装引导为用户交互条款,子代理不得注入: {}",
|
||
zh
|
||
);
|
||
assert!(!zh.contains("## 聚焦准则"), "会话面准则不进子代理");
|
||
|
||
// 父会话 prefix 与共享函数同字同源(DRY 锁:改共享函数父侧同步生效)
|
||
let (prefix, _, _) = system_prompt_parts("zh-CN");
|
||
assert!(prefix.contains(read_symbol_guidance("zh-CN")));
|
||
assert!(prefix.contains(duplicate_call_guidance("zh-CN")));
|
||
|
||
// 父侧完整文档探索段 = 核心 + obscura 引导(拆分不丢段)
|
||
let parent_doc = doc_exploration_strategy_section("zh-CN");
|
||
assert!(parent_doc.contains("失败 2 次必换方案"));
|
||
assert!(parent_doc.contains("要我帮你装吗"));
|
||
}
|
||
|
||
// 项目/任务清单注明语(中/英)存在
|
||
#[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"));
|
||
}
|
||
}
|