Procházet zdrojové kódy

fix(chat): 插入的用户消息按插入时机排序

Samien před 1 měsícem
rodič
revize
75f1472202

+ 46 - 4
src/server/chatTranscriptStore.ts

@@ -349,6 +349,40 @@ export class ChatTranscriptStore {
         .get(...scope)!.n,
     )
   }
+  private parkOpenAnswer(scope: Scope, state: TurnHead) {
+    if (!state.current) return
+    const active = this.message(...scope, state.current)
+    const visible = Boolean(
+      active &&
+        (String(active.content ?? '').trim() ||
+          String(active.reasoning ?? '').trim() ||
+          (Array.isArray(active.tool_calls) && active.tool_calls.length)),
+    )
+    if (active && visible) this.put(scope, { ...active, status: 'complete', final_result: false })
+    else if (active) {
+      const revision = active.revision + 1
+      this.db
+        .prepare(
+          'UPDATE chat_transcript_messages SET deleted=1,revision=? WHERE owner=? AND profile=? AND session_id=? AND id=?',
+        )
+        .run(revision, ...scope, active.id)
+      this.event(...scope, 'message.deleted', { id: active.id, revision })
+    }
+    state.current = undefined
+    state.preview = false
+  }
+  /** A later completion often repeats the whole turn. Keep only the part written after this row. */
+  private placedOutput(scope: Scope, row: TranscriptMessage | undefined, turn: string | undefined, output: unknown, fallback: unknown): unknown {
+    if (typeof output !== 'string') return output ?? fallback
+    if (!output || !turn) return output || fallback
+    const prefix = this.all(...scope)
+      .filter((message) => message.role === 'assistant' && message.turn_id === turn && message.id !== row?.id && (!row || message.seq < row.seq))
+      .sort((left, right) => left.seq - right.seq)
+      .map((message) => String(message.content ?? ''))
+      .join('')
+    if (!prefix || !output.startsWith(prefix)) return output
+    return output.slice(prefix.length) || fallback
+  }
   private put(scope: Scope, value: Data): TranscriptMessage {
     const position = Number(
       this.db
@@ -694,6 +728,10 @@ export class ChatTranscriptStore {
       if (!this.alias(...scope, `client:${client}`)) {
         const previousRunning = state.running,
           steer = p.method === 'session.steer' && state.running
+        // The open answer already has a sequence. Parking it here lets the
+        // inserted user row land at this moment, and the rest of the reply
+        // continues in a later row.
+        if (steer) this.parkOpenAnswer(scope, state)
         const turn = steer ? state.turn! : randomUUID()
         const user = this.put(scope, {
           id: `user:${delivery}`,
@@ -899,9 +937,11 @@ export class ChatTranscriptStore {
         type === 'message.complete' && !failed && !interrupted && !explicitStop && (toolRound || openTools)
       const output = p.output ?? p.text ?? p.content ?? p.message?.content
       if (continues) {
-        if (old || output || p.attachments)
+        const placed = this.placedOutput(scope, old, state.turn, output, old?.content ?? '')
+        const hasPlaced = typeof placed === 'string' ? placed.length > 0 : Boolean(placed)
+        if (old || hasPlaced || p.attachments)
           saveAssistant({
-            content: output === '' ? (old?.content ?? '') : (output ?? old?.content ?? ''),
+            content: placed,
             status: 'streaming',
             final_result: false,
           })
@@ -910,9 +950,11 @@ export class ChatTranscriptStore {
         state.queued = state.queue.length > 0 || Number(p.queue_remaining ?? 0) > 0
       } else {
       state.terminal = false
-      if (old || output || p.attachments)
+      const placed = this.placedOutput(scope, old, state.turn, output, old?.content ?? '')
+      const hasPlaced = typeof placed === 'string' ? placed.length > 0 : Boolean(placed)
+      if (old || hasPlaced || p.attachments)
         saveAssistant({
-          content: output === '' ? (old?.content ?? '') : (output ?? old?.content ?? ''),
+          content: placed,
           status: failed ? 'failed' : interrupted ? 'interrupted' : 'complete',
           segment_kind: 'answer',
           final_result: !failed && !interrupted,

+ 8 - 4
tests/server/ordinaryTranscriptV2.test.ts

@@ -113,17 +113,21 @@ describe('ordinary v2 canonical writer', () => {
     expect(new Set(all.filter((m) => m.role === 'assistant').map((m) => m.turn_id)).size).toBe(2)
     expect(store.transcripts.head(...scope).running).toBe(false)
   })
-  it('keeps steer in the current turn without moving the reply behind a future user', () => {
+  it('places a steered message at the insertion point and continues the reply after it', () => {
     const { store, send } = setup()
     store.recordCommand(...scope, 'prompt.submit', { text: '开始', _delivery_id: 'web:prompt:one' })
     send('message.delta', { text: '前' })
     const assistant = messages(store).find((m) => m.role === 'assistant')!
     store.recordCommand(...scope, 'session.steer', { text: '补充', _delivery_id: 'web:prompt:steer' })
     send('message.delta', { text: '后' })
-    send('message.complete')
-    expect(messages(store).filter((m) => m.role === 'assistant')).toMatchObject([
-      { id: assistant.id, content: '前后' },
+    send('message.complete', { text: '前后' })
+    expect(messages(store).map((m) => [m.role, m.content])).toEqual([
+      ['user', '开始'],
+      ['assistant', '前'],
+      ['user', '补充'],
+      ['assistant', '后'],
     ])
+    expect(messages(store).find((m) => m.content === '前')!.id).toBe(assistant.id)
     expect(new Set(messages(store).map((m) => m.turn_id)).size).toBe(1)
   })
   it('keeps a delayed tool result attached to its original segment', () => {