Просмотр исходного кода

修复各客户端上下文统计口径不一致

Samien 1 месяц назад
Родитель
Сommit
d1650eff7b

+ 12 - 7
src/client/components/composer/ComposerShell.vue

@@ -21,7 +21,8 @@ const props = withDefaults(defineProps<{
   reasoningOptions?: ComposerOption[]
   contextUsed?: number
   contextLimit?: number
-  contextEstimated?: boolean
+  contextPercent?: number
+  showContext?: boolean
   queueMode?: boolean
   toolTraceVisible?: boolean
   reference?: ComposerReference | null
@@ -49,7 +50,7 @@ const props = withDefaults(defineProps<{
   reasoningOptions: () => [],
   contextUsed: 0,
   contextLimit: 0,
-  contextEstimated: false,
+  showContext: false,
   queueMode: false,
   toolTraceVisible: true,
   reference: null,
@@ -106,8 +107,10 @@ const canStop = computed(() => props.streaming && (props.stopWhileRunning || pro
 // holds a draft, so resource waits can always be cancelled by the user.
 const showStop = computed(() => canStop.value && !text.value.trim() && !attachments.value.length)
 const compactModel = computed(() => props.modelLabel.split('/').filter(Boolean).at(-1) || props.modelLabel)
-const contextPercent = computed(() => props.contextLimit > 0 ? Math.min(100, Math.round(props.contextUsed / props.contextLimit * 100)) : 0)
-const hasContext = computed(() => props.contextLimit > 0)
+const contextPercent = computed(() => Math.max(0, Math.min(100, props.contextPercent
+  ?? (props.contextLimit > 0 ? Math.round(props.contextUsed / props.contextLimit * 100) : 0))))
+const hasContextUsage = computed(() => props.contextUsed > 0)
+const hasContext = computed(() => props.showContext || props.contextLimit > 0 || hasContextUsage.value)
 const remainingContextTokens = computed(() => Math.max(0, props.contextLimit - props.contextUsed))
 const reasoningOptionIndex = computed(() => Math.max(0, props.reasoningOptions.findIndex(option => option.id === props.reasoningValue)))
 const reasoningLabel = computed(() => props.reasoningOptions[reasoningOptionIndex.value]?.label || props.reasoningEffort)
@@ -532,9 +535,11 @@ defineExpose({
       <input ref="fileInput" class="composer-file-input" type="file" multiple @change="onFiles" />
       <div class="composer-resize" title="拖动调整高度;双击复位" @pointerdown="startResize" @dblclick="resetHeight" />
 
-      <div v-if="hasContext" class="composer-context" :class="{ warning: contextPercent > 80 }" :title="contextEstimated ? 'Hermes 未提供实时上下文用量;此数值按当前会话内容估算。' : undefined">
-        <span>{{ contextEstimated ? '约 ' : '' }}{{ formatTokens(contextUsed) }} / {{ formatTokens(contextLimit) }} · 剩余 {{ formatTokens(remainingContextTokens) }}</span>
-        <i><b :style="{ width: `${contextPercent}%` }" /></i>
+      <div v-if="hasContext" class="composer-context" :class="{ warning: hasContextUsage && contextPercent > 80 }" :title="hasContextUsage ? '最近一次模型请求的上下文用量' : '等待服务端提供最近一次模型请求的上下文用量'">
+        <span v-if="!hasContextUsage">等待本轮实际用量 · {{ contextLimit > 0 ? `窗口 ${formatTokens(contextLimit)}` : '窗口未确认' }}</span>
+        <span v-else-if="contextLimit > 0">{{ formatTokens(contextUsed) }} / {{ formatTokens(contextLimit) }} · 剩余 {{ formatTokens(remainingContextTokens) }}</span>
+        <span v-else>当前上下文 {{ formatTokens(contextUsed) }} · 窗口未确认</span>
+        <i v-if="hasContextUsage && contextLimit > 0"><b :style="{ width: `${contextPercent}%` }" /></i>
       </div>
 
       <textarea

+ 34 - 13
src/client/stores/chat.ts

@@ -20,6 +20,7 @@ import { bool, normalizeChatMessage, number, record, string, values } from '@/ut
 import { appendSessionPage, pinnedSessionsFirst } from '@/utils/sessionOrder'
 import { modelForSession, modelSelectionFromSessionInfo } from '@/utils/sessionModel'
 import { moveSessionFastMode, readSessionFastMode, writeSessionFastMode } from '@/utils/sessionPreferences'
+import { normalizeChatUsage } from '@/utils/contextUsage'
 import { useAuthStore } from './auth'
 
 interface CachedHistory { messages: ChatMessage[]; total: number; savedAt: number; transcript?: Omit<TranscriptSnapshot,'messages'> }
@@ -98,6 +99,7 @@ export const useChatStore = defineStore('chat', () => {
   const error = ref<string>()
   const models = ref<ModelOption[]>([])
   const unreadCounts = ref<Record<string, number>>({})
+  const contextUsageRequests = new Map<string, number>()
   const selectedModel = ref<ModelOption>()
   const reasoningEffort = ref<string>()
   const socket = new ChatRpcSocket()
@@ -158,6 +160,11 @@ export const useChatStore = defineStore('chat', () => {
         // Usage events replace the route while storage is pending. Commit to
         // the current route after both writes, preserving any newer local send.
         const latest=current();if(!latest)return
+        const usage=normalizeChatUsage(value.session)
+        if(usage && (['contextTokens','contextLimit','percentUsed'] as const)
+          .some(field => latest.usage?.[field] === undefined && usage[field] !== undefined)) {
+          latest.usage={...usage,...latest.usage}
+        }
         const latestPending=latest.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
         latest.messages=mergeChatMessages(canonical,latestPending);latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
         const approval=value.pendingApproval,clarification=value.pendingClarification
@@ -322,6 +329,7 @@ export const useChatStore = defineStore('chat', () => {
       if (active?.historySynced && (active.isStreaming || activeSession.value?.owned === true)
         && !active.route.sessionId.startsWith('draft-') && !active.runtimeSessionId && !reconnectResumePromise) {
         reconnectResumePromise = ensureRuntime(active)
+          .then(current => refreshContextUsage(current))
           .catch(cause => { active.error = errorMessage(cause) })
           .finally(() => { reconnectResumePromise = undefined })
       }
@@ -419,6 +427,8 @@ export const useChatStore = defineStore('chat', () => {
       if (liveSelection) {
         reconcileSessionModel(state.route.profile, state.route.sessionId, liveSelection.model, liveSelection.provider)
       }
+      const usage = normalizeChatUsage(payload)
+      if (usage) state.usage = { ...state.usage, ...usage }
     }
     const info = record(payload.info)
     const liveFastMode = optionalBoolean(payload.fast ?? info.fast)
@@ -644,10 +654,11 @@ export const useChatStore = defineStore('chat', () => {
     syncSelectedModel(refreshed?.model, refreshed?.provider)
     if (refreshed?.owned === true && !sessionId.startsWith('draft-')
       && activeSessionId.value === sessionId && activeProfileName.value === selectedProfile) {
-      await ensureRuntime(routes[routeKey(selectedProfile, sessionId)] ?? state).catch(cause => {
+      const current = await ensureRuntime(routes[routeKey(selectedProfile, sessionId)] ?? state).catch(cause => {
         const current = routes[routeKey(selectedProfile, sessionId)]
         if (current) current.error = errorMessage(cause)
       })
+      if (current) void refreshContextUsage(current).catch(() => undefined)
     }
   }
 
@@ -717,6 +728,8 @@ export const useChatStore = defineStore('chat', () => {
       if (!runtimeId || !storedId) throw new Error('Hermes 未返回新会话标识')
       const migrated = migrateRoute(initialKey, storedId, runtimeId)
       const info = record(result.info)
+      const usage = normalizeChatUsage(result)
+      if (usage) migrated.usage = { ...migrated.usage, ...usage }
       if (!desiredModelRoutes.has(routeKey(migrated.route.profile, migrated.route.sessionId))) {
         syncSelectedModel(string(result.model ?? info.model), string(result.provider ?? info.provider))
       }
@@ -739,6 +752,8 @@ export const useChatStore = defineStore('chat', () => {
     const migrated = migrateRoute(initialKey, storedId, runtimeId)
     migrated.error = undefined
     const info = record(result.info)
+    const usage = normalizeChatUsage(result)
+    if (usage) migrated.usage = { ...migrated.usage, ...usage }
     if (!desiredModelRoutes.has(routeKey(migrated.route.profile, migrated.route.sessionId))) {
       syncSelectedModel(string(result.model ?? info.model), string(result.provider ?? info.provider))
     }
@@ -1059,18 +1074,24 @@ export const useChatStore = defineStore('chat', () => {
     // Opening history is read-only. Resuming an old session merely to fetch
     // usage updates Hermes' last_active timestamp and incorrectly moves it
     // into today's sidebar group.
-    if (!initialState || initialState.route.sessionId.startsWith('draft-') || !initialState.runtimeSessionId) return
-    const current = initialState
-    const usage = resultRecord(await socket.request('session.usage', { session_id: current.runtimeSessionId! }))
-    current.usage = {
-      inputTokens: number(usage.input_tokens ?? usage.input) || undefined,
-      outputTokens: number(usage.output_tokens ?? usage.output) || undefined,
-      totalTokens: number(usage.total_tokens ?? usage.total) || undefined,
-      contextTokens: number(usage.context_tokens ?? usage.context_used ?? usage.used) || undefined,
-      contextLimit: number(usage.context_limit ?? usage.context_max ?? usage.limit) || undefined,
-      percentUsed: number(usage.percent_used ?? usage.context_percent) || undefined,
-      raw: usage as JsonValue,
-    }
+    if (!initialState || initialState.route.sessionId.startsWith('draft-') || !initialState.runtimeSessionId || initialState.isStreaming) return
+    const key = routeKey(initialState.route.profile, initialState.route.sessionId)
+    const runtimeId = initialState.runtimeSessionId
+    const generation = initialState.generation
+    const previousUsage = initialState.usage
+    const request = (contextUsageRequests.get(key) ?? 0) + 1
+    contextUsageRequests.set(key, request)
+    // Use the same source order as mobile. Breakdown can supply the gauge when
+    // session.usage only contains cumulative billing counters.
+    const results = await Promise.allSettled([
+      socket.request('session.usage', { session_id: runtimeId }, 15_000),
+      socket.request('session.context_breakdown', { session_id: runtimeId }, 15_000),
+    ])
+    const current = routes[key]
+    if (!current || current.generation !== generation || current.runtimeSessionId !== runtimeId
+      || current.usage !== previousUsage || contextUsageRequests.get(key) !== request) return
+    const usage = normalizeChatUsage(...results.flatMap(result => result.status === 'fulfilled' ? [result.value] : []))
+    if (usage) current.usage = { ...current.usage, ...usage }
   }
 
   return {

+ 31 - 18
src/client/utils/contextUsage.ts

@@ -1,20 +1,33 @@
-import type { ChatMessage } from '@shared/types'
+import type { ChatUsage, JsonValue } from '@shared/types'
+import { record } from './normalize'
 
-function estimateTextTokens(value: string): number {
-  const nonAscii = (value.match(/[^\x00-\x7F]/g) ?? []).length
-  return Math.ceil((value.length - nonAscii) / 4 + nonAscii * 0.9)
-}
-
-/**
- * A clearly-labelled fallback for gateways that do not persist a live context
- * gauge after a session has gone idle. It is intentionally conservative and
- * estimates only visible conversation payloads, never a model's hidden prompt.
- */
-export function estimateConversationTokens(messages: ChatMessage[]): number {
-  return messages.reduce((total, message) => {
-    const tools = message.toolCalls?.map(tool => JSON.stringify({
-      name: tool.name, arguments: tool.arguments, result: tool.result, error: tool.error,
-    })).join('\n') ?? ''
-    return total + estimateTextTokens([message.content, message.reasoning ?? '', tools].join('\n'))
-  }, 0)
+/** Match mobile: context is the latest prompt occupancy, never cumulative usage
+ * or the full (possibly compacted/paginated) visible transcript. */
+export function normalizeChatUsage(...sources: unknown[]): ChatUsage | undefined {
+  const fields: [Exclude<keyof ChatUsage, 'raw'>, string[]][] = [
+    ['inputTokens', ['input_tokens', 'inputTokens', 'input', 'prompt']],
+    ['outputTokens', ['output_tokens', 'outputTokens', 'output', 'completion']],
+    ['totalTokens', ['total_tokens', 'totalTokens', 'total']],
+    ['contextTokens', ['context_used', 'contextUsed', 'context_tokens', 'contextTokens']],
+    ['contextLimit', ['context_max', 'contextMax', 'context_limit', 'contextLimit', 'context_length', 'contextLength', 'effective_context_length']],
+    ['percentUsed', ['context_percent', 'contextPercent', 'percent_used', 'percentUsed']],
+  ]
+  const usage: ChatUsage = {}
+  for (const source of sources) {
+    const root = record(source)
+    for (const value of [record(root.usage), record(record(root.info).usage), root, record(root.info)]) {
+      for (const [field, aliases] of fields) {
+        if (usage[field] !== undefined) continue
+        for (const alias of aliases) {
+          const raw = value[alias]
+          if (typeof raw !== 'number' && (typeof raw !== 'string' || !raw.trim())) continue
+          const number = Number(raw)
+          if (!Number.isFinite(number) || number < 0 || (field === 'contextLimit' && number === 0)) continue
+          usage[field] = number
+          break
+        }
+      }
+    }
+  }
+  return Object.keys(usage).length ? { ...usage, raw: sources[0] as JsonValue } : undefined
 }

+ 8 - 18
src/client/utils/messageReducer.ts

@@ -1,9 +1,10 @@
 import type {
-  ApprovalRequest, ChatMessage, ChatRouteState, ChatUsage, ClarificationRequest, JsonValue, RpcEventFrame, ToolCall,
+  ApprovalRequest, ChatMessage, ChatRouteState, ClarificationRequest, JsonValue, RpcEventFrame, ToolCall,
 } from '@shared/types'
 import { createId } from './id'
 import { toolStatus } from '@shared/chatTools'
 import { bool, number, record, string } from './normalize'
+import { normalizeChatUsage } from './contextUsage'
 
 type MergePosition = 'append' | 'prepend' | 'snapshot'
 
@@ -173,20 +174,6 @@ function toolFromEvent(payload: Record<string, unknown>, status: ToolCall['statu
   }
 }
 
-function normalizeUsage(payload: Record<string, unknown>): ChatUsage | undefined {
-  const raw = record(payload.usage ?? payload)
-  if (!Object.keys(raw).length) return undefined
-  return {
-    inputTokens: number(raw.input_tokens ?? raw.input ?? raw.prompt) || undefined,
-    outputTokens: number(raw.output_tokens ?? raw.output ?? raw.completion) || undefined,
-    totalTokens: number(raw.total_tokens ?? raw.total) || undefined,
-    contextTokens: number(raw.context_tokens ?? raw.context_used ?? raw.current_tokens ?? raw.used) || undefined,
-    contextLimit: number(raw.context_limit ?? raw.context_max ?? raw.max_tokens ?? raw.limit) || undefined,
-    percentUsed: number(raw.percent_used ?? raw.context_percent ?? raw.percentage) || undefined,
-    raw: raw as JsonValue,
-  }
-}
-
 function updateStreamingMessage(
   state: ChatRouteState,
   payload: Record<string, unknown>,
@@ -302,7 +289,8 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
       next.isStreaming = waitingForOutput
       next.isQueued = false
       next.liveStatus = undefined
-      next.usage = normalizeUsage(payload) ?? next.usage
+      const usage = normalizeChatUsage(payload)
+      if (usage) next.usage = { ...next.usage, ...usage }
       if (failed) next.error = string(payload.error ?? payload.text ?? payload.message, '运行失败')
       break
     }
@@ -323,9 +311,11 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
       break
     case 'session.usage':
     case 'usage.update':
-    case 'context.update':
-      next.usage = normalizeUsage(payload)
+    case 'context.update': {
+      const usage = normalizeChatUsage(payload)
+      if (usage) next.usage = { ...next.usage, ...usage }
       break
+    }
     case 'tool.start':
     case 'tool.progress':
     case 'tool.started':

+ 4 - 10
src/client/views/ChatView.vue

@@ -24,7 +24,6 @@ import { consumeLibraryItemForComposer, loadComposerFile } from '@/components/wo
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
 import { modelChoiceId, modelForChoiceId } from '@/utils/sessionModel'
 import { MODEL_CATALOG_CHANGED_EVENT, modelCatalogChangedProfile } from '@/utils/modelCatalogEvents'
-import { estimateConversationTokens } from '@/utils/contextUsage'
 import { isOwnedChatSession } from '@/utils/sessionOwnership'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
@@ -72,12 +71,6 @@ const sidebarItems = computed(() => sessions.value.map(session => sessionSidebar
   agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
 )))
 const messages = computed(() => chatMessagesToUi(chat.messages, profile => profile ? agentNames.value.get(profile) : undefined))
-const reportedContextTokens = computed(() => chat.contextUsage?.contextTokens
-  || chat.contextUsage?.totalTokens
-  || (activeSession.value?.inputTokens ?? 0) + (activeSession.value?.outputTokens ?? 0))
-const estimatedContextTokens = computed(() => estimateConversationTokens(chat.messages))
-const contextUsed = computed(() => reportedContextTokens.value || estimatedContextTokens.value)
-const contextIsEstimated = computed(() => !reportedContextTokens.value && contextUsed.value > 0)
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const activeSession = computed(() => chat.activeSession)
@@ -469,9 +462,10 @@ watch(() => chat.activeSessionId, async id => {
         :reasoning-effort="reasoningLabel"
         :reasoning-value="chat.reasoningEffort || ''"
         :reasoning-options="reasoningComposerOptions"
-        :context-used="contextUsed"
-        :context-limit="chat.contextUsage?.contextLimit || 262144"
-        :context-estimated="contextIsEstimated"
+        show-context
+        :context-used="chat.contextUsage?.contextTokens"
+        :context-limit="chat.contextUsage?.contextLimit"
+        :context-percent="chat.contextUsage?.percentUsed"
         :queue-mode="queueMode || chat.isQueued"
         :tool-trace-visible="showThinking"
         :reference="reference"

+ 0 - 7
src/client/views/HistoryView.vue

@@ -24,7 +24,6 @@ import { consumeLibraryItemForComposer, loadComposerFile } from '@/components/wo
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
 import { modelChoiceId, modelForChoiceId } from '@/utils/sessionModel'
 import { MODEL_CATALOG_CHANGED_EVENT, modelCatalogChangedProfile } from '@/utils/modelCatalogEvents'
-import { estimateConversationTokens } from '@/utils/contextUsage'
 import { isOwnedChatSession } from '@/utils/sessionOwnership'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
@@ -74,12 +73,6 @@ const sidebarItems = computed(() => sessions.value.map(session => sessionSidebar
   agentNames.value.get(session.profile || auth.activeProfile?.name || ''),
 )))
 const messages = computed(() => chatMessagesToUi(chat.messages, profile => profile ? agentNames.value.get(profile) : undefined))
-const reportedContextTokens = computed(() => chat.contextUsage?.contextTokens
-  || chat.contextUsage?.totalTokens
-  || (activeSession.value?.inputTokens ?? 0) + (activeSession.value?.outputTokens ?? 0))
-const estimatedContextTokens = computed(() => estimateConversationTokens(chat.messages))
-const contextUsed = computed(() => reportedContextTokens.value || estimatedContextTokens.value)
-const contextIsEstimated = computed(() => !reportedContextTokens.value && contextUsed.value > 0)
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const activeSession = computed(() => chat.activeSession)

+ 53 - 0
tests/client/chatCrossClientSync.test.ts

@@ -137,6 +137,59 @@ describe('ordinary v2 cross-client store', () => {
     await vi.waitFor(() => expect(wire.clients.some((c) => c.id === id)).toBe(true))
     return wire.clients.filter((c) => c.id === id).at(-1)!
   }
+  it('refreshes an opened owned session with the same gauge source order as mobile', async () => {
+    wire.request.mockImplementation(async (method: string) => {
+      if (method === 'session.resume') return { session_id: 'runtime', stored_session_id: 's' }
+      if (method === 'session.usage') return { input_tokens: 1_800_000, output_tokens: 100_000, total_tokens: 1_900_000 }
+      if (method === 'session.context_breakdown') return { context_used: 12_500, context_max: 114_688, context_percent: 10.9 }
+      return {}
+    })
+    await open()
+    await vi.waitFor(() => expect(chat.contextUsage).toMatchObject({
+      totalTokens: 1_900_000, contextTokens: 12_500, contextLimit: 114_688, percentUsed: 10.9,
+    }))
+    expect(wire.request).toHaveBeenCalledWith('session.usage', { session_id: 'runtime' }, 15_000)
+    expect(wire.request).toHaveBeenCalledWith('session.context_breakdown', { session_id: 'runtime' }, 15_000)
+  })
+
+  it('keeps read-only history unattached when context statistics are unavailable', async () => {
+    chat.sessions = [session('s', false)]
+    await chat.selectSession('s', 'p')
+    expect(wire.request).not.toHaveBeenCalled()
+    expect(chat.contextUsage).toBeUndefined()
+  })
+
+  it('does not let a slow usage response replace a newer realtime gauge', async () => {
+    await open()
+    let finish!: (value: unknown) => void
+    wire.request.mockImplementation((method: string) => method === 'session.usage'
+      ? new Promise(resolve => { finish = resolve }) : Promise.resolve({}))
+    const refreshing = chat.refreshContextUsage()
+    emit('session.usage', { context_used: 4000, context_max: 8000, context_percent: 50 })
+    finish({ context_used: 1000, context_max: 8000, context_percent: 12.5 })
+    await refreshing
+    expect(chat.contextUsage).toMatchObject({ contextTokens: 4000, contextLimit: 8000, percentUsed: 50 })
+  })
+
+  it('refreshes context after a turn completed on another client', async () => {
+    const client = await open()
+    await client.changed(snapshot([answer('处理中', { status: 'streaming' })], 's', { running: true }))
+    wire.request.mockImplementation(async (method: string) => method === 'session.usage'
+      ? { context_used: 4000, context_max: 8000 } : {})
+    await client.changed(snapshot([answer()], 's', { running: false }))
+    await vi.waitFor(() => expect(chat.contextUsage).toMatchObject({ contextTokens: 4000, contextLimit: 8000 }))
+  })
+
+  it('restores a confirmed gauge from session metadata without inventing context from history', async () => {
+    wire.request.mockResolvedValue({ session_id: 'runtime', stored_session_id: 's',
+      info: { usage: { context_used: 2000, context_max: 8000 } } })
+    const client = await open()
+    expect(chat.contextUsage).toMatchObject({ contextTokens: 2000, contextLimit: 8000 })
+    await client.changed(snapshot([answer('很长的旧记录'.repeat(200))], 's', {
+      session: { input_tokens: 1_800_000, output_tokens: 100_000 },
+    }))
+    expect(chat.contextUsage).toMatchObject({ contextTokens: 2000, contextLimit: 8000 })
+  })
   it.each(['image_path', 'path', 'ref_path'])('preserves uploaded image %s in pending and canonical messages', async field => {
     const client = await open()
     const paths = ['/home/user/.hermes/images/照片 one.png', '/tmp/photo-two.png']

+ 10 - 8
tests/client/chatStore.test.ts

@@ -1,7 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import type { ChatMessage, ChatRouteState, RpcEventFrame } from '@shared/types'
 import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
-import { estimateConversationTokens } from '@/utils/contextUsage'
 
 function message(id: string, content = '相同内容', clientMessageId?: string): ChatMessage {
   return {
@@ -23,13 +22,6 @@ function event(type: string, payload: Record<string, unknown>, sessionId = 'sess
 }
 
 describe('chat message reducer', () => {
-  it('estimates visible conversation tokens when Hermes has no live gauge', () => {
-    expect(estimateConversationTokens([
-      message('one', '你好,Hermes!'),
-      { ...message('two', 'The quick brown fox'), role: 'assistant', reasoning: '检查上下文' },
-    ])).toBeGreaterThan(0)
-  })
-
   it('preserves repeated user text when server identities differ', () => {
     const merged = mergeChatMessages([message('one')], [message('two')])
     expect(merged).toHaveLength(2)
@@ -199,6 +191,16 @@ describe('chat message reducer', () => {
     expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, percentUsed: 10.9 })
   })
 
+  it('retains the context gauge when completion only updates cumulative counters', () => {
+    let current = applyChatEvent(state(), event('session.usage', { context_used: 12_500, context_max: 114_688 }))
+    current = applyChatEvent(current, event('message.complete', { text: '完成', usage: { total_tokens: 1_900_000 } }))
+    expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, totalTokens: 1_900_000 })
+    current = applyChatEvent(current, event('usage.update', { input_tokens: 10 }))
+    expect(current.usage).toMatchObject({ contextTokens: 12_500, contextLimit: 114_688, inputTokens: 10 })
+    current = applyChatEvent(current, event('context.update', { context_used: 0, context_percent: 0 }))
+    expect(current.usage).toMatchObject({ contextTokens: 0, percentUsed: 0 })
+  })
+
   it('tracks approval and clarification independently', () => {
     let current = applyChatEvent(state(), event('approval.request', { request_id: 'approval-1', message: '允许吗?' }))
     current = applyChatEvent(current, event('clarify.request', { request_id: 'clarify-1', question: '选哪个?' }))

+ 30 - 0
tests/client/composer.test.ts

@@ -2,6 +2,36 @@ import { mount } from '@vue/test-utils'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import ComposerShell from '@/components/composer/ComposerShell.vue'
 
+describe('composer context gauge', () => {
+  it('shows unknown measurements instead of inventing a 256k window or remaining budget', async () => {
+    const wrapper = mount(ComposerShell, { props: { showContext: true } })
+    expect(wrapper.get('.composer-context').text()).toBe('等待本轮实际用量 · 窗口未确认')
+    expect(wrapper.find('.composer-context i').exists()).toBe(false)
+    await wrapper.setProps({ contextLimit: 262_144 })
+    expect(wrapper.get('.composer-context').text()).toBe('等待本轮实际用量 · 窗口 256.0k')
+    expect(wrapper.text()).not.toContain('剩余')
+    wrapper.unmount()
+  })
+
+  it('uses confirmed context and server percentage, and clears the gauge after reset', async () => {
+    const wrapper = mount(ComposerShell, { props: { showContext: true, contextUsed: 2000, contextLimit: 8000, contextPercent: 45.5 } })
+    expect(wrapper.get('.composer-context').text()).toBe('2.0k / 7.8k · 剩余 5.9k')
+    expect(wrapper.get('.composer-context b').attributes('style')).toContain('45.5%')
+    expect(wrapper.get('.composer-context').attributes('title')).toContain('最近一次模型请求')
+    await wrapper.setProps({ contextUsed: 0, contextPercent: 0 })
+    expect(wrapper.get('.composer-context').text()).toBe('等待本轮实际用量 · 窗口 7.8k')
+    expect(wrapper.find('.composer-context i').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
+  it('shows confirmed occupancy without pretending the model window is known', () => {
+    const wrapper = mount(ComposerShell, { props: { showContext: true, contextUsed: 12_500 } })
+    expect(wrapper.get('.composer-context').text()).toBe('当前上下文 12.2k · 窗口未确认')
+    expect(wrapper.find('.composer-context i').exists()).toBe(false)
+    wrapper.unmount()
+  })
+})
+
 beforeEach(() => {
   const values = new Map<string, string>()
   vi.stubGlobal('localStorage', {

+ 38 - 0
tests/client/contextUsage.test.ts

@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeChatUsage } from '@/utils/contextUsage'
+
+describe('context usage shared by web and desktop', () => {
+  it('never treats cumulative billing or an output limit as context occupancy', () => {
+    const usage = normalizeChatUsage({ input_tokens: 1_800_000, output_tokens: 100_000,
+      total_tokens: 1_900_000, max_tokens: 262_144, used: 1_900_000, limit: 262_144 })
+    expect(usage).toMatchObject({ inputTokens: 1_800_000, outputTokens: 100_000, totalTokens: 1_900_000 })
+    expect(usage?.contextTokens).toBeUndefined()
+    expect(usage?.contextLimit).toBeUndefined()
+  })
+
+  it('uses the same last-prompt source order and server percentage as mobile', () => {
+    const usage = normalizeChatUsage({ usage: { context_used: 2000, context_max: 8000, context_percent: 45.5 } },
+      { context_used: 3000, context_max: 16000 })
+    expect(usage).toMatchObject({ contextTokens: 2000, contextLimit: 8000, percentUsed: 45.5 })
+  })
+
+  it('fills missing gauges from context breakdown without using cumulative totals', () => {
+    expect(normalizeChatUsage({ total: 1_900_000 }, { context_used: 12_500, context_max: 114_688 }))
+      .toMatchObject({ totalTokens: 1_900_000, contextTokens: 12_500, contextLimit: 114_688 })
+  })
+
+  it('recognizes nested session metadata and camel-case context fields', () => {
+    expect(normalizeChatUsage({ info: { usage: { contextUsed: 2000, contextMax: 8000, contextPercent: 0 } } }))
+      .toMatchObject({ contextTokens: 2000, contextLimit: 8000, percentUsed: 0 })
+    expect(normalizeChatUsage({ contextTokens: 2000, contextLimit: 8000 }))
+      .toMatchObject({ contextTokens: 2000, contextLimit: 8000 })
+  })
+
+  it('preserves an explicit reset to zero and ignores missing or invalid measurements', () => {
+    expect(normalizeChatUsage({ context_used: 0, context_percent: 0, total: 1_900_000 }))
+      .toMatchObject({ contextTokens: 0, percentUsed: 0 })
+    expect(normalizeChatUsage({ text: '完成', context_used: -1, context_max: 0, context_percent: Number.NaN }))
+      .toBeUndefined()
+    expect(normalizeChatUsage(undefined, {})).toBeUndefined()
+  })
+})

+ 6 - 4
tests/e2e/ordinary-local.spec.ts

@@ -2,16 +2,17 @@ import {test,expect} from '@playwright/test'
 
 test('ordinary SSE replies remain readable from Web without a second history source',async({page})=>{
   await page.goto('/chat')
-  if(await page.getByRole('heading',{name:'登录夭夭'}).isVisible()){
-    await page.getByLabel('账号').fill('admin');await page.getByLabel('密码').fill('e2e-password')
-    await page.getByRole('button',{name:'登录',exact:true}).click()
-  }
+  await expect(page.getByRole('heading',{name:'登录夭夭'})).toBeVisible()
+  await page.getByLabel('账号').fill('admin');await page.getByLabel('密码').fill('e2e-password')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
   await expect(page.getByRole('navigation')).toBeVisible()
   await page.getByRole('button',{name:'新建聊天',exact:true}).click()
+  await expect(page.locator('.composer-context')).toHaveText('等待本轮实际用量 · 窗口未确认')
   const prompt=`本地落盘验收 ${Date.now()}`
   await page.locator('.composer-textarea').fill(prompt)
   await page.getByRole('button',{name:'发送消息'}).click()
   await expect(page.locator('.message__content').filter({hasText:'这是来自假 Gateway 的流式回复。'})).toHaveCount(1,{timeout:15000})
+  await expect(page.locator('.composer-context')).toHaveText('12.2k / 112.0k · 剩余 99.8k')
   const result=await page.evaluate(async()=>{
     const response=await fetch('/api/app/sessions/session-new/messages?profile=yaoyao&limit=100')
     return{status:response.status,source:response.headers.get('X-Yaoyao-Data-Source'),body:await response.json()}
@@ -21,5 +22,6 @@ test('ordinary SSE replies remain readable from Web without a second history sou
   expect(result.body.messages.filter((m:any)=>m.role==='assistant').map((m:any)=>m.content)).toEqual(['这是来自假 Gateway 的流式回复。'])
   await page.reload()
   await expect(page.locator('.message__content').filter({hasText:'这是来自假 Gateway 的流式回复。'})).toHaveCount(1)
+  await expect(page.locator('.composer-context')).toHaveText('12.2k / 112.0k · 剩余 99.8k')
   await page.screenshot({path:'/tmp/yaoyao-ordinary-local-browser.png',fullPage:true})
 })