优化: 缓存命中字段解析与提示词前缀稳定化
- openai 兼容口径补解析嵌套 cached_tokens - cache 显示区分无数据与真零,估算轮不渲染 - 轨迹展开折叠空过程记录行降噪 - token 统计行恒完整可见不截断 - openai_compat 纳入 system 稳定易变拆分 - 系统警示与主题标记改尾部注入不位移前缀 - 工具结果视图压缩按入库时刻冻结 - 会话内工具集冻结仅增不减 - anthropic 缓存补消息级断点,设置页加开关 - 补 intent 测试对 Agent domain 覆盖
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 + 小写比较。黑名单优先于白名单。',
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user