Jelajahi Sumber

修复聊天消息顺序与上下文用量

LSamien 2 bulan lalu
induk
melakukan
9d4c599d6b

+ 12 - 5
src/client/stores/chat.ts

@@ -202,9 +202,13 @@ export const useChatStore = defineStore('chat', () => {
       persistFastMode(state)
     }
     const attributedPayload = { ...payload, session_id: state.route.sessionId } as JsonValue
-    routes[key] = applyChatEvent(state, {
+    const updated = applyChatEvent(state, {
       ...event, session_id: state.route.sessionId, profile: state.route.profile, payload: attributedPayload,
     })
+    routes[key] = updated
+    if (['message.complete', 'run.completed'].includes(event.type)) {
+      void refreshContextUsage(updated).catch(() => undefined)
+    }
   })
 
   async function connect(): Promise<void> {
@@ -374,6 +378,10 @@ export const useChatStore = defineStore('chat', () => {
     // The runtime is attached lazily by send/interrupt/usage, or when a known
     // in-flight turn reconnects. REST history remains authoritative here.
     if (!state.historySynced) await loadHistory(state)
+    // Context usage comes from the realtime runtime rather than the REST
+    // history response.  Fetch it when opening an existing session so the
+    // composer does not stay at zero until a later event happens to include it.
+    void refreshContextUsage(state).catch(() => undefined)
     const refreshed = sessions.value.find(item => item.id === sessionId && item.profile === selectedProfile)
     syncSelectedModel(refreshed?.model, refreshed?.provider)
   }
@@ -791,10 +799,9 @@ export const useChatStore = defineStore('chat', () => {
     }
   }
 
-  async function refreshContextUsage(): Promise<void> {
-    const state = activeRouteState.value
-    if (!state) return
-    const current = await ensureRuntime(state)
+  async function refreshContextUsage(initialState = activeRouteState.value): Promise<void> {
+    if (!initialState || initialState.route.sessionId.startsWith('draft-')) return
+    const current = await ensureRuntime(initialState)
     const usage = resultRecord(await socket.request('session.usage', { session_id: current.runtimeSessionId! }))
     current.usage = {
       inputTokens: number(usage.input_tokens ?? usage.input) || undefined,

+ 25 - 1
src/client/utils/messageReducer.ts

@@ -6,6 +6,30 @@ import { bool, number, record, string } from './normalize'
 
 type MergePosition = 'append' | 'prepend' | 'snapshot'
 
+function messageTime(message: ChatMessage): number {
+  return message.timestamp < 10_000_000_000 ? message.timestamp * 1000 : message.timestamp
+}
+
+/**
+ * Keep the conversation in its canonical chronological order even when a
+ * history snapshot races a realtime event.  Sequence is preferred when both
+ * rows carry one; otherwise timestamps retain the order supplied by Hermes.
+ */
+export function orderChatMessages(messages: ChatMessage[]): ChatMessage[] {
+  return messages
+    .map((message, index) => ({ message, index }))
+    .sort((left, right) => {
+      const leftSequence = left.message.sequence
+      const rightSequence = right.message.sequence
+      if (leftSequence !== undefined && rightSequence !== undefined && leftSequence !== rightSequence) {
+        return leftSequence - rightSequence
+      }
+      const timeDelta = messageTime(left.message) - messageTime(right.message)
+      return timeDelta || left.index - right.index
+    })
+    .map(({ message }) => message)
+}
+
 function sameMessage(left: ChatMessage, right: ChatMessage): boolean {
   if (left.id && right.id && left.id === right.id) return true
   return Boolean(left.clientMessageId && right.clientMessageId && left.clientMessageId === right.clientMessageId)
@@ -35,7 +59,7 @@ export function mergeChatMessages(existing: ChatMessage[], incoming: ChatMessage
     if (index < 0) result.push(message)
     else result[index] = mergeOne(result[index], message)
   }
-  return result
+  return orderChatMessages(result)
 }
 
 function payloadRecord(event: RpcEventFrame['params']): Record<string, unknown> {

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

@@ -37,6 +37,22 @@ describe('chat message reducer', () => {
     expect(merged[0].stage).toBe('settled')
   })
 
+  it('keeps history and a realtime assistant reply in chronological order', () => {
+    const optimistic = { ...message('local-user', '新问题', 'client-1'), timestamp: 30, stage: 'pending' as const }
+    const older = { ...message('history-user', '旧问题'), timestamp: 10, sequence: 10 }
+    const newer = {
+      ...message('history-answer', '旧回答'), role: 'assistant' as const, timestamp: 20, sequence: 11,
+    }
+    const streamed = {
+      ...message('stream-answer', '新回答'), role: 'assistant' as const, timestamp: 31, stage: 'streaming' as const, isStreaming: true,
+    }
+
+    const merged = mergeChatMessages([optimistic], [newer, older], 'snapshot')
+    expect(mergeChatMessages(merged, [streamed]).map(item => item.id)).toEqual([
+      'history-user', 'history-answer', 'local-user', 'stream-answer',
+    ])
+  })
+
   it('replaces a cached iOS marker with an attachment-only normalized message', () => {
     const cached = message('ios-file', '[用户附加文件:车位.pdf]\n@file:`attachments/车位.pdf`')
     const normalized = {