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

- 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
+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)