Răsfoiți Sursa

补充会话上下文用量估算

LSamien 2 luni în urmă
părinte
comite
269837c547

+ 4 - 2
src/client/components/composer/ComposerShell.vue

@@ -18,6 +18,7 @@ const props = withDefaults(defineProps<{
   reasoningOptions?: ComposerOption[]
   contextUsed?: number
   contextLimit?: number
+  contextEstimated?: boolean
   queueMode?: boolean
   toolTraceVisible?: boolean
   reference?: ComposerReference | null
@@ -40,6 +41,7 @@ const props = withDefaults(defineProps<{
   reasoningOptions: () => [],
   contextUsed: 0,
   contextLimit: 0,
+  contextEstimated: false,
   queueMode: false,
   toolTraceVisible: true,
   reference: null,
@@ -430,8 +432,8 @@ 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 }">
-        <span>{{ formatTokens(contextUsed) }} / {{ formatTokens(contextLimit) }} · 剩余 {{ formatTokens(remainingContextTokens) }}</span>
+      <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>
 

+ 20 - 0
src/client/utils/contextUsage.ts

@@ -0,0 +1,20 @@
+import type { ChatMessage } from '@shared/types'
+
+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)
+}

+ 9 - 1
src/client/views/ChatView.vue

@@ -22,6 +22,7 @@ import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import { chatInteraction, chatMessagesToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
 import { consumeLibraryItemForComposer, loadComposerFile } from '@/components/workspace/pendingComposer'
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
+import { estimateConversationTokens } from '@/utils/contextUsage'
 import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { getSession } from '@/api/sessions'
@@ -60,6 +61,12 @@ 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)
@@ -432,8 +439,9 @@ watch(() => chat.activeSessionId, async id => {
         :reasoning-effort="reasoningLabel"
         :reasoning-value="chat.reasoningEffort || ''"
         :reasoning-options="reasoningComposerOptions"
-        :context-used="chat.contextUsage?.contextTokens || chat.contextUsage?.totalTokens || 0"
+        :context-used="contextUsed"
         :context-limit="chat.contextUsage?.contextLimit || 262144"
+        :context-estimated="contextIsEstimated"
         :queue-mode="queueMode || chat.isQueued"
         :tool-trace-visible="showThinking"
         :reference="reference"

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

@@ -1,6 +1,7 @@
 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 {
@@ -22,6 +23,13 @@ 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)