From 423acfdd008b6c30d5ed907e66231f6666f4eff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 1 Oct 2026 19:35:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E5=AD=97=E6=AE=B5=E8=A7=A3=E6=9E=90=E4=B8=8E?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=89=8D=E7=BC=80=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openai 兼容口径补解析嵌套 cached_tokens - cache 显示区分无数据与真零,估算轮不渲染 - 轨迹展开折叠空过程记录行降噪 - token 统计行恒完整可见不截断 - openai_compat 纳入 system 稳定易变拆分 - 系统警示与主题标记改尾部注入不位移前缀 - 工具结果视图压缩按入库时刻冻结 - 会话内工具集冻结仅增不减 - anthropic 缓存补消息级断点,设置页加开关 - 补 intent 测试对 Agent domain 覆盖 --- crates/df-ai/src/anthropic_compat.rs | 122 ++++++++++++++++- crates/df-ai/src/intent.rs | 4 +- crates/df-ai/src/openai_helpers.rs | 124 ++++++++++++++++-- src-tauri/src/commands/ai/agentic/helpers.rs | 19 ++- src-tauri/src/commands/ai/agentic/mod.rs | 107 +++++++++++---- .../src/commands/ai/agentic/stall_detector.rs | 34 ++++- .../src/commands/ai/agentic/sub_agent.rs | 12 +- src-tauri/src/commands/ai/commands/chat.rs | 23 ++-- src-tauri/src/commands/ai/mod.rs | 17 +++ src-tauri/src/commands/ai/prompt.rs | 21 ++- src/components/ai/MessageList.vue | 82 +++++++----- src/i18n/en/settings.ts | 5 + src/i18n/zh-CN/settings.ts | 5 + src/views/Settings.vue | 23 ++++ 14 files changed, 499 insertions(+), 99 deletions(-) diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 57bb38a..425f010 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -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 = 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"); diff --git a/crates/df-ai/src/intent.rs b/crates/df-ai/src/intent.rs index 7e7a749..65d0495 100644 --- a/crates/df-ai/src/intent.rs +++ b/crates/df-ai/src/intent.rs @@ -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 { diff --git a/crates/df-ai/src/openai_helpers.rs b/crates/df-ai/src/openai_helpers.rs index da469ae..5509a95 100644 --- a/crates/df-ai/src/openai_helpers.rs +++ b/crates/df-ai/src/openai_helpers.rs @@ -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, +} + +/// 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, +} + +/// 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 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) } } } + +#[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); + } +} diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index a42eb98..9c078b1 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -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; diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index a3e7996..b995ecd 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -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 = 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 = 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 = 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>, 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(强制续轮)" diff --git a/src-tauri/src/commands/ai/agentic/stall_detector.rs b/src-tauri/src/commands/ai/agentic/stall_detector.rs index 91d27e8..af8a182 100644 --- a/src-tauri/src/commands/ai/agentic/stall_detector.rs +++ b/src-tauri/src/commands/ai/agentic/stall_detector.rs @@ -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, history_tokens_snapshot: u32, + view_decisions: &mut std::collections::HashMap, conv_id: &str, iteration: usize, ) -> Vec { @@ -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() +} diff --git a/src-tauri/src/commands/ai/agentic/sub_agent.rs b/src-tauri/src/commands/ai/agentic/sub_agent.rs index 95d3874..7922756 100644 --- a/src-tauri/src/commands/ai/agentic/sub_agent.rs +++ b/src-tauri/src/commands/ai/agentic/sub_agent.rs @@ -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(); diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 280c869..b0c293a 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -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 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)。 diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 6dc799d..ba1a21a 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -1003,6 +1003,21 @@ pub struct PerConvState { pub workflow_id: Option, /// T4 工作流 DAG 摘要(预计算的活跃路径文本,避免运行时加载 DAG) pub workflow_dag_summary: Option, + /// 跨轮工具集冻结(前缀稳定):首次意图裁剪结果按工具名冻结,后续轮只增不减。 + /// + /// 治「每轮按末条 user 消息重新 intent 裁工具 → tools 数组跨轮变化 → 前缀断」: + /// tools 数组在请求里位于 system 之后、消息之前,数组字节一变,自动前缀缓存 + /// (DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。新意图带来的工具并入冻结集, + /// 已并入的不移除。None = 尚未冻结(首轮裁剪时写入);纯问候空工具集不进本字段 + /// (仅作用当轮请求)。随会话销毁不落库(对齐 pinned_goals 语义)。 + pub frozen_tool_names: Option>, + /// tool_result 视图压缩决策冻结(content 稳定哈希 → 是否压缩)。 + /// + /// 治「压缩阈值随 history_tokens 增长漂移 → 同一条历史第 N 轮发全文、第 N+1 轮变摘要 + /// → 轮中前缀断」:决策按消息首次进入请求视图时刻一次性判定并冻结,后续轮沿用同一视图。 + /// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后 + /// 位置漂移,内容不变)。随会话销毁不落库。 + pub tool_view_decisions: std::collections::HashMap, } /// 单个目标记录(含状态追踪)。 @@ -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(), } } } diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index 6903d88..3e7e8be 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -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, diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index b1bca1a..944a431 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -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({
- -
+ + {{ formatTokensK(tokenInOf(item.msg)) }} in - · - {{ formatTokensK(tokenCacheOf(item.msg)) }} cache + + · {{ formatTokensK(tokenOutOf(item.msg)) }} out