优化: token分项显示(in/cache/out/reasoning)+ 详情面板 + base前置
token分项(各计费不同,不显 total):df-ai 解析 provider cache/reasoning(openai_compat prompt_cache_hit/miss/reasoning_tokens + anthropic cache_read/creation)+ TokenUsage 加字段(全构造点)+ AiMessage/AiCompleted/DB V39(ai_messages 加 cache_hit/miss/reasoning 列)+ message_repo 映射(持久化)+ 前端 MessageList 显 in·cache·out·reason(in=cache_miss 全价,reasoning 有才显)+ 点击 token 弹详情面板(完整 usage+缓存命中率+model)+ df-miniapp 同步 base前置(提升 prompt cache 命中率):chat.rs aug 拼 base 后(4处)+ knowledge_inject 知识拼 base 后(固定 base 前缀,cache 命中) 附修:replace_conversation 原 13 列 INSERT 丢消息级 token → 改 18 列
This commit is contained in:
+28
-2
@@ -393,7 +393,14 @@ export type AiChatEvent = ({
|
||||
} | {
|
||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: incomplete 标记流中途失败保文(网络中断),
|
||||
// 前端据此差异化展示(如系统提示「⚠ 响应因网络中断不完整」)。正常完成/停止均为 undefined。
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number; incomplete?: boolean; pinned_goals?: GoalEntry[]
|
||||
type: 'AiCompleted'; total_tokens: number; prompt_tokens: number; completion_tokens: number
|
||||
/** token 分项显示(2026-08-02):cache 命中(低价,deepseek prompt_cache_hit/anthropic cache_read) */
|
||||
prompt_cache_hit_tokens: number
|
||||
/** 未命中(全价真实输入,deepseek prompt_cache_miss/anthropic cache_creation)。前端 in 显示用此 */
|
||||
prompt_cache_miss_tokens: number
|
||||
/** 思考(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。0=非 reasoning 模型 */
|
||||
reasoning_tokens: number
|
||||
incomplete?: boolean; pinned_goals?: GoalEntry[]
|
||||
} | {
|
||||
type: 'AiError'; error: string; error_type?: AiErrorType
|
||||
} | {
|
||||
@@ -512,8 +519,21 @@ export interface AiMessage {
|
||||
/**
|
||||
* 这一轮对话的 token 用量(仅 assistant 消息,每轮 AiCompleted 时回填)。
|
||||
* undefined=未记录(历史消息/未启用 token 显示)。
|
||||
*
|
||||
* token 分项显示(2026-08-02):tokenUsage 扩展 cache_hit/cache_miss/reasoning。
|
||||
* in 显示用 cache_miss(全价真实),非 prompt(总,含 cache_hit 掩盖命中比例)。
|
||||
* 老消息(无 cache 字段)cache_hit/cache_miss/reasoning undefined,前端 fallback prompt_tokens。
|
||||
*/
|
||||
tokenUsage?: { prompt: number; completion: number }
|
||||
tokenUsage?: {
|
||||
prompt: number
|
||||
completion: number
|
||||
/** 缓存命中(低价) */
|
||||
cache_hit?: number
|
||||
/** 未命中(全价真实输入,前端 in 显示用此) */
|
||||
cache_miss?: number
|
||||
/** 思考 token(隐藏输出) */
|
||||
reasoning?: number
|
||||
}
|
||||
/**
|
||||
* 本轮输入 token(消息级持久化,后端 ChatMessage.prompt_tokens 镜像)。
|
||||
* reload 时由 switchConversation 映射回 tokenUsage(历史 assistant 消息 token 回显)。
|
||||
@@ -522,6 +542,12 @@ export interface AiMessage {
|
||||
prompt_tokens?: number
|
||||
/** 本轮输出 token(语义同 prompt_tokens)。 */
|
||||
completion_tokens?: number
|
||||
/** 缓存命中 token(低价)。老消息 undefined(向前兼容)。 */
|
||||
prompt_cache_hit_tokens?: number
|
||||
/** 未命中 token(全价真实输入,前端 in 显示用此)。老消息 undefined。 */
|
||||
prompt_cache_miss_tokens?: number
|
||||
/** 思考 token(deepseek-reasoner/o1 reasoning_tokens)。老消息 undefined。 */
|
||||
reasoning_tokens?: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,50 @@ function formatTokens(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
||||
}
|
||||
|
||||
// ── token 分项显示(2026-08-02):in/cache/out/reason 分计费 + 详情面板 ──
|
||||
//
|
||||
// 各 provider 计费不同(deepseek:cache 命中低价/未命中全价/输出价高/reasoning 隐藏输出),
|
||||
// 故不显 total(三者相加无意义)。in=cache_miss(全价真实),cache=cache_hit(命中),out=completion,
|
||||
// reasoning > 0 才显(reason 后缀)。
|
||||
//
|
||||
// 详情面板(tokenPopoverMsgId 控制显隐):点击 token 区弹出,含完整 usage + 流程(模型/缓存命中率)。
|
||||
// 流程数据从 message 取(model 在 msg.model;cache 命中率 = hit/(hit+miss))。
|
||||
const tokenPopoverMsgId = ref<string | null>(null)
|
||||
|
||||
/** 取消息的 in token(全价输入)= prompt_tokens(总) - cache_hit(命中低价)。
|
||||
* 统一 GLM(无 cache,prompt_tokens 即全价)与 deepseek(prompt=hit+miss,全价=miss=prompt-hit)。
|
||||
* 原 cache_miss ?? prompt 因 agent 设 cache_miss=0(GLM 不报)致 ?? 不触发返 0,改减法。 */
|
||||
function tokenInOf(m: AiMessage): number {
|
||||
const prompt = m.tokenUsage?.prompt ?? m.prompt_tokens ?? 0
|
||||
const hit = m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
|
||||
return Math.max(0, prompt - hit)
|
||||
}
|
||||
/** 取消息的 cache hit token */
|
||||
function tokenCacheOf(m: AiMessage): number {
|
||||
return m.tokenUsage?.cache_hit ?? m.prompt_cache_hit_tokens ?? 0
|
||||
}
|
||||
/** 取消息的 out token */
|
||||
function tokenOutOf(m: AiMessage): number {
|
||||
return m.tokenUsage?.completion ?? m.completion_tokens ?? 0
|
||||
}
|
||||
/** 取消息的 reasoning token */
|
||||
function tokenReasonOf(m: AiMessage): number {
|
||||
return m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? 0
|
||||
}
|
||||
/** 计算 cache 命中率(0-100),hit+miss=0 时返回 null(无 cache 数据) */
|
||||
function cacheHitRate(m: AiMessage): number | null {
|
||||
const hit = tokenCacheOf(m)
|
||||
const miss = tokenInOf(m)
|
||||
const sum = hit + miss
|
||||
return sum > 0 ? Math.round((hit / sum) * 100) : null
|
||||
}
|
||||
/** 切换 token 详情面板显隐(同消息再点关,不同消息切) */
|
||||
function toggleTokenPopover(m: AiMessage, e: Event): void {
|
||||
e.stopPropagation()
|
||||
const id = m.id
|
||||
tokenPopoverMsgId.value = tokenPopoverMsgId.value === id ? null : id
|
||||
}
|
||||
|
||||
// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存,已抽取至 useMessageScroll) ──
|
||||
const {
|
||||
showBackToBottom,
|
||||
@@ -616,8 +660,15 @@ watch(() => store.state.activeConversationId, () => {
|
||||
// streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。
|
||||
onBeforeUnmount(() => {
|
||||
cancelPendingRaf()
|
||||
document.removeEventListener('click', closeTokenPopoverOnOutsideClick)
|
||||
})
|
||||
|
||||
// token 详情面板:点击外部关闭(token 区内的 @click.stop 已阻冒泡,故文档级点击必为外部)
|
||||
function closeTokenPopoverOnOutsideClick(): void {
|
||||
tokenPopoverMsgId.value = null
|
||||
}
|
||||
document.addEventListener('click', closeTokenPopoverOnOutsideClick)
|
||||
|
||||
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
|
||||
// 多次调用安全,确保子组件挂载即预热)。
|
||||
loadMarkdown()
|
||||
@@ -761,7 +812,9 @@ defineExpose({
|
||||
</div>
|
||||
|
||||
|
||||
<!-- token 用量(显示在每条 assistant 消息底部,有数据时)。
|
||||
<!-- token 分项显示(2026-08-02):in/cache/out/reason 分计费,不显 total(相加无意义)。
|
||||
in=cache_miss(全价真实,非 prompt 总),cache=cache_hit(命中低价),out=completion,
|
||||
reasoning > 0 才显。点击 token 区弹详情面板(完整 usage + 流程:模型/缓存命中率)。
|
||||
tokenUsage(内存,AiCompleted 实时)优先;fallback prompt_tokens/completion_tokens
|
||||
(DB 持久化字段,reload 自动有)—— 不依赖某个 reload 映射点,压缩/切会话都生效。 -->
|
||||
<div
|
||||
@@ -769,7 +822,53 @@ defineExpose({
|
||||
class="ai-token-usage"
|
||||
>
|
||||
<span class="ai-token-usage-icon">🔣</span>
|
||||
<span>{{ formatTokens(item.msg.tokenUsage?.prompt ?? item.msg.prompt_tokens ?? 0) }} in · {{ formatTokens(item.msg.tokenUsage?.completion ?? item.msg.completion_tokens ?? 0) }} out</span>
|
||||
<button
|
||||
class="ai-token-usage-trigger"
|
||||
:aria-expanded="tokenPopoverMsgId === item.msg.id"
|
||||
@click="toggleTokenPopover(item.msg, $event)"
|
||||
>
|
||||
<span>{{ formatTokens(tokenInOf(item.msg)) }} in</span>
|
||||
<span class="ai-token-sep">·</span>
|
||||
<span>{{ formatTokens(tokenCacheOf(item.msg)) }} cache</span>
|
||||
<span class="ai-token-sep">·</span>
|
||||
<span>{{ formatTokens(tokenOutOf(item.msg)) }} out</span>
|
||||
<template v-if="tokenReasonOf(item.msg) > 0">
|
||||
<span class="ai-token-sep">·</span>
|
||||
<span>{{ formatTokens(tokenReasonOf(item.msg)) }} reason</span>
|
||||
</template>
|
||||
</button>
|
||||
<!-- 详情面板(absolute/right 0/popout 样式,复用 TopBar 风格):完整 usage + 流程 -->
|
||||
<div
|
||||
v-if="tokenPopoverMsgId === item.msg.id"
|
||||
class="ai-token-popover"
|
||||
@click.stop
|
||||
>
|
||||
<div class="ai-token-popover-title">Token 用量详情</div>
|
||||
<div class="ai-token-popover-row">
|
||||
<span class="ai-token-popover-label">输入(未命中,全价)</span>
|
||||
<span class="ai-token-popover-val">{{ formatTokens(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span>
|
||||
</div>
|
||||
<div class="ai-token-popover-row">
|
||||
<span class="ai-token-popover-label">缓存命中(低价)</span>
|
||||
<span class="ai-token-popover-val">{{ formatTokens(tokenCacheOf(item.msg)) }} ({{ tokenCacheOf(item.msg) }})</span>
|
||||
</div>
|
||||
<div class="ai-token-popover-row">
|
||||
<span class="ai-token-popover-label">输出</span>
|
||||
<span class="ai-token-popover-val">{{ formatTokens(tokenOutOf(item.msg)) }} ({{ tokenOutOf(item.msg) }})</span>
|
||||
</div>
|
||||
<div v-if="tokenReasonOf(item.msg) > 0" class="ai-token-popover-row">
|
||||
<span class="ai-token-popover-label">思考(reasoning)</span>
|
||||
<span class="ai-token-popover-val">{{ formatTokens(tokenReasonOf(item.msg)) }} ({{ tokenReasonOf(item.msg) }})</span>
|
||||
</div>
|
||||
<div v-if="cacheHitRate(item.msg) != null" class="ai-token-popover-row">
|
||||
<span class="ai-token-popover-label">缓存命中率</span>
|
||||
<span class="ai-token-popover-val">{{ cacheHitRate(item.msg) }}%</span>
|
||||
</div>
|
||||
<div v-if="item.msg.model" class="ai-token-popover-row">
|
||||
<span class="ai-token-popover-label">模型</span>
|
||||
<span class="ai-token-popover-val">{{ item.msg.model }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
|
||||
@@ -1128,6 +1227,7 @@ defineExpose({
|
||||
|
||||
/* ── token 用量条(克制:右对齐 / 最小字号 / dim / 分隔线,不抢正文焦点) ── */
|
||||
.ai-token-usage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
@@ -1141,6 +1241,64 @@ defineExpose({
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ai-token-usage-icon { font-size: 9px; }
|
||||
/* token 分项显示(2026-08-02):trigger 是可点击按钮(无背景,继承 dim 样式) */
|
||||
.ai-token-usage-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
.ai-token-usage-trigger:hover {
|
||||
color: var(--df-text);
|
||||
opacity: 1;
|
||||
}
|
||||
.ai-token-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */
|
||||
.ai-token-popover {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
min-width: 200px;
|
||||
background: var(--df-bg-card, var(--df-bg));
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text);
|
||||
z-index: 10;
|
||||
text-align: left;
|
||||
}
|
||||
.ai-token-popover-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-token-popover-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.ai-token-popover-label {
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.ai-token-popover-val {
|
||||
color: var(--df-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 用户气泡内的图片(多模态消息渲染) */
|
||||
.ai-msg-images {
|
||||
|
||||
@@ -152,9 +152,20 @@ export async function switchConversation(id: string) {
|
||||
// (V38 迁移后 push_assistant_message 落库的本轮 token),映射回 tokenUsage 供
|
||||
// MessageList.vue 渲染 in/out 计数。压缩/切会话后历史 assistant 消息 token 不丢。
|
||||
// 老消息 NULL → m.prompt_tokens==null → tokenUsage 不设(对齐 useAiEvents 实时态语义)。
|
||||
// 分项 token(2026-08-02):cache/reasoning 透传(V39 列),老消息无则 undefined 前端 fallback。
|
||||
tokenUsage: m.role === 'assistant' && m.prompt_tokens != null
|
||||
? { prompt: m.prompt_tokens, completion: m.completion_tokens ?? 0 }
|
||||
? {
|
||||
prompt: m.prompt_tokens,
|
||||
completion: m.completion_tokens ?? 0,
|
||||
cache_hit: m.prompt_cache_hit_tokens,
|
||||
cache_miss: m.prompt_cache_miss_tokens,
|
||||
reasoning: m.reasoning_tokens,
|
||||
}
|
||||
: undefined,
|
||||
// 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致)
|
||||
prompt_cache_hit_tokens: m.prompt_cache_hit_tokens,
|
||||
prompt_cache_miss_tokens: m.prompt_cache_miss_tokens,
|
||||
reasoning_tokens: m.reasoning_tokens,
|
||||
// F-260614-05 Phase 2b: 透传 parts(多模态 Image 片)。后端序列化的 ContentPart[]
|
||||
// 含 type:'text'|'image' discriminator + url/base64/media_type/alt 字段,
|
||||
// 此处原样透传供 AiChat.vue 用户气泡渲染 <img>(base64 模式持久化层已替换占位 Text 片,
|
||||
|
||||
@@ -693,11 +693,15 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
}
|
||||
void loadConversations()
|
||||
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
|
||||
// 分项 token(2026-08-02):cache_hit/cache_miss/reasoning 透传,前端 in=cache_miss 分计费展示
|
||||
if (isShowTokenUsage()) {
|
||||
state.lastTokenUsage = {
|
||||
prompt: event.prompt_tokens,
|
||||
completion: event.completion_tokens,
|
||||
total: event.total_tokens,
|
||||
cache_hit: event.prompt_cache_hit_tokens,
|
||||
cache_miss: event.prompt_cache_miss_tokens,
|
||||
reasoning: event.reasoning_tokens,
|
||||
}
|
||||
if (state.convTokenTotal) {
|
||||
state.convTokenTotal.prompt += event.prompt_tokens
|
||||
@@ -707,10 +711,20 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
||||
state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens }
|
||||
}
|
||||
// 每轮 token 写入对应 assistant 消息(最后一条 AI 消息),供 MessageList 逐条显示。
|
||||
// 同时写消息级 cache/reasoning 字段(详情面板 + 分项显示用)。
|
||||
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||
const m = state.messages[i]
|
||||
if (m.role === 'assistant' && !m.isError) {
|
||||
m.tokenUsage = { prompt: event.prompt_tokens, completion: event.completion_tokens }
|
||||
m.tokenUsage = {
|
||||
prompt: event.prompt_tokens,
|
||||
completion: event.completion_tokens,
|
||||
cache_hit: event.prompt_cache_hit_tokens,
|
||||
cache_miss: event.prompt_cache_miss_tokens,
|
||||
reasoning: event.reasoning_tokens,
|
||||
}
|
||||
m.prompt_cache_hit_tokens = event.prompt_cache_hit_tokens
|
||||
m.prompt_cache_miss_tokens = event.prompt_cache_miss_tokens
|
||||
m.reasoning_tokens = event.reasoning_tokens
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ const _stateBase: {
|
||||
archivedCollapsed: boolean
|
||||
foldedGroups: Record<string, boolean>
|
||||
searchQuery: string
|
||||
lastTokenUsage: { prompt: number; completion: number; total: number } | null
|
||||
lastTokenUsage: { prompt: number; completion: number; total: number; cache_hit?: number; cache_miss?: number; reasoning?: number } | null
|
||||
convTokenTotal: { prompt: number; completion: number; total: number } | null
|
||||
} = {
|
||||
messages: [],
|
||||
|
||||
Reference in New Issue
Block a user