优化: 缓存命中字段解析与提示词前缀稳定化

- openai 兼容口径补解析嵌套 cached_tokens
- cache 显示区分无数据与真零,估算轮不渲染
- 轨迹展开折叠空过程记录行降噪
- token 统计行恒完整可见不截断
- openai_compat 纳入 system 稳定易变拆分
- 系统警示与主题标记改尾部注入不位移前缀
- 工具结果视图压缩按入库时刻冻结
- 会话内工具集冻结仅增不减
- anthropic 缓存补消息级断点,设置页加开关
- 补 intent 测试对 Agent domain 覆盖
This commit is contained in:
lxy
2026-10-01 19:35:21 +08:00
parent 2187ffe173
commit 423acfdd00
14 changed files with 499 additions and 99 deletions
+120 -2
View File
@@ -43,8 +43,9 @@ use crate::anthropic_helpers::{
/// API 网关——**cache_control 字段可能被非官方网关拒收**,故必须默认关、用户对支持的端点显式开启。
///
/// 开关开:`convert_request` 把 system 包成 blocks 数组并附加 `cache_control:{type:ephemeral}`
/// (配合上层把易变段挪出 system,让 system 稳定段真正命中缓存)。
/// 开关关:system 保持纯字符串(现状行为,零风险兼容网关)。
/// (配合上层把易变段挪出 system,让 system 稳定段真正命中缓存),
/// 并在历史段稳定边界补消息级断点(使 system 之后的对话历史也可命中)。
/// 开关关:system 保持纯字符串、消息不打断点(现状行为,零风险兼容网关)。
///
/// 进程级全局而非 per-provider:上层(AppState settings)按「用户对当前启用的 anthropic 端点
/// 是否支持缓存」一次性开启,所有 anthropic 请求一致生效。默认关。
@@ -239,6 +240,12 @@ impl AnthropicCompatProvider {
// (正常会话首条本就是 user),远优于丢弃上下文。
Self::ensure_leading_user(&mut messages);
// 消息级缓存断点(开关开):除 system 断点外在历史段尾部再打一个 cache_control,
// 使 system 之后的对话历史也可命中(否则只缓存 system,长会话历史段全价重算)。
if anthropic_cache_enabled() {
Self::apply_message_cache_breakpoint(&mut messages);
}
let tools = req.tools.map(|defs| {
defs.into_iter()
.map(|d| AnthropicToolDef {
@@ -316,6 +323,45 @@ impl AnthropicCompatProvider {
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
}
/// 消息级缓存断点:在「历史段稳定边界」消息的末个 content block 上打 cache_control。
///
/// 断点落点取**末条 user 消息之前的相邻前一条**(末条非 user 收尾的异常序列则取末条):
/// 末条 user 是每轮请求的漂移边界——缓存拆分模式下它是请求末尾追加的易变尾段
/// (日期/清单/augmentation,每次重组),非拆分模式下是本轮新问题,二者都不属于
/// 只增历史;打在它身上断点位置随请求漂移,永远无法命中。标其前一条(assistant /
/// tool_result 归并的 user)则断点始终落在只增历史的末尾,历史只增不改位:
/// 上一轮写入的缓存前缀是本轮请求前缀的逐字节前缀 → 命中后增量续写,滚动前进。
///
/// content 形态:string(简写)→ 升级为单 text block 数组再标;array(blocks)→ 标末块。
/// 断点预算:Anthropic 单请求最多 4 个 cache_control,system 1 个 + 消息级 1 个,余量充足。
fn apply_message_cache_breakpoint(messages: &mut [serde_json::Value]) {
let n = messages.len();
if n == 0 {
return;
}
let last_is_user =
messages[n - 1].get("role").and_then(|r| r.as_str()) == Some("user");
let idx = if last_is_user && n >= 2 { n - 2 } else { n - 1 };
let msg = &mut messages[idx];
let cache_control = serde_json::json!({ "type": "ephemeral" });
match msg.get("content").cloned() {
Some(serde_json::Value::Array(mut blocks)) => {
if let Some(last_block) = blocks.last_mut() {
last_block["cache_control"] = cache_control;
}
msg["content"] = serde_json::Value::Array(blocks);
}
Some(serde_json::Value::String(text)) => {
msg["content"] = serde_json::json!([{
"type": "text",
"text": text,
"cache_control": cache_control,
}]);
}
_ => {}
}
}
/// 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
/// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。
/// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发,
@@ -752,6 +798,14 @@ mod tests {
use super::*;
// apply_anthropic_event / TokenUsage 经 super::*(含 anthropic_helpers::apply_anthropic_event 的 use)可见。
/// convert_request 读取进程级缓存开关(全局 AtomicBool)。开关切换用例的「开」窗口内,
/// 并行运行的普通转换用例会带上缓存形态(system blocks / 消息级断点)致断言互踩。
/// 所有走 convert_request 的用例统一先取本锁串行化,消除进程级状态的用例间污染。
static CONVERT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn convert_lock() -> std::sync::MutexGuard<'static, ()> {
CONVERT_LOCK.lock().unwrap_or_else(|p| p.into_inner())
}
/// 辅助:构造 message_start 事件
fn message_start(input_tokens: u32) -> String {
format!(
@@ -945,6 +999,7 @@ mod tests {
/// 含图 user 消息 → content blocks(text + image source.base64)
#[test]
fn anthropic_convert_multimodal_user_blocks() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
@@ -980,6 +1035,7 @@ mod tests {
/// 纯文本 user 消息 → content 仍是字符串简写(无图不数组化)
#[test]
fn anthropic_convert_text_only_user_remains_string() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
@@ -1001,6 +1057,62 @@ mod tests {
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
}
// ---------- 消息级缓存断点(apply_message_cache_breakpoint,纯函数直测) ----------
// 注:不开全局 ANTHROPIC_CACHE_ENABLED(convert_request 层开关默认关,已有断言不受扰),
// 直接测纯函数落点,避免进程级开关与并行测试互踩。
/// 末条 user(漂移边界:易变尾段/本轮新问题)不打断点,标其前一条稳定历史消息。
#[test]
fn cache_breakpoint_marks_message_before_trailing_user() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": [{"type": "text", "text": "a1"}] }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
// 前一条 assistant 的末块带 cache_control
let a1 = &messages[1];
let blocks = a1["content"].as_array().unwrap();
assert_eq!(blocks.last().unwrap()["cache_control"]["type"], "ephemeral");
// 末条 user 保持字符串简写,未被打点
assert_eq!(messages[2]["content"].as_str(), Some("q2"));
}
/// string content 升级为单 text block 数组并附带 cache_control。
#[test]
fn cache_breakpoint_upgrades_string_content_to_blocks() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "q1" }),
serde_json::json!({ "role": "assistant", "content": "a1" }),
serde_json::json!({ "role": "user", "content": "q2" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
let blocks = messages[1]["content"].as_array().expect("string 应升级为 blocks 数组");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], "a1");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
/// 单条消息(无前一条可标)→ 标自身,不 panic。
#[test]
fn cache_breakpoint_single_message_marks_itself() {
let mut messages = vec![
serde_json::json!({ "role": "user", "content": "only" }),
];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
let blocks = messages[0]["content"].as_array().expect("单条应升级为 blocks");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
/// 空 messages 不 panic。
#[test]
fn cache_breakpoint_empty_messages_noop() {
let mut messages: Vec<serde_json::Value> = vec![];
AnthropicCompatProvider::apply_message_cache_breakpoint(&mut messages);
assert!(messages.is_empty());
}
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息
@@ -1016,6 +1128,7 @@ mod tests {
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
#[test]
fn anthropic_ensure_leading_user_tool_use_chain_preserves_context() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
@@ -1054,6 +1167,7 @@ mod tests {
/// 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
#[test]
fn anthropic_ensure_leading_user_plain_assistant() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
@@ -1080,6 +1194,7 @@ mod tests {
/// 正常序列(user 开头)不补占位——零回归验证。
#[test]
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
@@ -1107,6 +1222,7 @@ mod tests {
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
#[test]
fn anthropic_ensure_leading_user_three_round_chain() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
@@ -1144,6 +1260,7 @@ mod tests {
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
#[test]
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
let _convert = convert_lock();
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let req = CompletionRequest {
model: "claude-3-5-sonnet".into(),
@@ -1173,6 +1290,7 @@ mod tests {
/// (两态共用进程级全局开关,拆两个用例并行跑会互相污染——单测内顺序切换断言,末尾复位。)
#[test]
fn anthropic_cache_switch_controls_system_shape() {
let _convert = convert_lock();
set_anthropic_cache_enabled(false); // 兜底复位,防污染其他并行用例
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
+3 -1
View File
@@ -1273,9 +1273,10 @@ mod tests {
];
for intent in all_intents {
let subset = tool_subset_for(&intent);
// 全 registry 工具名(五 domain 并集:Data + DataReadOnly + File + Exec + Http)
// 全 registry 工具名(六 domain 并集:Data + DataReadOnly + File + Exec + Http + Agent)
// 注:DataReadOnly 工具名是 Data 的子集,chain 它仅为语义显式(并集去重无副作用),
// 防 Code subset 里 list_projects 等 DataReadOnly 工具被判"不在 registry"。
// Agent(spawn_agent)是编排委派基本盘成员,子集合法出现,漏 chain 会误报。
let registry: std::collections::HashSet<&str> = ToolDomain::Data
.tools()
.iter()
@@ -1283,6 +1284,7 @@ mod tests {
.chain(ToolDomain::File.tools().iter())
.chain(ToolDomain::Exec.tools().iter())
.chain(ToolDomain::Http.tools().iter())
.chain(ToolDomain::Agent.tools().iter())
.copied()
.collect();
for name in &subset {
+115 -9
View File
@@ -97,25 +97,78 @@ pub(crate) struct OpenAiFunctionResp {
pub arguments: String,
}
/// OpenAI 标准嵌套缓存口径形态(usage.prompt_tokens_details):
/// GLM/Qwen/OpenAI 官方在嵌套结构报缓存命中,DeepSeek 用扁平 prompt_cache_hit_tokens。
/// cached_tokens 用 Option 容错给空 details 对象的网关。
#[derive(Debug, Default, Deserialize)]
pub(crate) struct PromptTokensDetails {
#[serde(default)]
pub cached_tokens: Option<u64>,
}
/// usage 反序列化中间载体:与 OpenAiUsage 字段同构,经 `From` 做缓存口径归一后产出
/// OpenAiUsage(见 impl From)。流式 SSE 与非流式 JSON 两条解析路径因此都自动获得
/// 嵌套 cached_tokens 回填,消费方一律读归一后的扁平字段。
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiUsageRaw {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner)。无此字段 → default 0。
#[serde(default)]
pub prompt_cache_hit_tokens: u32,
/// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。无此字段 → default 0。
#[serde(default)]
pub prompt_cache_miss_tokens: u32,
/// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。
/// 非 reasoning 模型无此字段 → default 0。
#[serde(default)]
pub reasoning_tokens: u32,
/// OpenAI 标准嵌套缓存口径(GLM/Qwen/OpenAI 官方)。
#[serde(default)]
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
/// OpenAI usage(缓存口径已归一)
///
/// 归一发生在反序列化时(`#[serde(from)]`):扁平 hit 有值优先(DeepSeek 自报 hit+miss);
/// 扁平 hit 未报而嵌套 cached_tokens > 0 时回填 hit,miss 推断为 prompt - hit(嵌套口径
/// 缺全价分项);两种口径都没有则保持 0。消费方(apply_openai_sse / openai_compat 非流式)
/// 直接读扁平字段,无需感知 provider 差异。
#[derive(Debug, Deserialize)]
#[serde(from = "OpenAiUsageRaw")]
pub(crate) struct OpenAiUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
/// DeepSeek 扩展:缓存命中 token(低价,deepseek-chat/reasoner prompt_cache_hit_tokens)。
/// OpenAI 官方(o1 等)无此字段 → serde default 0。其他 OpenAI 兼容网关若支持 cache 也用此名。
#[serde(default)]
/// 缓存命中 token(低价;扁平优先,缺失时回填自嵌套 cached_tokens)
pub prompt_cache_hit_tokens: u32,
/// DeepSeek 扩展:未命中 token(全价真实输入,prompt_cache_miss_tokens)。
/// OpenAI 官方无此字段 → serde default 0。
#[serde(default)]
/// 缓存未命中 token(全价真实输入;嵌套口径推断时 = prompt - hit)
pub prompt_cache_miss_tokens: u32,
/// DeepSeek-reasoner / OpenAI o1 扩展:思考 token(隐藏输出,reasoning_tokens)。
/// 非 reasoning 模型无此字段 → serde default 0。
#[serde(default)]
/// 思考 token(隐藏输出)
pub reasoning_tokens: u32,
}
impl From<OpenAiUsageRaw> for OpenAiUsage {
fn from(raw: OpenAiUsageRaw) -> Self {
let (hit, miss) = match raw.prompt_tokens_details.and_then(|d| d.cached_tokens) {
Some(nested) if nested > 0 && raw.prompt_cache_hit_tokens == 0 => {
let hit = u32::try_from(nested).unwrap_or(u32::MAX);
(hit, raw.prompt_tokens.saturating_sub(hit))
}
_ => (raw.prompt_cache_hit_tokens, raw.prompt_cache_miss_tokens),
};
Self {
prompt_tokens: raw.prompt_tokens,
completion_tokens: raw.completion_tokens,
total_tokens: raw.total_tokens,
prompt_cache_hit_tokens: hit,
prompt_cache_miss_tokens: miss,
reasoning_tokens: raw.reasoning_tokens,
}
}
}
/// SSE 流式响应 chunk
#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiStreamChunk {
@@ -287,3 +340,56 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// DeepSeek 扁平口径:hit/miss 各取其值,不参与嵌套回填
#[test]
fn usage_deepseek_flat_cache() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}"#,
)
.unwrap();
assert_eq!(u.prompt_tokens, 100);
assert_eq!(u.prompt_cache_hit_tokens, 64);
assert_eq!(u.prompt_cache_miss_tokens, 36);
}
/// OpenAI/GLM 嵌套口径:扁平未报,hit 回填自 cached_tokens,miss 推断为 prompt - hit
#[test]
fn usage_nested_cached_tokens_backfill() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,
"prompt_tokens_details":{"cached_tokens":64}}"#,
)
.unwrap();
assert_eq!(u.prompt_cache_hit_tokens, 64);
assert_eq!(u.prompt_cache_miss_tokens, 36);
}
/// 两种口径都没有:hit/miss 保持 0(前端不渲染 cache 段)
#[test]
fn usage_no_cache_fields() {
let u: OpenAiUsage = serde_json::from_str(
r#"{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}"#,
)
.unwrap();
assert_eq!(u.prompt_cache_hit_tokens, 0);
assert_eq!(u.prompt_cache_miss_tokens, 0);
}
/// 端到端:usage-only chunk 经 apply_openai_sse 累加路径带出归一后的缓存分项
#[test]
fn sse_usage_chunk_nested_cache_reaches_token_usage() {
let data = r#"{"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,
"total_tokens":110,"prompt_tokens_details":{"cached_tokens":64}}}"#;
let mut accum = None;
let chunk = apply_openai_sse(data, &mut accum);
let usage = chunk.usage.expect("usage chunk 应携带 usage");
assert_eq!(usage.prompt_cache_hit_tokens, 64);
assert_eq!(usage.prompt_cache_miss_tokens, 36);
}
}
+13 -6
View File
@@ -11,7 +11,9 @@ use df_ai::provider::ChatMessage;
use crate::state::AppState;
use super::approval_timeout;
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider};
use crate::commands::ai::prompt::{
build_system_prompt_parts, cache_enabled, cache_split_provider_type, get_active_provider,
};
use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use crate::commands::ai::{AiChatEvent, AiSession, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState;
@@ -20,9 +22,14 @@ use super::conv_state::ConvState;
///
/// 抽自 4 处同构骨架(insert_destructive_loop_warning / insert_stall_warning /
/// insert_tool_call_over_limit_warning / audit::cache insert_listing_bypass_warning):
/// 「lock → per_conv.contains_key 守卫 → messages.insert_at(0, system) → tracing::info」。
/// 「lock → per_conv.contains_key 守卫 → messages.push(system) → tracing::info」。
/// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。
///
/// **尾部追加而非 insert_at(0)**:历史头部插入会位移整条前缀,叠加警示文本逐条不同,
/// 自动前缀缓存(DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。警示语义都是
/// 「提示当前轮」,追加到消息流末尾对 LLM 同样可见(紧邻本轮 tool_result 之后),
/// 且不破坏既有前缀字节。文本统一不含轮次计数(同类警示文本字节稳定)。
///
/// `log_tag` 为日志正文(不带 "[ai] " 前缀,helper 统一补);各站点特有字段
/// (iteration/loop_count 等)由调用方编入 tag 文本保留定位信息,helper 统一打 conv_id 字段。
pub(crate) async fn insert_system_notice(
@@ -34,7 +41,7 @@ pub(crate) async fn insert_system_notice(
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(text));
conv.messages.push(ChatMessage::system(text));
tracing::info!(conv_id = %conv_id, "[ai] {}", log_tag);
}
}
@@ -121,10 +128,10 @@ pub async fn try_continue_agent_loop(
};
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
let conv_id_owned = conv_id.to_string();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价;仅 anthropic provider 拆分,
// OpenAI 零影响)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
// prompt caching 拆分模式组装(开关关 → 与旧路径逐字等价;anthropic 系显式缓存 /
// openai_compat 前缀稳定)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
let cache_on = cache_enabled(state).await
&& matches!(provider_config.provider_type.as_str(), "anthropic" | "anthropic_compat");
&& cache_split_provider_type(&provider_config.provider_type);
let (system_prompt, base_volatile) =
build_system_prompt_parts(state, &lang, &[], &[], cache_on).await;
+83 -24
View File
@@ -680,22 +680,66 @@ pub(crate) async fn run_agentic_loop(
// 分离:即使将来 PLANNING_ENABLED 翻 true,plan_hint 内部 PLAN_HINT_ENABLED 关闭时
// filter_tool_defs_planned 仍退扁平(双层开关,任一关闭均退旧行为)。
let tool_defs: Vec<df_ai::provider::ToolDefinition> = if pure_greeting {
Vec::new() // 纯问候 → 空工具集(tools=None,强制纯文本回复)
} else if conf >= INTENT_CONF_THRESHOLD {
let filtered = if df_ai::planner::PLANNING_ENABLED {
// Phase 1:plan_hint 编排排序。intent_label 供 plan_hint 备用(当前规则纯关键词驱动)。
filter_tool_defs_planned(&all_defs, &intent, intent.as_str(), &user_text)
} else {
// 旧行为:intent 收敛扁平子集(零行为变更,flag 默认关走此路)。
filter_tool_defs(&all_defs, &intent)
};
if filtered.len() < 3 {
all_defs // 兜底:过滤<3(漂移/误收敛)回全量
} else {
filtered
}
Vec::new() // 纯问候 → 空工具集(tools=None,强制纯文本回复;仅作用当轮,不写冻结集)
} else {
all_defs // 低置信 fallback 全量
let computed: Vec<df_ai::provider::ToolDefinition> = if conf >= INTENT_CONF_THRESHOLD {
let filtered = if df_ai::planner::PLANNING_ENABLED {
// Phase 1:plan_hint 编排排序。intent_label 供 plan_hint 备用(当前规则纯关键词驱动)。
filter_tool_defs_planned(&all_defs, &intent, intent.as_str(), &user_text)
} else {
// 旧行为:intent 收敛扁平子集(零行为变更,flag 默认关走此路)。
filter_tool_defs(&all_defs, &intent)
};
if filtered.len() < 3 {
all_defs.clone() // 兜底:过滤<3(漂移/误收敛)回全量
} else {
filtered
}
} else {
all_defs.clone() // 低置信 fallback 全量
};
// 跨轮工具集冻结(前缀稳定):tools 数组在请求中位于 system 之后、消息之前,
// 每轮按末条 user 消息重新裁剪会让数组跨轮变化 → 前缀字节断,自动前缀缓存
// (DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。改按会话冻结:首次裁剪结果
// 入冻结集,后续轮只增不减(新意图带来的工具并入,已并入的不移除)。
// 顺序稳定:冻结集是 all_defs 的子集过滤,all_defs 顺序恒定 → 数组字节只增不改位。
let mut frozen_names = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.and_then(|c| c.frozen_tool_names.clone())
};
let mut changed = false;
match frozen_names.as_mut() {
Some(prev) => {
for d in &computed {
if !prev.contains(&d.function.name) {
prev.push(d.function.name.clone());
changed = true;
}
}
}
None => {
if !computed.is_empty() {
frozen_names = Some(computed.iter().map(|d| d.function.name.clone()).collect());
changed = true;
}
}
}
if changed {
let mut session = session_arc.lock().await;
// contains_key 守卫防 conv() 惰性建(循环入口已桥接,正常恒存在,防御性保留)。
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).frozen_tool_names = frozen_names.clone();
}
}
match &frozen_names {
Some(names) => all_defs
.into_iter()
.filter(|d| names.contains(&d.function.name))
.collect(),
None => computed,
}
};
tracing::info!(
conv_id = %conv_id,
@@ -1294,10 +1338,12 @@ async fn prepare_round_inputs(
"── 用户已切换话题(从「{}」到「{}」),请以新话题为准 ──",
old_topic, new_topic
);
// 尾部追加而非 insert_at(0):头部插入位移整条前缀破坏 prompt 缓存,
// 语义「提示当前轮」放末尾同样生效(紧邻本轮内容,LLM 必见)。
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(&marker_text));
conv.messages.push(ChatMessage::system(&marker_text));
tracing::info!(
conv_id = %conv_id,
iteration,
@@ -1429,15 +1475,26 @@ async fn prepare_round_inputs(
// 故即使摘要有误/过度压缩,下次 build_for_request 仍从 DB 全量重建,可自愈。
//
// TOOL_RESULT_COMPRESS_ENABLED=false(常量开关)→ 跳过(原样送 LLM,排障/对比用)。
// 压缩决策经 per_conv.tool_view_decisions 冻结:首次判定后沿用,不再随阈值翻转
// (防同一条历史跨轮在全文/摘要间翻转致前缀断)。写回仅在 conv 存活时做(防惰性建)。
let messages: Vec<ChatMessage> = if TOOL_RESULT_COMPRESS_ENABLED {
let history_tokens_snapshot: u32 = {
let (history_tokens_snapshot, mut view_decisions) = {
let session = session_arc.lock().await;
session
.conv_read(conv_id)
.map(|c| c.messages.history_tokens())
.unwrap_or(0)
let conv = session.conv_read(conv_id);
(
conv.map(|c| c.messages.history_tokens()).unwrap_or(0),
conv.map(|c| c.tool_view_decisions.clone()).unwrap_or_default(),
)
};
summarize_tool_results(messages, history_tokens_snapshot, conv_id, iteration)
let messages =
summarize_tool_results(messages, history_tokens_snapshot, &mut view_decisions, conv_id, iteration);
if !view_decisions.is_empty() {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
session.conv(conv_id).tool_view_decisions = view_decisions;
}
}
messages
} else {
messages
};
@@ -1767,7 +1824,7 @@ async fn heartbeat_loop(
/// 注入目标验证续轮 nudge(强制续轮,非软提示)。
///
/// 文案随 goal_verify_rounds 递进:第 1 次请验证,第 2 次(将达上限)明确最后机会。
/// 复用 insert_stall_warning 的 insert_at(0, system) 范式 + fetch_goal_summary 拼目标。
/// 复用 insert_stall_warning 的尾部追加 system 范式 + fetch_goal_summary 拼目标。
async fn insert_goal_verify_nudge(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
@@ -1789,7 +1846,9 @@ async fn insert_goal_verify_nudge(
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(&nudge_text));
// 尾部追加而非 insert_at(0):nudge 紧邻本轮内容语义不变,且不位移既有前缀字节
// (insert_at(0) 会把整条历史后移,自动前缀缓存/Anthropic 显式缓存全失效)。
conv.messages.push(ChatMessage::system(&nudge_text));
tracing::info!(
conv_id = %conv_id, iteration, verify_rounds,
"[GOAL-VERIFY] 目标未验证,注入续轮 nudge(强制续轮)"
@@ -223,9 +223,14 @@ fn is_failure_content(content: &str) -> bool {
// ── summarize_tool_results: tool_result view-only 摘要压缩(扁平抽自原嵌套 map 块) ──
// 超阈值的 Tool 消息用 extract_key_info 压缩 content;非 Tool / 未超阈值 原样返回。
// 仅改入参 Vec(clone),不改 ContextManager 持久化(下次 build_for_request 从 DB 全量重建)。
//
// 压缩决策冻结(前缀稳定):判定结果按消息内容哈希记入 `view_decisions`(首次见到时按
// 当轮阈值判定并写入),后续轮只查表沿用、不再随 history_tokens 增长翻转——否则同一条
// 历史第 N 轮发全文、第 N+1 轮变摘要,轮中前缀必断(自动前缀缓存/显式缓存全失效)。
pub(crate) fn summarize_tool_results(
messages: Vec<ChatMessage>,
history_tokens_snapshot: u32,
view_decisions: &mut std::collections::HashMap<u64, bool>,
conv_id: &str,
iteration: usize,
) -> Vec<ChatMessage> {
@@ -246,10 +251,6 @@ pub(crate) fn summarize_tool_results(
return m;
}
let content_len = m.content.len();
let content_tokens = est.estimate_text(&m.content);
if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) {
return m;
}
// 解析真实工具名:tool_call_id 查表;查不到(异常/老数据)退化为 "tool"。
let resolved_name = m
.tool_call_id
@@ -259,6 +260,7 @@ pub(crate) fn summarize_tool_results(
.unwrap_or("tool");
// read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。
// read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。
// 豁免判定在决策记录之前(豁免消息不进决策表,每轮恒豁免无漂移)。
if resolved_name == "read_file" {
tracing::debug!(
conv_id = %conv_id, iteration,
@@ -267,6 +269,21 @@ pub(crate) fn summarize_tool_results(
);
return m;
}
// 冻结决策:首次见到按当轮阈值判定并记录,后续轮直接沿用(不再随阈值翻转)。
let key = tool_view_decision_key(&m.content);
let compress = match view_decisions.get(&key) {
Some(&decided) => decided,
None => {
let content_tokens = est.estimate_text(&m.content);
let decided =
should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens);
view_decisions.insert(key, decided);
decided
}
};
if !compress {
return m;
}
original_bytes += content_len;
let compressed = extract_key_info(&m.content, resolved_name);
compressed_bytes += compressed.len();
@@ -283,3 +300,12 @@ pub(crate) fn summarize_tool_results(
}
msgs
}
/// tool_result 压缩决策键:内容稳定哈希(u64)。键只做「同内容同决策」关联,
/// 非密码学用途;碰撞的后果仅是 view-only 视图多压/少压一条,可自愈。
fn tool_view_decision_key(content: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
content.hash(&mut h);
h.finish()
}
@@ -460,7 +460,8 @@ async fn run_sub_agent_loop_inner(
// ── prepare:build_for_request(预算裁剪)+ system prompt + namespace 展开 +
// tool_result 视图压缩 + 占位配对断言 + 输入 token 估值(对齐主 loop prepare 段)──
let (messages, estimated_prompt) = {
let session = session_arc.lock().await;
// mut:压缩决策冻结写回 per_conv(短锁段,无 await 夹杂)
let mut session = session_arc.lock().await;
let Some(conv) = session.conv_read(&child_conv_id) else {
status = SubAgentStatus::Failed;
stop_reason = "子会话条目已消失".to_string();
@@ -482,8 +483,13 @@ async fn run_sub_agent_loop_inner(
let mut msgs = vec![ChatMessage::system(&system_prompt)];
msgs.extend(history_msgs);
let history_tokens = conv.messages.history_tokens();
// tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线)
let msgs = summarize_tool_results(msgs, history_tokens, &child_conv_id, round as usize);
// tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线)。
// 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转。
let mut view_decisions = conv.tool_view_decisions.clone();
let msgs = summarize_tool_results(msgs, history_tokens, &mut view_decisions, &child_conv_id, round as usize);
if !view_decisions.is_empty() {
session.conv(&child_conv_id).tool_view_decisions = view_decisions;
}
let msgs = ContextManager::assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED);
let est = TokenEstimator::default();
let estimated_prompt: u32 = msgs.iter().map(|m| est.estimate_message(m)).sum();
+9 -14
View File
@@ -39,7 +39,7 @@ use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize;
use super::super::conversation::save_conversation;
use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use super::super::prompt::{build_system_prompt_parts, cache_enabled};
use super::super::prompt::{build_system_prompt_parts, cache_enabled, cache_split_provider_type};
use super::super::{AiChatEvent, ApprovalKind, SessionState};
@@ -312,11 +312,6 @@ fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<Strin
(proj, task)
}
/// 当前 provider 是否 anthropic 协议(仅此类型启用 prompt caching 拆分,OpenAI 路径零影响)。
fn is_anthropic_type(provider_type: &str) -> bool {
matches!(provider_type, "anthropic" | "anthropic_compat")
}
/// 把段追加到易变尾段(以 `\n\n---\n` 分隔,对齐旧 system 拼段风格)。首段直接赋。
fn append_volatile(volatile: String, seg: &str) -> String {
if volatile.is_empty() {
@@ -389,8 +384,8 @@ pub async fn ai_regenerate(
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
@@ -783,8 +778,8 @@ pub async fn ai_chat_send(
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
@@ -1809,8 +1804,8 @@ pub async fn ai_chat_edit(
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
@@ -2069,8 +2064,8 @@ pub async fn ai_chat_force_send(
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
+17
View File
@@ -1003,6 +1003,21 @@ pub struct PerConvState {
pub workflow_id: Option<String>,
/// T4 工作流 DAG 摘要(预计算的活跃路径文本,避免运行时加载 DAG)
pub workflow_dag_summary: Option<String>,
/// 跨轮工具集冻结(前缀稳定):首次意图裁剪结果按工具名冻结,后续轮只增不减。
///
/// 治「每轮按末条 user 消息重新 intent 裁工具 → tools 数组跨轮变化 → 前缀断」:
/// tools 数组在请求里位于 system 之后、消息之前,数组字节一变,自动前缀缓存
/// (DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。新意图带来的工具并入冻结集,
/// 已并入的不移除。None = 尚未冻结(首轮裁剪时写入);纯问候空工具集不进本字段
/// (仅作用当轮请求)。随会话销毁不落库(对齐 pinned_goals 语义)。
pub frozen_tool_names: Option<Vec<String>>,
/// tool_result 视图压缩决策冻结(content 稳定哈希 → 是否压缩)。
///
/// 治「压缩阈值随 history_tokens 增长漂移 → 同一条历史第 N 轮发全文、第 N+1 轮变摘要
/// → 轮中前缀断」:决策按消息首次进入请求视图时刻一次性判定并冻结,后续轮沿用同一视图。
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
/// 位置漂移,内容不变)。随会话销毁不落库。
pub tool_view_decisions: std::collections::HashMap<u64, bool>,
}
/// 单个目标记录(含状态追踪)。
@@ -1066,6 +1081,8 @@ impl PerConvState {
working_context: df_ai::context_helpers::WorkingContext::new(),
workflow_id: None,
workflow_dag_summary: None,
frozen_tool_names: None,
tool_view_decisions: std::collections::HashMap::new(),
}
}
}
+17 -4
View File
@@ -498,11 +498,24 @@ async fn build_custom_section(state: &AppState) -> String {
String::new()
}
/// 读取 Anthropic prompt caching 开关(AppState settings KV,默认关)并同步 df-ai 进程级开关。
/// 当前 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 端点显式开启。
/// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的端点显式开启。
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
/// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。
pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state
.settings
@@ -526,8 +539,8 @@ pub(crate) async fn cache_enabled(state: &AppState) -> bool {
/// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容
/// 不会失效已缓存前缀(跨天缓存仍命中)。
///
/// 注意:调用方须先按「开关开 AND 当前 provider 为 anthropic 类型」gate 再传 `cache_enabled`,
/// 保证 OpenAI 路径零影响(开关开也不改动 openai 请求结构)。
/// 注意:调用方须先按「开关开 AND 当前 provider 支持缓存拆分(anthropic 系/openai_compat)」
/// gate 再传 `cache_enabled`,保证开关关时全部 provider 零影响(逐字等价旧路径)。
pub(crate) async fn build_system_prompt_parts(
state: &AppState,
lang: &str,
+50 -32
View File
@@ -131,6 +131,14 @@ function tokenInOf(m: AiMessage): number {
function tokenCacheOf(m: AiMessage): number {
return m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
}
/** cache 段是否有真实上报数据(渲染闸):hit/miss 均为 0 时「provider 没报缓存」与
* 「真 0」不可区分(GLM 无缓存口径,估算轮更无真实用量),渲染「0 cache」会误导,不渲染。 */
function tokenCacheVisible(m: AiMessage): boolean {
if (tokenEstimated(m)) return false
const hit = m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
const miss = m.tokenUsage?.cache_miss ?? m.prompt_cache_miss_tokens ?? 0
return hit > 0 || miss > 0
}
/** 取消息的 out token */
function tokenOutOf(m: AiMessage): number {
return m.tokenUsage?.completion ?? m.completion_tokens ?? 0
@@ -765,15 +773,20 @@ function thinkFullText(m: AiMessage): string {
if (!txt) return t('ai.trace.processOnly')
return txt.length > 500 ? txt.slice(0, 500) + '…' : txt
}
/** 思考 pill 右端 token 小字:「30.3k in(估算) · 0 cache · 157 out」(无 token 数据返回空串隐藏) */
/** 该轮是否有实质思考内容:空内容轮(pill 只会显示「过程记录」占位)不渲染 pill,消除占位噪音;
* 该轮工具卡照常渲染,有实质内容的思考行不受影响。 */
function hasThinkBody(m: AiMessage): boolean {
const txt = thinkPlainText(m)
return !!txt && txt !== t('ai.trace.processOnly')
}
/** 思考 pill 右端 token 小字:「30.3k in(估算) · 12k cache · 157 out」(无 token 数据返回空串隐藏)。
* cache 段仅在 provider 真实上报时渲染(tokenCacheVisible),避免把「无数据」显示成「0 cache」。 */
function roundTokenText(m: AiMessage): string {
if (!m.tokenUsage && m.prompt_tokens == null) return ''
const est = tokenEstimated(m) ? `(${t('aiChat.estimated')})` : ''
const parts = [
`${formatTokensK(tokenInOf(m))} in${est}`,
`${formatTokensK(tokenCacheOf(m))} cache`,
`${formatTokensK(tokenOutOf(m))} out`,
]
const parts = [`${formatTokensK(tokenInOf(m))} in${est}`]
if (tokenCacheVisible(m)) parts.push(`${formatTokensK(tokenCacheOf(m))} cache`)
parts.push(`${formatTokensK(tokenOutOf(m))} out`)
if (tokenReasonOf(m) > 0) parts.push(`${formatTokensK(tokenReasonOf(m))} reason`)
return parts.join(' · ')
}
@@ -1053,22 +1066,25 @@ defineExpose({
<!-- 展开态 rail 面板:每轮=思考单行 pill(点击展开全文)+工具卡,图标锚定左轨连线贯通 -->
<div v-if="item.expanded" class="ai-trace-body">
<div v-for="m in item.traceMsgs" :key="m.id" class="ai-trace-round">
<button
class="ai-think-pill"
:title="thinkFullText(m)"
@click="toggleThink(m.id)"
>
<span class="ai-think-ic">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 00-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0012 2z"/></svg>
</span>
<span class="ai-think-text">{{ thinkSnippet(m) }}</span>
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
</button>
<div
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
class="ai-think-full ai-md"
v-html="renderMd(m.content)"
></div>
<!-- 空内容轮(只剩「过程记录」占位)不渲染思考 pill,降噪;工具卡照常 -->
<template v-if="hasThinkBody(m)">
<button
class="ai-think-pill"
:title="thinkFullText(m)"
@click="toggleThink(m.id)"
>
<span class="ai-think-ic">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 00-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0012 2z"/></svg>
</span>
<span class="ai-think-text">{{ thinkSnippet(m) }}</span>
<span v-if="roundTokenText(m)" class="ai-think-tokens">{{ roundTokenText(m) }}</span>
</button>
<div
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
class="ai-think-full ai-md"
v-html="renderMd(m.content)"
></div>
</template>
<ToolCardList
v-if="m.toolCalls && m.toolCalls.length > 0"
ref="toolCardListRef"
@@ -1257,8 +1273,11 @@ defineExpose({
@click="toggleTokenPopover(item.msg, $event)"
>
<span>{{ formatTokensK(tokenInOf(item.msg)) }} in<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">({{ $t('aiChat.estimated') }})</span></template></span>
<span class="ai-token-sep">·</span>
<span>{{ formatTokensK(tokenCacheOf(item.msg)) }} cache</span>
<!-- cache 段仅在 provider 真实上报时渲染(tokenCacheVisible),避免「无数据」显示成「0 cache」 -->
<template v-if="tokenCacheVisible(item.msg)">
<span class="ai-token-sep">·</span>
<span>{{ formatTokensK(tokenCacheOf(item.msg)) }} cache</span>
</template>
<span class="ai-token-sep">·</span>
<span>{{ formatTokensK(tokenOutOf(item.msg)) }} out</span>
<template v-if="tokenReasonOf(item.msg) > 0">
@@ -1277,7 +1296,7 @@ defineExpose({
<span class="ai-token-popover-label">{{ $t('aiChat.tokenInLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span>
</div>
<div class="ai-token-popover-row">
<div v-if="tokenCacheVisible(item.msg)" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }})</span>
</div>
@@ -1289,7 +1308,8 @@ defineExpose({
<span class="ai-token-popover-label">{{ $t('aiChat.tokenReasonLabel') }}</span>
<span class="ai-token-popover-val">{{ formatTokensK(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
</div>
<div v-if="cacheHitRate(item.msg) != null" class="ai-token-popover-row">
<!-- 命中率与 cache 段同闸:无缓存口径的 provider 不显 0%,防「无数据」误读成「全未命中」 -->
<div v-if="tokenCacheVisible(item.msg) && cacheHitRate(item.msg) != null" class="ai-token-popover-row">
<span class="ai-token-popover-label">{{ $t('aiChat.tokenCacheRateLabel') }}</span>
<span class="ai-token-popover-val">{{ cacheHitRate(item.msg) }}%</span>
</div>
@@ -1938,7 +1958,8 @@ defineExpose({
}
.ai-trace-round + .ai-trace-round { margin-top: 2px; }
/* ── 思考单行 pill:灯泡锚轨 + 单行截断 + 右端 token 小字(空间不足先吃 token 尾段) ── */
/* ── 思考单行 pill:灯泡锚轨 + 单行截断 + 右端 token 小字
(空间不足先压缩摘要文本段(hover title 有全文),token 尾段不再被截,恒完整可见) ── */
.ai-think-pill {
position: relative;
display: flex;
@@ -1978,16 +1999,13 @@ defineExpose({
}
.ai-think-text {
flex: 1 1 auto;
min-width: 80px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-think-tokens {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 0 auto;
white-space: nowrap;
font-family: var(--df-font-mono);
font-size: 9.5px;
+5
View File
@@ -203,6 +203,11 @@ export default {
pathAuthBypassConfirmTitle: 'Enable "Trust all directories"?',
pathAuthBypassConfirmBody: 'Once enabled, AI tools will no longer ask for per-access authorization and can directly reach any path, including sensitive directories such as .ssh and .aws. Sensitive credentials may end up in the conversation context; proceed at your own risk. Are you sure you want to enable it?',
// Anthropic prompt caching (Settings - AI Model; KV df-ai-anthropic-cache, default off, effective immediately)
panelAnthropicCache: 'AI Prompt Caching',
labelAnthropicCache: 'Enable prompt caching',
descAnthropicCache: 'When enabled, Anthropic-protocol endpoints get explicit cache breakpoints on the system prompt and conversation history, greatly reducing token cost for long sessions; OpenAI-compatible endpoints also get prefix-stable splitting (volatile content moves to the end of the message stream so automatic prefix caches can hit). Only enable for endpoints that support caching; gateways without support may reject the cache_control field.',
// ===== ScriptNode command execution safety =====
panelScriptSafety: '🛡️ Command Execution Safety',
descScriptSafety: 'Configure ScriptNode command whitelist/blacklist. Command name is the first token (e.g. `rm -rf /` -> `rm`), compared trimmed and lowercase. Blacklist takes precedence over whitelist.',
+5
View File
@@ -202,6 +202,11 @@ export default {
pathAuthBypassConfirmTitle: '开启「信任所有目录」?',
pathAuthBypassConfirmBody: '开启后 AI 工具将无需逐次授权,直接访问包括 .ssh、.aws 等敏感目录在内的任意路径,敏感凭据可能进入对话上下文,风险自担。确定开启?',
// Anthropic prompt caching(设置-AI 模型;KV df-ai-anthropic-cache,默认关,改后即时生效)
panelAnthropicCache: 'AI Prompt 缓存',
labelAnthropicCache: '启用 prompt 缓存',
descAnthropicCache: '开启后 Anthropic 协议端点在 system 与历史段打显式缓存断点,长会话显著省 token;OpenAI 兼容端点同时启用前缀稳定拆分(易变内容挪到消息流末尾,自动前缀缓存可命中)。仅对支持缓存的端点开启,不支持的网关可能拒收 cache_control 字段。',
// ===== ScriptNode 命令执行安全 =====
panelScriptSafety: '🛡️ 命令执行安全',
descScriptSafety: '配置 ScriptNode 命令白/黑名单。命令名取首词(如 `rm -rf /` 取 `rm`),按 trim + 小写比较。黑名单优先于白名单。',
+23
View File
@@ -33,6 +33,24 @@
@providers-changed="onProvidersChanged"
/>
<!-- AI Prompt 缓存总开关(KV df-ai-anthropic-cache,默认关;后端每请求读 settings,改后即时生效)
开:Anthropic 协议端点 system+历史段显式缓存断点;openai_compat 前缀稳定拆分(易变段挪消息流末尾) -->
<section v-if="activeCategory === 'ai-model'" class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelAnthropicCache') }}</h2>
</div>
<SettingRow :label="$t('settings.labelAnthropicCache')" :desc="$t('settings.descAnthropicCache')">
<label class="toggle">
<input
type="checkbox"
:checked="anthropicCache"
@change="anthropicCache = ($event.target as HTMLInputElement).checked"
/>
<span class="toggle-slider"></span>
</label>
</SettingRow>
</section>
<!-- 知识库:auto_extract/trigger_mode/embedding_* -->
<KnowledgePanel
v-else-if="activeCategory === 'knowledge'"
@@ -68,6 +86,7 @@ import { useConfirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import SettingsNav, { type SettingsCategory } from '@/components/settings/SettingsNav.vue'
import SettingRow from '@/components/settings/SettingRow.vue'
import ProviderPanel from '@/components/settings/ProviderPanel.vue'
import ConnectionPanel from '@/components/settings/ConnectionPanel.vue'
import AppearanceSection from '@/components/settings/AppearanceSection.vue'
@@ -108,6 +127,10 @@ const activeCategory = computed<SettingsCategory>({
set: (v) => { void appSettings.set('df-settings-active-category', v) },
})
// AI Prompt 缓存总开关(KV df-ai-anthropic-cache,默认关,与后端 prompt.rs cache_enabled 同键;
// 值落库为 JSON 布尔,后端 "true"/"1" 判定兼容)
const anthropicCache = appSettings.useSetting<boolean>('df-ai-anthropic-cache', false)
// 窄屏响应式:监听窗口宽度,nav 折叠为横向 tab(避免依赖仅 CSS 的 :has,
// 并让 SettingsNav 按横向模式渲染 list)
const isNarrowScreen = ref(false)