Parcourir la source

提交普通聊天服务端统一消息与持久化 SSE 试验版本

新增稳定消息标识、顺序与版本、事务内事件日志、快照及可重放 SSE,并接入 Web 缓存检查点。新协议需设置 HERMES_YAOYAO_ORDINARY_TRANSCRIPTS=1 显式启用。当前为阶段提交,三端联调、特殊过程事件及断线分页边界验收尚未完成。
Samien il y a 1 mois
Parent
commit
e1d79a1790

+ 98 - 0
src/client/api/chatTranscript.ts

@@ -0,0 +1,98 @@
+import { apiRequest } from './client'
+import { SSEParser } from '@shared/sse'
+import { applyTranscriptEvent, CHAT_TRANSCRIPT_FEATURE, TranscriptGap,
+  type TranscriptEvent, type TranscriptMessage, type TranscriptSnapshot } from '@shared/chatTranscript'
+
+export class ChatTranscriptClient {
+  private abort = new AbortController()
+  private messages: TranscriptMessage[] = []
+  private cursor = 0
+  private epoch = ''
+  private snapshot?: TranscriptSnapshot
+  private fetchingOlder = false
+  constructor(readonly profile:string,readonly sessionId:string,
+    readonly changed:(snapshot:TranscriptSnapshot)=>Promise<void>,readonly failed:(error:Error)=>void,
+    readonly initialCount=150){}
+  static async supported():Promise<boolean>{
+    try{return (await apiRequest<{features:string[]}>('/api/app/chat/capabilities')).features.includes(CHAT_TRANSCRIPT_FEATURE)}catch{return false}
+  }
+  close(){this.abort.abort()}
+  restore(value:TranscriptSnapshot){if(value.profile===this.profile&&value.sessionId===this.sessionId){this.snapshot=value;this.messages=value.messages;this.cursor=value.cursor;this.epoch=value.epoch}}
+  private path(suffix:string,query:Record<string,string>={}) {return `/api/app/chat/sessions/${encodeURIComponent(this.sessionId)}/${suffix}?${new URLSearchParams({profile:this.profile,...query})}`}
+  private async page(before?:number):Promise<TranscriptSnapshot>{
+    const value=await apiRequest<TranscriptSnapshot>(this.path('snapshot',{limit:'150',...(before===undefined?{}:{before:String(before)})}),{signal:this.abort.signal})
+    if(value.protocol!==CHAT_TRANSCRIPT_FEATURE||value.profile!==this.profile||value.sessionId!==this.sessionId)throw new TranscriptGap()
+    return value
+  }
+  private async publish(){if(this.snapshot&&!this.abort.signal.aborted)await this.changed({...this.snapshot,messages:this.messages,cursor:this.cursor,epoch:this.epoch})}
+  private async hydrate(){
+    const page=await this.page()
+    if(this.abort.signal.aborted)return
+    const first=page.messages[0]?.seq??Infinity,deleted=new Set(page.deletedIds??[])
+    const old=this.epoch===page.epoch?this.messages.filter(m=>m.seq<first&&!deleted.has(m.id)):[]
+    this.messages=[...old,...page.messages];this.epoch=page.epoch;this.cursor=page.cursor;this.snapshot=page
+    // Upgrade already loaded legacy pages before replacing the visible cache.
+    while(this.messages.length<this.initialCount&&this.snapshot.hasOlder&&this.messages.length){
+      const older=await this.page(this.messages[0]!.seq)
+      if(older.epoch!==this.epoch)throw new TranscriptGap()
+      const ids=new Set(this.messages.map(m=>m.id))
+      const added=older.messages.filter(m=>!ids.has(m.id))
+      this.messages=[...added,...this.messages];this.snapshot.hasOlder=older.hasOlder
+      if(!added.length)break
+    }
+    await this.publish()
+  }
+  async loadOlder(){
+    if(this.fetchingOlder||!this.snapshot?.hasOlder||!this.messages.length)return
+    this.fetchingOlder=true
+    try{
+      const page=await this.page(this.messages[0]!.seq)
+      if(page.epoch!==this.epoch)throw new TranscriptGap()
+      const current=new Map(this.messages.map(m=>[m.id,m]))
+      for(const message of page.messages)if((current.get(message.id)?.revision??0)<message.revision)current.set(message.id,message)
+      this.messages=[...current.values()].sort((a,b)=>a.seq-b.seq||a.id.localeCompare(b.id));this.snapshot.hasOlder=page.hasOlder
+      // A history page is not an event acknowledgement; never move cursor here.
+      await this.publish()
+    }finally{this.fetchingOlder=false}
+  }
+  async run(){
+    let needsSnapshot=!this.snapshot
+    while(!this.abort.signal.aborted){
+      let reader:ReadableStreamDefaultReader<Uint8Array>|undefined
+      try{
+        if(needsSnapshot){await this.hydrate();needsSnapshot=false}
+        const response=await fetch(this.path('events'),{headers:{Accept:'text/event-stream','Last-Event-ID':`${this.epoch}:${this.cursor}`},credentials:'include',cache:'no-store',signal:this.abort.signal})
+        if(response.status===409){needsSnapshot=true;throw new TranscriptGap()}
+        if(!response.ok||!response.body)throw new Error(`聊天消息流 HTTP ${response.status}`)
+        const parser=new SSEParser(),decoder=new TextDecoder();reader=response.body.getReader()
+        while(!this.abort.signal.aborted){
+          const {value,done}=await reader.read();if(done)break
+          for(const frame of parser.feed(decoder.decode(value,{stream:true}))){
+            if(frame.event==='reset')throw new TranscriptGap()
+            if(frame.event!=='transcript')continue
+            const event=JSON.parse(frame.data) as TranscriptEvent
+            if(event.profile!==this.profile||event.sessionId!==this.sessionId||!Number.isSafeInteger(event.cursor))throw new TranscriptGap()
+            if(event.cursor<=this.cursor)continue
+            if(event.type==='session.deleted'||event.type==='session.migrated')throw new TranscriptGap()
+            const data=event.data as {seq?:number}
+            // Older, unloaded pages are read from their current snapshot later.
+            if(!(event.type==='message.patch'&&data.seq!==undefined&&this.messages[0]&&data.seq<this.messages[0].seq))
+              this.messages=applyTranscriptEvent(this.messages,event)
+            if(this.snapshot&&event.type==='session.changed'){
+              const detail=event.data as {eventType?:string;payload?:Record<string,unknown>;running?:boolean;queued?:boolean}
+              this.snapshot.running=detail.running;this.snapshot.queued=detail.queued
+              if(detail.eventType==='session.info'&&detail.payload)this.snapshot.session={...this.snapshot.session,...detail.payload}
+            }
+            const previous=this.cursor;this.cursor=event.cursor
+            try{await this.publish()}catch(error){this.cursor=previous;throw error}
+          }
+        }
+      }catch(error){
+        if(this.abort.signal.aborted)return
+        if(error instanceof TranscriptGap)needsSnapshot=true
+        this.failed(error instanceof Error?error:new Error('聊天消息需要同步'))
+      }finally{await reader?.cancel().catch(()=>{});reader?.releaseLock()}
+      await new Promise<void>(resolve=>{const finish=()=>{clearTimeout(timer);this.abort.signal.removeEventListener('abort',finish);resolve()};const timer=setTimeout(finish,1000);this.abort.signal.addEventListener('abort',finish,{once:true});if(this.abort.signal.aborted)finish()})
+    }
+  }
+}

+ 3 - 2
src/client/api/httpRealtime.ts

@@ -14,8 +14,8 @@ export class HTTPRealtimeChannel {
   private abort?: AbortController
   private ready = false
   constructor(readonly onFrame: (frame: string) => Promise<unknown>, readonly onFailure: (error: Error) => void) {}
-  static async requireSupport(): Promise<void> {
-    let value: { protocolVersion: number; csrfToken?: string }
+  static async requireSupport(): Promise<string[]> {
+    let value: { protocolVersion: number; csrfToken?: string; features?: string[] }
     try { value = await apiRequest('/api/realtime/capabilities') }
     catch (e) {
       if (e instanceof ApiError && [404, 405, 410].includes(e.status)) throw new ApiError('此服务端不支持 HTTP+SSE,请升级 15300 服务', 409, 'HTTP_SSE_REQUIRED')
@@ -23,6 +23,7 @@ export class HTTPRealtimeChannel {
     }
     if (value.protocolVersion !== 1) throw new ApiError('不支持的实时协议版本', 409, 'UNSUPPORTED_REALTIME_PROTOCOL')
     if (value.csrfToken) setApiCsrfToken(value.csrfToken)
+    return value.features ?? []
   }
   async open(channel: 'chat' | 'groups', anchor?: { epoch: string; cursor: number }): Promise<void> {
     const created = await apiRequest<{ id: string }>('/api/realtime/channels', { method: 'POST', body: { channel, ...anchor } })

+ 2 - 1
src/client/api/realtime.ts

@@ -22,6 +22,7 @@ export class ChatRpcSocket {
   private eventListeners = new Set<RpcEventListener>()
   private stateListeners = new Set<StateListener>()
   state: RealtimeConnectionState = 'idle'
+  transcriptSupported = false
 
   onEvent(listener: RpcEventListener): () => void { this.eventListeners.add(listener); return () => this.eventListeners.delete(listener) }
   onState(listener: StateListener): () => void { this.stateListeners.add(listener); return () => this.stateListeners.delete(listener) }
@@ -29,7 +30,7 @@ export class ChatRpcSocket {
     this.close()
     const generation = ++this.generation
     this.publishState('connecting')
-    await HTTPRealtimeChannel.requireSupport()
+    this.transcriptSupported = (await HTTPRealtimeChannel.requireSupport()).includes('ordinary-chat-transcript-v1')
     if (generation !== this.generation) return
     this.http = new HTTPRealtimeChannel(frame => this.handleMessage(frame, generation), error => {
       if (generation === this.generation) this.publishState('failed', error.message)

+ 67 - 8
src/client/stores/chat.ts

@@ -8,6 +8,8 @@ import {
 } from '@/api/sessions'
 import { getModels } from '@/api/profiles'
 import { ChatRpcSocket, RpcError } from '@/api/realtime'
+import { ChatTranscriptClient } from '@/api/chatTranscript'
+import type { TranscriptMessage, TranscriptSnapshot } from '@shared/chatTranscript'
 import { ApiError } from '@/api/client'
 import { encodeAttachment } from '@/utils/attachments'
 import { ScopedCache } from '@/utils/cache'
@@ -20,7 +22,7 @@ import { modelForSession, modelSelectionFromSessionInfo } from '@/utils/sessionM
 import { moveSessionFastMode, readSessionFastMode, writeSessionFastMode } from '@/utils/sessionPreferences'
 import { useAuthStore } from './auth'
 
-interface CachedHistory { messages: ChatMessage[]; total: number; savedAt: number }
+interface CachedHistory { messages: ChatMessage[]; total: number; savedAt: number; transcript?: Omit<TranscriptSnapshot,'messages'> }
 const historyCache = new ScopedCache<CachedHistory>('chat-history-v1')
 const INFLIGHT_SESSION_STORAGE_PREFIX = 'hermes-yaoyao:inflight-chat-sessions:'
 const SESSION_LIST_REFRESH_DEBOUNCE_MS = 200
@@ -115,6 +117,48 @@ export const useChatStore = defineStore('chat', () => {
   let reconnectResumePromise: Promise<unknown> | undefined
   let persistedRecoveryPromise: Promise<void> | undefined
   const runtimePromises = new Map<string, Promise<ChatRouteState>>()
+  const transcriptSupported = ref(false)
+  let transcript: ChatTranscriptClient | undefined
+  let transcriptGeneration = 0
+
+  function startTranscript(): void {
+    const generation=++transcriptGeneration
+    transcript?.close();transcript=undefined
+    const profile=activeProfileName.value,id=activeSessionId.value,owner=accountId()
+    if(!transcriptSupported.value||!profile||!id||id.startsWith('draft-')||sessionView==='history')return
+    const key=routeKey(profile,id),state=ensureRoute(profile,id),scope=cacheScope(profile)
+    const current=()=>generation===transcriptGeneration&&owner===accountId()?routes[key]:undefined
+    void (async()=>{
+      const cached=await historyCache.get(scope,id)
+      if(!current())return
+      if(!state.messages.length&&cached?.messages.length)state.messages=cached.messages
+      const client=new ChatTranscriptClient(profile,id,async value=>{
+        const initial=current();if(!initial)return
+        const canonical=value.messages.map(message=>{
+          const mapped=normalizeChatMessage(message,id,profile)
+          if(message.status==='pending')mapped.stage='pending'
+          return mapped
+        })
+        const ids=new Set(canonical.flatMap(m=>[m.id,m.clientMessageId].filter(Boolean)))
+        const pending=initial.messages.filter(m=>m.role==='user'&&m.stage!=='settled'&&!ids.has(m.id)&&!ids.has(m.clientMessageId))
+        const messages=[...canonical,...pending]
+        const {messages:_,...checkpoint}=value
+        await historyCache.set(scope,id,{messages,total:value.total,savedAt:Date.now(),transcript:checkpoint})
+        const latest=current();if(!latest)return
+        latest.messages=messages;latest.messageTotal=value.total;latest.loadedMessageCount=canonical.length
+        latest.hasMoreBefore=value.hasOlder;latest.historySynced=true;latest.isLoadingHistory=false;latest.error=undefined
+        latest.isStreaming=value.running??canonical.some(m=>m.isStreaming);latest.isQueued=value.queued??false
+        if(!latest.isStreaming)latest.liveStatus=undefined
+        updateInflightMarker(latest)
+      },error=>{const latest=current();if(latest){latest.error=error.message;latest.historySynced=false}},Math.max(150,state.messages.length))
+      if(cached?.transcript){
+        const canonical=cached.messages.map(m=>m.raw as unknown as TranscriptMessage).filter(m=>m?.id&&Number.isSafeInteger(m.revision)&&Number.isSafeInteger(m.seq))
+        client.restore({...cached.transcript,messages:canonical})
+      }
+      transcript=client;await client.run()
+    })().catch(error=>{const latest=current();if(latest)latest.error=errorMessage(error)})
+  }
+  watch(()=>[activeSessionId.value,activeProfileName.value,transcriptSupported.value,auth.user?.id],startTranscript)
 
   const activeRouteState = computed(() => {
     if (!activeSessionId.value || !activeProfileName.value) return undefined
@@ -305,7 +349,7 @@ export const useChatStore = defineStore('chat', () => {
     if (event.type === 'gateway.ready') return
     const payload = record(event.payload)
     if (event.type === 'sessions.changed') {
-      if(payload.reason==='cache.synced' && typeof payload.profile==='string' && typeof payload.session_id==='string'){
+      if(!transcriptSupported.value && payload.reason==='cache.synced' && typeof payload.profile==='string' && typeof payload.session_id==='string'){
         const state=routes[routeKey(payload.profile,payload.session_id)]
         if(state){
           state.historySyncRevision=(state.historySyncRevision??0)+1
@@ -333,6 +377,18 @@ export const useChatStore = defineStore('chat', () => {
     }
     if (!key) return
     let state = routes[key]
+    if(transcriptSupported.value&&sessionView==='chat'&&(
+      /^(message\.|tool\.|reasoning\.|thinking\.)/.test(event.type)
+      || ['run.started','run.completed','run.failed','run.peer_user_message','peer.user.message'].includes(event.type))){
+      // Message bodies come exclusively from the persisted transcript stream.
+      // Legacy control events may only wake/settle the execution indicator.
+      if(['message.start','run.started'].includes(event.type))state.isStreaming=true
+      if(['message.complete','run.completed','run.failed'].includes(event.type)){
+        state.isQueued=number(payload.queue_remaining)>0;state.isStreaming=state.isQueued||number(payload.background_pending)>0
+        if(!state.isStreaming){state.liveStatus=undefined;state.pendingApproval=undefined;state.pendingClarification=undefined}
+      }
+      updateInflightMarker(state);return
+    }
     if (event.type === 'session.info') {
       const storedId = string(payload.stored_session_id ?? payload.storedSessionId ?? payload.session_key)
       if (storedId && storedId !== state.route.sessionId) {
@@ -369,7 +425,7 @@ export const useChatStore = defineStore('chat', () => {
       window.clearTimeout(reconnectTimer)
       reconnectTimer = undefined
     }
-    connectPromise = socket.connect().catch(cause => {
+    connectPromise = socket.connect().then(()=>{if(auth.isAuthenticated)transcriptSupported.value=socket.transcriptSupported===true}).catch(cause => {
       connectionState.value = 'failed'
       error.value = errorMessage(cause)
       throw cause
@@ -378,6 +434,7 @@ export const useChatStore = defineStore('chat', () => {
   }
 
   function disconnect(): void {
+    transcriptGeneration++;transcript?.close();transcript=undefined;transcriptSupported.value=false
     if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer)
     reconnectTimer = undefined
     socket.close()
@@ -491,6 +548,7 @@ export const useChatStore = defineStore('chat', () => {
   }
 
   async function loadHistory(state: ChatRouteState, forceRefresh = false): Promise<void> {
+    if(transcriptSupported.value&&sessionView==='chat'){if(state===activeRouteState.value)startTranscript();return}
     const key = routeKey(state.route.profile, state.route.sessionId)
     const generation = ++historyLoadGeneration
     state.generation = generation
@@ -573,6 +631,7 @@ export const useChatStore = defineStore('chat', () => {
   }
 
   async function loadOlder(): Promise<void> {
+    if(transcriptSupported.value&&transcript){await transcript.loadOlder();return}
     const state = activeRouteState.value
     if (!state || state.isLoadingHistory || !state.hasMoreBefore) return
     const key = routeKey(state.route.profile, state.route.sessionId)
@@ -660,7 +719,7 @@ export const useChatStore = defineStore('chat', () => {
     persistFastMode(migrated)
     const resumedMessages = values(result.messages)
       .map(value => normalizeChatMessage(value, storedId, migrated.route.profile))
-    if (resumedMessages.length) migrated.messages = mergeChatMessages(migrated.messages, resumedMessages, 'snapshot')
+    if (!transcriptSupported.value&&resumedMessages.length) migrated.messages = mergeChatMessages(migrated.messages, resumedMessages, 'snapshot')
     const inflight = record(result.inflight ?? result.active_run)
     const assistant = string(inflight.assistant)
     const inflightError = string(inflight.error)
@@ -671,7 +730,7 @@ export const useChatStore = defineStore('chat', () => {
     // A turn can be thinking or calling tools before producing any text.
     // Create its live row now so subsequent tool events have a visible target.
     const existing = [...migrated.messages].reverse().find(message => message.role === 'assistant' && message.isStreaming)
-    if (migrated.isStreaming || inflightError || (existing && assistant)) {
+    if (!transcriptSupported.value&&(migrated.isStreaming || inflightError || (existing && assistant))) {
       migrated.messages = mergeChatMessages(migrated.messages, [{
         ...existing,
         id: existing?.id ?? `resume:${runtimeId}`,
@@ -687,11 +746,11 @@ export const useChatStore = defineStore('chat', () => {
     }
     if (!migrated.isStreaming) {
       migrated.liveStatus = undefined
-      migrated.messages = settleChatMessages(migrated.messages)
+      if(!transcriptSupported.value)migrated.messages = settleChatMessages(migrated.messages)
     }
     migrated.pendingApproval = undefined
     migrated.pendingClarification = undefined
-    values(inflight.corrections).forEach((correction, index) => {
+    values(transcriptSupported.value?[]:inflight.corrections).forEach((correction, index) => {
       const content = string(correction)
       if (!content) return
       migrated.messages = mergeChatMessages(migrated.messages, [{
@@ -704,7 +763,7 @@ export const useChatStore = defineStore('chat', () => {
         stage: 'accepted',
       }])
     })
-    if (queuedUser) {
+    if (!transcriptSupported.value&&queuedUser) {
       migrated.messages = mergeChatMessages(migrated.messages, [{
         id: `resume-queued:${runtimeId}`,
         sessionId: storedId,

+ 5 - 2
src/server/app.ts

@@ -1,3 +1,4 @@
+import { chatTranscriptRouter } from './chatTranscriptApi.js'
 import {DesktopEnvironments} from './desktopEnvironments.js'
 import type {GrokAuth} from './grokAuth.js'
 import {GrokCloud} from './grokCloud.js'
@@ -261,6 +262,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   }, (owner, profile, sessionID) =>
     chatCache?.store.ownsSession(owner, profile, sessionID) ?? false)
   realtime.broker.protectedSession = id => workspace.ownsUpstream(id)
+  realtime.transcriptsAvailable = chatCache.store.transcripts.enabled
   realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
     chatCache?.observe(owner, profile, storedId, frame)
     if(/^(message\.(start|delta|interim|complete)|reasoning\.|tool\.|run\.(completed|failed))/.test(frame.type)){
@@ -290,8 +292,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   realtime.broker.onNativeCommand = (owner, profile, storedId, method, params) => {
     chatCache?.command(owner, profile, storedId, method, params)
   }
-  realtime.broker.onNativeRoute = (owner, profile, storedId, runtimeId) => {
-    chatCache?.route(owner, profile, storedId, runtimeId)
+  realtime.broker.onNativeRoute = (owner, profile, storedId, runtimeId, running) => {
+    chatCache?.route(owner, profile, storedId, runtimeId, running)
   }
   chatPushJobs.setTransportFactory(realtime.recoveryTransport, job => realtime.ownsPushJob(job.id))
   try {
@@ -447,6 +449,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
       } catch { /* Preserve non-JSON error responses. */ }
     }
   })
+  app.use(chatTranscriptRouter(chatCache, auth).routes())
   const router = createApiRouter({
     onChatListChanged: (owner,profile,id) => realtime.broker.publishOwnerSessionsChanged(owner,'local-state',profile,id),
     onServerIdentityChanged: identity => realtime.broker.publishServerIdentity(identity, config.upstream.href),

+ 34 - 7
src/server/chatCache.ts

@@ -1,4 +1,5 @@
 import { projectChatEvent } from './chatEventProjection.js'
+import { ChatTranscriptStore } from './chatTranscriptStore.js'
 import { isFinalChatResult } from './chatUnread.js'
 import { HttpError } from './errors.js'
 import { createHash, randomUUID } from 'node:crypto'
@@ -139,6 +140,7 @@ function referencedPaths(value: unknown, output = new Set<string>()): Set<string
 export class ChatCacheStore {
   readonly db: DatabaseSync
   readonly assetsRoot: string
+  readonly transcripts: ChatTranscriptStore
   private closed = false
   get isClosed(): boolean { return this.closed }
 
@@ -316,9 +318,10 @@ export class ChatCacheStore {
       this.db.exec('ROLLBACK')
       throw error
     }
+    this.transcripts = new ChatTranscriptStore(this.db,(owner,profile,id,rows)=>this.userMessageIdentities(owner,profile,id,rows,false))
   }
 
-  close(): void { this.closed = true; this.db.close() }
+  close(): void { this.closed = true; this.transcripts.close(); this.db.close() }
 
   stats(): Record<string, number> {
     const count = (table: string) => Number((this.db.prepare(`SELECT COUNT(*) count FROM ${table}`).get() as { count: number }).count)
@@ -429,6 +432,7 @@ export class ChatCacheStore {
       }
       if(typeof patch.title==='string') this.recordAuthoritativeTitle(owner,profile,id,patch.title)
       this.db.prepare("UPDATE chat_sessions SET sync_state=CASE WHEN complete=1 THEN 'current' ELSE sync_state END WHERE owner=? AND profile=? AND session_id=?").run(owner,profile,id)
+      this.transcripts.sessionChanged(owner,profile,id,{eventType:'session.info',payload:data})
       this.db.exec('RELEASE local_chat_patch')
     } catch(e) {this.db.exec('ROLLBACK TO local_chat_patch; RELEASE local_chat_patch');throw e}
   }
@@ -447,6 +451,7 @@ export class ChatCacheStore {
     if (typeof message.content==='string' && message.content.trim()) summary.preview=message.content.slice(0,160)
     this.db.prepare('UPDATE chat_sessions SET data=?,message_total=?,last_synced_at=? WHERE owner=? AND profile=? AND session_id=?').run(JSON.stringify(summary),total,now,owner,profile,id)
     for(const path of referencedPaths(message)) this.db.prepare(`INSERT OR IGNORE INTO chat_attachments(owner,profile,session_id,source_path,state,updated_at) VALUES(?,?,?,?,'pending',?)`).run(owner,profile,id,path,now)
+    this.transcripts.upsert(owner,profile,id,message,position)
   }
 
   projectedMessageID(owner:string,profile:string,id:string):string|undefined {
@@ -478,7 +483,7 @@ export class ChatCacheStore {
     if(message){
       if(previous && previous.id !== message.id && !startsNew){
         const exists=this.db.prepare('SELECT 1 FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?').get(owner,profile,id,message.id)
-        if(!exists)this.db.prepare('UPDATE chat_messages SET message_id=? WHERE owner=? AND profile=? AND session_id=? AND message_id=?').run(message.id,owner,profile,id,previous.id)
+        if(!exists){this.transcripts.promote(owner,profile,id,String(previous.id),String(message.id));this.db.prepare('UPDATE chat_messages SET message_id=? WHERE owner=? AND profile=? AND session_id=? AND message_id=?').run(message.id,owner,profile,id,previous.id)}
         else {this.db.prepare("UPDATE chat_sessions SET sync_state='gap' WHERE owner=? AND profile=? AND session_id=?").run(owner,profile,id);return}
       }
       this.writeLiveMessage(owner,profile,id,message)
@@ -560,10 +565,14 @@ export class ChatCacheStore {
           const identified = this.userMessageIdentities(owner, profile, sessionID, messages, false)
           const end=pagination.total-Number(pagination.offset??0),start=end-messages.length
           const ids=new Set(messages.map((m,index)=>messageID(m,String(start+index))))
-          const existing=this.db.prepare('SELECT message_id,data FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND position>=? AND position<?').all(owner,profile,sessionID,start,end) as {message_id:string;data:string}[]
+          const existing=this.db.prepare('SELECT message_id,data,position FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND position>=? AND position<?').all(owner,profile,sessionID,start,end) as {message_id:string;data:string;position:number}[]
           for(const row of existing){
             if(ids.has(row.message_id))continue
             const value=JSON.parse(row.data)
+            const incoming=messages[row.position-start]
+            if(incoming&&value.role==='assistant'&&incoming.role==='assistant'&&/^event:/.test(row.message_id)
+              &&typeof value.content==='string'&&value.content.length>0&&value.content===incoming.content)
+              this.transcripts.promote(owner,profile,sessionID,row.message_id,messageID(incoming,String(row.position)))
             if(value.role==='user'&&value.status==='pending'&&!identified.some(m=>m.role==='user'
               && value.client_message_id && m.client_message_id===value.client_message_id))
               throw new Error('Unconfirmed submission must be reconciled before history replacement')
@@ -583,6 +592,8 @@ export class ChatCacheStore {
       }))
       const latest=this.db.prepare("SELECT message_id FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND json_extract(data,'$.role')='assistant' ORDER BY position DESC LIMIT 1").get(owner,profile,sessionID) as {message_id:string}|undefined
       if(latest)this.db.prepare('UPDATE chat_local_state SET assistant_id=? WHERE owner=? AND profile=? AND session_id=?').run(latest.message_id,owner,profile,sessionID)
+      this.transcripts.reconcile(owner,profile,sessionID)
+      this.transcripts.sessionChanged(owner,profile,sessionID,{eventType:'history.synced'})
       this.db.exec('RELEASE history_sync')
       return true
     } catch (error) {
@@ -745,6 +756,7 @@ export class ChatCacheStore {
       sync_state=excluded.sync_state,last_synced_at=excluded.last_synced_at`)
       .run(owner, profile, rawSeq > 0 ? rawSeq : lastSeen, state, now)
     this.projectEvent(owner,profile,sessionID,type,object(frame.payload)??{},eventID)
+    if (!['message.delta','reasoning.delta','thinking.delta'].includes(type)&&!type.startsWith('tool.')) this.transcripts.sessionChanged(owner,profile,sessionID,{eventType:type,payload:object(frame.payload)??{}})
     this.db.exec('RELEASE live_event')
     return true
     }catch(e){this.db.exec('ROLLBACK TO live_event; RELEASE live_event');throw e}
@@ -771,11 +783,14 @@ export class ChatCacheStore {
       this.db.prepare('INSERT OR REPLACE INTO chat_session_aliases VALUES(?,?,?,?)').run(owner,profile,old.session_id,id)
       this.db.prepare('UPDATE chat_session_aliases SET session_id=? WHERE owner=? AND profile=? AND session_id=?').run(id,owner,profile,old.session_id)
       this.db.prepare("UPDATE chat_sessions SET data=json_set(data,'$.id',?) WHERE owner=? AND profile=? AND session_id=?").run(id,owner,profile,id)
+      this.transcripts.migrate(owner,profile,old.session_id,id)
       this.db.exec('RELEASE migrate_live_route')
     }catch(e){this.db.exec('ROLLBACK TO migrate_live_route; RELEASE migrate_live_route');throw e}
   }
 
-  recordRoute(owner: string, profile: string, sessionID: string, runtimeID: string): void {
+  recordRoute(owner: string, profile: string, sessionID: string, runtimeID: string, running?: boolean): void {
+    this.db.exec('SAVEPOINT chat_route')
+    try {
     this.migrateRuntimeSession(owner,profile,sessionID,runtimeID)
     const now = Date.now()
     const summary = { id: sessionID, profile, source: 'unknown', title: '新对话', started_at: now, last_active: now }
@@ -785,10 +800,15 @@ export class ChatCacheStore {
       ownership_evidence=COALESCE(chat_sessions.ownership_evidence,excluded.ownership_evidence)`)
       .run(owner, profile, sessionID, runtimeID, 'unknown', JSON.stringify(summary), 'stale', 0, now, now, 'route-confirmed')
     this.markListsStale(owner)
+    if (running !== undefined) this.transcripts.sessionChanged(owner,profile,sessionID,{eventType:'route.resumed',payload:{running}})
+    this.db.exec('RELEASE chat_route')
+    } catch(error) { this.db.exec('ROLLBACK TO chat_route; RELEASE chat_route'); throw error }
   }
 
   recordCommand(owner: string, profile: string, sessionID: string, method: string,
     params: Record<string, unknown>): void {
+    this.db.exec('SAVEPOINT chat_command')
+    try {
     const now = Date.now()
     const source = sessionSource(params) || 'unknown'
     const establishesOwnership = method === 'session.create' || method === 'session.branch'
@@ -806,7 +826,10 @@ export class ChatCacheStore {
     if((method==='prompt.submit'||method==='session.steer') && params._delivery_id){
       const userID=`user:${params._delivery_id}`
       this.writeLiveMessage(owner,profile,sessionID,{id:userID,role:'user',content:params.text??params.input??'',timestamp:now/1000,status:'pending',client_message_id:String(params._delivery_id).replace(/^(web|ios):prompt:/,'')})
-      this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,assistant_id) VALUES(?,?,?,NULL) ON CONFLICT(owner,profile,session_id) DO UPDATE SET assistant_id=NULL`).run(owner,profile,sessionID)
+      const streaming=this.db.prepare(`SELECT 1 FROM chat_local_state l JOIN chat_messages m ON m.owner=l.owner AND m.profile=l.profile AND m.session_id=l.session_id AND m.message_id=l.assistant_id
+        WHERE l.owner=? AND l.profile=? AND l.session_id=? AND json_extract(m.data,'$.status')='streaming'`).get(owner,profile,sessionID)
+      if(!streaming)this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,assistant_id) VALUES(?,?,?,NULL) ON CONFLICT(owner,profile,session_id) DO UPDATE SET assistant_id=NULL`).run(owner,profile,sessionID)
+      this.transcripts.sessionChanged(owner,profile,sessionID,{eventType:'command.submitted',payload:{delivery_id:params._delivery_id}})
     }
     const payload = JSON.stringify(params)
     const seq = Number.parseInt(createHash('sha256').update(`${method}:${payload}`).digest('hex').slice(0, 12), 16)
@@ -814,6 +837,8 @@ export class ChatCacheStore {
       VALUES(?,?,?,?,?,?,?)`).run(owner, profile, sessionID, seq, `command:${method}`, payload, now)
     this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE owner=? AND (session_id=? OR kind='list')")
       .run(owner, sessionID)
+    this.db.exec('RELEASE chat_command')
+    } catch(error) { this.db.exec('ROLLBACK TO chat_command; RELEASE chat_command'); throw error }
   }
 
   fileSourceMessage(owner: string, profile: string, sessionID: string, messageID: string): Record<string, unknown> | undefined {
@@ -869,6 +894,7 @@ export class ChatCacheStore {
       }
       this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND ((profile=? AND session_id=?) OR kind='list')")
         .run(owner, profile, sessionID)
+      this.transcripts.remove(owner,profile,sessionID)
       this.db.exec('COMMIT')
       for (const asset of assets) {
         const referenced = this.db.prepare('SELECT 1 ok FROM chat_attachments WHERE local_path=? LIMIT 1').get(asset.local_path)
@@ -1220,6 +1246,7 @@ export class ChatCacheStore {
         WHERE chat_messages.content_hash<>excluded.content_hash OR chat_messages.position<>excluded.position`)
         .run(owner, profile, sessionID, messageID(message, String(start + index)), start + index, data,
           createHash('sha256').update(data).digest('hex'))
+      this.transcripts.upsert(owner,profile,sessionID,{...message,id:messageID(message,String(start+index))},start+index)
       for (const sourcePath of referencedPaths(message)) {
         this.db.prepare(`INSERT INTO chat_attachments(owner,profile,session_id,source_path,state,updated_at)
           VALUES(?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source_path) DO UPDATE SET updated_at=excluded.updated_at`)
@@ -1438,8 +1465,8 @@ export class ChatCacheCoordinator {
     this.store.recordCommand(owner, profile, sessionID, method, params)
   }
 
-  route(owner: string, profile: string, sessionID: string, runtimeID: string): void {
-    this.store.recordRoute(owner, profile, sessionID, runtimeID)
+  route(owner: string, profile: string, sessionID: string, runtimeID: string, running?: boolean): void {
+    this.store.recordRoute(owner, profile, sessionID, runtimeID, running)
   }
 
   async reconcile(owner: string, profile: string, sessionID: string, force = false): Promise<void> {

+ 13 - 4
src/server/chatEventProjection.ts

@@ -4,7 +4,7 @@ export type ChatRecord = Record<string, any>
 const text = (p: ChatRecord) => String(p.delta ?? p.text_delta ?? p.content_delta ?? p.text ?? '')
 export function projectChatEvent(type: string, payload: ChatRecord, previous: ChatRecord | undefined,
   fallbackID: string, now: number): ChatRecord | undefined {
-  const supported = /^(message\.(start|delta|interim|complete)|reasoning\.(delta|complete)|tool\.|run\.(completed|failed)|error$)/.test(type)
+  const supported = /^(message\.(start|delta|interim|complete)|(?:reasoning|thinking)\.(delta|complete)|tool\.|run\.(completed|failed)|error$)/.test(type)
   if (!supported) return undefined
   // Parameter-generation notifications have no invocation identity. They are
   // progress text, not an additional tool call which can later complete.
@@ -14,8 +14,16 @@ export function projectChatEvent(type: string, payload: ChatRecord, previous: Ch
   const message: ChatRecord = { role: 'assistant', content: '', reasoning: '', timestamp: now / 1000,
     ...previous, ...supplied, id }
   if (type === 'message.delta') message.content = String(message.content ?? '') + text(payload)
-  if (type === 'message.interim') message.status = 'complete'
-  if (type === 'reasoning.delta') message.reasoning = String(message.reasoning ?? '') + text(payload)
+  if (type === 'message.interim') {
+    if (typeof payload.text === 'string') message.content = payload.text
+    else if (typeof payload.content === 'string') message.content = payload.content
+    message.status = 'complete'
+  }
+  if (type === 'reasoning.delta' || type === 'thinking.delta') message.reasoning = String(message.reasoning ?? '') + text(payload)
+  if (type === 'reasoning.complete' || type === 'thinking.complete') {
+    const reasoning = payload.reasoning ?? payload.text ?? payload.content
+    if (typeof reasoning === 'string') message.reasoning = reasoning
+  }
   if (type.startsWith('tool.')) {
     const tools: ChatRecord[] = Array.isArray(message.tool_calls) ? [...message.tool_calls] : []
     const toolID = String(payload.tool_id ?? payload.tool_call_id ?? payload.id ?? '')
@@ -29,7 +37,8 @@ export function projectChatEvent(type: string, payload: ChatRecord, previous: Ch
     if (typeof payload.output === 'string') message.content = payload.output
     else if (typeof payload.content === 'string') message.content = payload.content
     else if (typeof payload.text === 'string') message.content = payload.text
-    message.status = payload.error || ['error','failed'].includes(payload.status) ? 'failed' : 'complete'
+    message.status = ['interrupted','cancelled','canceled'].includes(payload.status) ? 'interrupted'
+      : payload.error || ['error','failed'].includes(payload.status) ? 'failed' : 'complete'
     if(payload.error)message.error=payload.error
   } else if (type === 'error' || type === 'run.failed') {
     message.status = 'failed'; message.error = String(payload.error ?? payload.message ?? '运行失败')

+ 73 - 0
src/server/chatTranscriptApi.ts

@@ -0,0 +1,73 @@
+import Router from '@koa/router'
+import type Koa from 'koa'
+import type { ChatCacheCoordinator } from './chatCache.js'
+import type { LocalAuthStore } from './localAuth.js'
+import { HttpError } from './errors.js'
+import { CHAT_TRANSCRIPT_FEATURE } from '../shared/chatTranscript.js'
+
+export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthStore):Router {
+  const router=new Router(),store=cache.store,transcripts=store.transcripts
+  const scope=(ctx:Koa.Context)=>{
+    const owner=auth.require(ctx).id,profile=String(ctx.query.profile??'default'),requested=ctx.params.id
+    if(!profile||profile.length>256||!requested||requested.length>256)throw new HttpError(400,'会话标识无效','invalid_session')
+    const id=store.canonicalSessionID(owner,profile,requested)
+    store.requireOwned(owner,profile,id)
+    if(!auth.canUseSource(owner,'local',profile))throw new HttpError(403,'没有此 Profile 的权限','profile_forbidden')
+    ctx.set('Cache-Control','no-store')
+    return {owner,profile,id}
+  }
+  router.get('/api/app/chat/capabilities',ctx=>{auth.require(ctx);ctx.set('Cache-Control','no-store');ctx.body={features:transcripts.enabled?[CHAT_TRANSCRIPT_FEATURE]:[],epoch:transcripts.epoch}})
+  router.get('/api/app/chat/sessions/:id/snapshot',ctx=>{
+    const {owner,profile,id}=scope(ctx)
+    const before=ctx.query.before===undefined?Number.MAX_SAFE_INTEGER:Number(ctx.query.before),limit=ctx.query.limit===undefined?150:Number(ctx.query.limit)
+    if(!Number.isSafeInteger(before)||before<1||!Number.isSafeInteger(limit)||limit<1||limit>500)throw new HttpError(400,'历史分页参数无效','invalid_page')
+    const total=Number(JSON.parse(store.localDetail(owner,profile,id)!.response.body.toString()).message_count??0)
+    const offset=before===Number.MAX_SAFE_INTEGER?0:Math.max(0,total-(before-1))
+    if(!store.messagePage(owner,profile,id,offset,before===Number.MAX_SAFE_INTEGER?limit:Math.min(limit,before-1))){cache.schedule(owner,profile,id,true);throw new HttpError(409,'历史正在后台补齐','CHAT_HISTORY_SYNC_PENDING')}
+    cache.readLocal(owner,'messages',profile,id,{limit:1})
+    store.db.exec('SAVEPOINT transcript_snapshot')
+    try{
+      transcripts.seed(owner,profile,id)
+      const detail=store.localDetail(owner,profile,id)!
+      ctx.body=transcripts.snapshot(owner,profile,id,JSON.parse(detail.response.body.toString()),detail.state,before,limit)
+      store.db.exec('RELEASE transcript_snapshot')
+    }catch(error){store.db.exec('ROLLBACK TO transcript_snapshot; RELEASE transcript_snapshot');throw error}
+  })
+  router.get('/api/app/chat/sessions/:id/events',ctx=>{
+    const {owner,profile,id}=scope(ctx),version=auth.pushAuthorizationVersion(owner)
+    const raw=ctx.get('last-event-id')||String(ctx.query.after??''),parts=raw.split(':')
+    let cursor=Number(parts.at(-1)),closed=false,lastFlush=0
+    if(parts.length!==2||parts[0]!==transcripts.epoch||!Number.isSafeInteger(cursor)||cursor<0||cursor>transcripts.cursor(owner,profile,id))throw new HttpError(409,'聊天事件需要重新同步','transcript_reset')
+    const res=ctx.res
+    let timer:ReturnType<typeof setTimeout>|undefined,heartbeat:ReturnType<typeof setInterval>|undefined,off=()=>{}
+    const valid=()=>!store.isClosed&&auth.isUserActive(owner)&&auth.pushAuthorizationVersion(owner)===version&&auth.current(ctx)?.id===owner&&store.ownsSession(owner,profile,id)&&auth.canUseSource(owner,'local',profile)
+    const close=()=>{if(closed)return;closed=true;clearTimeout(timer);clearInterval(heartbeat);off();if(!res.writableEnded)res.end()}
+    const write=(value:string)=>{
+      if(closed||res.destroyed||!valid()){close();return false}
+      if(res.writableLength+Buffer.byteLength(value)>4*1024*1024){res.write('event: reset\ndata: {}\n\n');close();return false}
+      res.write(value);return true
+    }
+    const flush=()=>{
+      clearTimeout(timer);timer=undefined
+      if(!valid()){close();return}
+      const events=transcripts.events(owner,profile,id,cursor)
+      for(const event of events){if(!write(`id: ${transcripts.epoch}:${event.cursor}\nevent: transcript\ndata: ${JSON.stringify(event)}\n\n`))return;cursor=event.cursor}
+      if(events.length)lastFlush=Date.now()
+      if(events.length===250)timer=setTimeout(flush,0)
+    }
+    const changed=()=>{
+      if(closed)return
+      const events=transcripts.events(owner,profile,id,cursor)
+      if(!events.length)return
+      if(events.some(event=>event.type!=='message.patch')||Date.now()-lastFlush>=33)flush()
+      else if(timer===undefined)timer=setTimeout(flush,Math.max(0,33-(Date.now()-lastFlush)))
+    }
+    ctx.respond=false
+    res.writeHead(200,{'Content-Type':'text/event-stream; charset=utf-8','Cache-Control':'no-store','X-Accel-Buffering':'no'})
+    res.flushHeaders();res.once('close',close);res.once('error',close)
+    off=transcripts.subscribe(changed)
+    flush();write(`event: ready\ndata: ${JSON.stringify({epoch:transcripts.epoch,cursor})}\n\n`)
+    heartbeat=setInterval(()=>{if(valid())write(': heartbeat\n\n');else close()},15_000);heartbeat.unref()
+  })
+  return router
+}

+ 146 - 0
src/server/chatTranscriptStore.ts

@@ -0,0 +1,146 @@
+import { randomUUID } from 'node:crypto'
+import type { DatabaseSync } from 'node:sqlite'
+import { CHAT_TRANSCRIPT_FEATURE, type TranscriptEvent, type TranscriptMessage, type TranscriptSnapshot } from '../shared/chatTranscript.js'
+
+/** A versioned display projection and replay log in the chat cache transaction. */
+export class ChatTranscriptStore {
+  // Opt in until the three clients finish end-to-end transcript validation.
+  readonly enabled = process.env.HERMES_YAOYAO_ORDINARY_TRANSCRIPTS === '1'
+  readonly epoch: string
+  private listeners = new Set<() => void>()
+  private scheduled = false
+  private closed = false
+  constructor(readonly db: DatabaseSync,readonly enrich:(owner:string,profile:string,sessionId:string,rows:Record<string,any>[])=>Record<string,any>[] = (_o,_p,_s,rows)=>rows) {
+    db.exec(`CREATE TABLE IF NOT EXISTS chat_transcript_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
+      CREATE TABLE IF NOT EXISTS chat_transcript_scopes(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,PRIMARY KEY(owner,profile,session_id));
+      CREATE TABLE IF NOT EXISTS chat_transcript_execution(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,data TEXT NOT NULL,PRIMARY KEY(owner,profile,session_id));
+      CREATE TABLE IF NOT EXISTS chat_transcript_messages(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,
+        id TEXT NOT NULL,seq INTEGER NOT NULL,revision INTEGER NOT NULL,data TEXT NOT NULL,deleted INTEGER NOT NULL DEFAULT 0,
+        PRIMARY KEY(owner,profile,session_id,id));
+      CREATE INDEX IF NOT EXISTS chat_transcript_order ON chat_transcript_messages(owner,profile,session_id,seq);
+      CREATE TABLE IF NOT EXISTS chat_transcript_aliases(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,
+        source TEXT NOT NULL,id TEXT NOT NULL,PRIMARY KEY(owner,profile,session_id,source));
+      CREATE TABLE IF NOT EXISTS chat_transcript_events(cursor INTEGER PRIMARY KEY AUTOINCREMENT,owner TEXT NOT NULL,
+        profile TEXT NOT NULL,session_id TEXT NOT NULL,type TEXT NOT NULL,data TEXT NOT NULL,created_at INTEGER NOT NULL);
+      CREATE INDEX IF NOT EXISTS chat_transcript_replay ON chat_transcript_events(owner,profile,session_id,cursor);`)
+    db.prepare("INSERT OR IGNORE INTO chat_transcript_meta VALUES('epoch',?)").run(randomUUID())
+    this.epoch = String(db.prepare("SELECT value FROM chat_transcript_meta WHERE key='epoch'").get()!.value)
+  }
+  close() { this.closed=true; this.listeners.clear() }
+  subscribe(listener: () => void) { this.listeners.add(listener); return () => { this.listeners.delete(listener) } }
+  private notify() {
+    if (this.scheduled || this.closed) return
+    this.scheduled=true
+    // All enclosing cache savepoints finish synchronously. Read committed log
+    // rows in subscribers; a rolled-back notification therefore emits nothing.
+    queueMicrotask(() => { this.scheduled=false; if(!this.closed) for(const listener of this.listeners) { try{listener()}catch{} } })
+  }
+  private event(owner:string,profile:string,sessionId:string,type:TranscriptEvent['type'],data:unknown) {
+    this.db.prepare('INSERT INTO chat_transcript_events(owner,profile,session_id,type,data,created_at) VALUES(?,?,?,?,?,?)')
+      .run(owner,profile,sessionId,type,JSON.stringify(data),Date.now())
+    this.notify()
+  }
+  private alias(owner:string,profile:string,sessionId:string,key:string):string|undefined {
+    return (this.db.prepare('SELECT id FROM chat_transcript_aliases WHERE owner=? AND profile=? AND session_id=? AND source=?')
+      .get(owner,profile,sessionId,key) as {id:string}|undefined)?.id
+  }
+  promote(owner:string,profile:string,sessionId:string,oldSource:string,newSource:string) {
+    const id=this.alias(owner,profile,sessionId,`source:${oldSource}`)
+    if(id&&!this.alias(owner,profile,sessionId,`source:${newSource}`)) this.db.prepare('INSERT INTO chat_transcript_aliases VALUES(?,?,?,?,?)').run(owner,profile,sessionId,`source:${newSource}`,id)
+  }
+  upsert(owner:string,profile:string,sessionId:string,source:Record<string,any>,position:number) {
+    const sourceID=String(source.id??source.message_id??'')
+    if(!sourceID)return
+    const keys=[`source:${sourceID}`]
+    if(source.role==='user'&&source.client_message_id)keys.unshift(`client:${source.client_message_id}`)
+    if(source.role==='tool'&&source.tool_call_id)keys.unshift(`tool:${source.tool_call_id}`)
+    const id=keys.map(key=>this.alias(owner,profile,sessionId,key)).find(Boolean)??randomUUID()
+    for(const duplicate of new Set(keys.map(key=>this.alias(owner,profile,sessionId,key)).filter((value):value is string=>!!value&&value!==id))){
+      const old=this.db.prepare('SELECT revision,deleted FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND id=?').get(owner,profile,sessionId,duplicate) as {revision:number;deleted:number}|undefined
+      this.db.prepare('UPDATE chat_transcript_aliases SET id=? WHERE owner=? AND profile=? AND session_id=? AND id=?').run(id,owner,profile,sessionId,duplicate)
+      if(old&&!old.deleted){this.db.prepare('UPDATE chat_transcript_messages SET deleted=1,revision=revision+1 WHERE owner=? AND profile=? AND session_id=? AND id=?').run(owner,profile,sessionId,duplicate);this.event(owner,profile,sessionId,'message.deleted',{id:duplicate,revision:old.revision+1})}
+    }
+    const row=this.db.prepare('SELECT data,revision,deleted FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND id=?')
+      .get(owner,profile,sessionId,id) as {data:string;revision:number;deleted:number}|undefined
+    const old:TranscriptMessage|undefined=row?JSON.parse(row.data):undefined
+    const next:TranscriptMessage={...source,id,source_message_id:sourceID,seq:position+1,revision:row?.revision??0,
+      role:String(source.role??'assistant'),content:source.content??'',reasoning:String(source.reasoning??source.reasoning_content??'')}
+    if(!next.client_message_id&&old?.client_message_id)next.client_message_id=old.client_message_id
+    for(const key of keys)this.db.prepare('INSERT INTO chat_transcript_aliases VALUES(?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source) DO UPDATE SET id=excluded.id').run(owner,profile,sessionId,key,id)
+    if(old&&!row?.deleted&&JSON.stringify(old)===JSON.stringify(next))return
+    next.revision++
+    this.db.prepare('INSERT INTO chat_transcript_messages VALUES(?,?,?,?,?,?,?,0) ON CONFLICT(owner,profile,session_id,id) DO UPDATE SET seq=excluded.seq,revision=excluded.revision,data=excluded.data,deleted=0')
+      .run(owner,profile,sessionId,id,next.seq,next.revision,JSON.stringify(next))
+    let patch:Record<string,unknown>|undefined
+    if(old&&!row?.deleted&&old.status==='streaming'&&next.status==='streaming'&&typeof old.content==='string'&&typeof next.content==='string'
+      &&next.content.startsWith(old.content)&&next.reasoning!.startsWith(String(old.reasoning??''))){
+      const {content:a,reasoning:b,revision:c,...oldMeta}=old
+      const {content:d,reasoning:e,revision:f,...newMeta}=next
+      if(JSON.stringify(oldMeta)===JSON.stringify(newMeta))patch={id,seq:next.seq,baseRevision:old.revision,revision:next.revision,
+        contentAppend:next.content.slice(old.content.length),reasoningAppend:next.reasoning!.slice(String(old.reasoning??'').length)}
+    }
+    this.event(owner,profile,sessionId,patch?'message.patch':'message.upsert',patch??next)
+  }
+  reconcile(owner:string,profile:string,sessionId:string) {
+    const rows=this.db.prepare('SELECT data,position FROM chat_messages WHERE owner=? AND profile=? AND session_id=? ORDER BY position').all(owner,profile,sessionId) as {data:string;position:number}[]
+    const live=new Set<string>()
+    const enriched=this.enrich(owner,profile,sessionId,rows.map(row=>JSON.parse(row.data)))
+    for(const [index,row] of rows.entries()){const source=enriched[index]!;this.upsert(owner,profile,sessionId,source,row.position);const id=this.alias(owner,profile,sessionId,`source:${source.id??source.message_id}`);if(id)live.add(id)}
+    for(const row of this.db.prepare('SELECT id,revision FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0').all(owner,profile,sessionId) as {id:string;revision:number}[]){
+      if(live.has(row.id))continue
+      this.db.prepare('UPDATE chat_transcript_messages SET deleted=1,revision=revision+1 WHERE owner=? AND profile=? AND session_id=? AND id=?').run(owner,profile,sessionId,row.id)
+      this.event(owner,profile,sessionId,'message.deleted',{id:row.id,revision:row.revision+1})
+    }
+  }
+  seed(owner:string,profile:string,sessionId:string) {
+    if(this.db.prepare('SELECT 1 FROM chat_transcript_scopes WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionId))return
+    this.reconcile(owner,profile,sessionId)
+    this.db.prepare('INSERT INTO chat_transcript_scopes VALUES(?,?,?)').run(owner,profile,sessionId)
+  }
+  sessionChanged(owner:string,profile:string,sessionId:string,data:Record<string,unknown>) {
+    const row=this.db.prepare('SELECT data FROM chat_transcript_execution WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionId) as {data:string}|undefined
+    const state=row?JSON.parse(row.data):{running:false,queued:false,pending:{}}
+    const p=(data.payload??{}) as Record<string,any>,type=String(data.eventType)
+    state.pending??={}
+    if(type==='command.submitted'){state.pending[p.delivery_id]=state.running;state.queued=state.running;state.running=true}
+    if(type==='command.confirmed'||type==='command.rejected'){
+      if(type==='command.rejected')state.running=state.pending[p.delivery_id]??state.running
+      else{state.running=true;state.queued=p.status==='queued'}
+      delete state.pending[p.delivery_id]
+    }
+    if(['message.start','run.started','approval.request','clarify.request'].includes(type))state.running=true
+    if(type==='route.resumed'&&typeof p.running==='boolean')state.running=p.running
+    if(['message.complete','run.completed','run.failed','error'].includes(type)){
+      state.queued=Number(p.queue_remaining??p.queueRemaining??0)>0
+      state.running=state.queued||Number(p.background_pending??p.backgroundPending??0)>0
+    }
+    this.db.prepare('INSERT INTO chat_transcript_execution VALUES(?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data').run(owner,profile,sessionId,JSON.stringify(state))
+    this.event(owner,profile,sessionId,'session.changed',{...data,running:state.running,queued:state.queued})
+  }
+  migrate(owner:string,profile:string,oldID:string,newID:string) {
+    for(const table of ['chat_transcript_messages','chat_transcript_aliases','chat_transcript_scopes','chat_transcript_execution'])this.db.prepare(`UPDATE ${table} SET session_id=? WHERE owner=? AND profile=? AND session_id=?`).run(newID,owner,profile,oldID)
+    this.event(owner,profile,oldID,'session.migrated',{sessionId:newID})
+  }
+  remove(owner:string,profile:string,sessionId:string) {
+    this.event(owner,profile,sessionId,'session.deleted',{sessionId})
+    for(const table of ['chat_transcript_messages','chat_transcript_aliases','chat_transcript_scopes','chat_transcript_execution'])this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(owner,profile,sessionId)
+  }
+  cursor(owner:string,profile:string,sessionId:string):number {
+    return Number(this.db.prepare('SELECT COALESCE(MAX(cursor),0) cursor FROM chat_transcript_events WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionId)!.cursor)
+  }
+  events(owner:string,profile:string,sessionId:string,after:number,limit=250):TranscriptEvent[] {
+    return (this.db.prepare('SELECT cursor,type,data FROM chat_transcript_events WHERE owner=? AND profile=? AND session_id=? AND cursor>? ORDER BY cursor LIMIT ?').all(owner,profile,sessionId,after,limit) as {cursor:number;type:TranscriptEvent['type'];data:string}[])
+      .map(row=>({...row,profile,sessionId,data:JSON.parse(row.data)}))
+  }
+  snapshot(owner:string,profile:string,sessionId:string,session:Record<string,unknown>,state:string,before=Number.MAX_SAFE_INTEGER,limit=150):TranscriptSnapshot {
+    const execution=this.db.prepare('SELECT data FROM chat_transcript_execution WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionId) as {data:string}|undefined
+    const run=execution?JSON.parse(execution.data):{}
+    const rows=this.db.prepare('SELECT data FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0 AND seq<? ORDER BY seq DESC,id DESC LIMIT ?')
+      .all(owner,profile,sessionId,before,limit+1) as {data:string}[]
+    return {protocol:CHAT_TRANSCRIPT_FEATURE,epoch:this.epoch,cursor:this.cursor(owner,profile,sessionId),profile,sessionId,running:run.running,queued:run.queued,
+      messages:rows.slice(0,limit).reverse().map(row=>JSON.parse(row.data)),session,state,
+      deletedIds:(this.db.prepare('SELECT id FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=1').all(owner,profile,sessionId) as {id:string}[]).map(row=>row.id),
+      hasOlder:rows.length>limit||(rows.length>0&&Number(JSON.parse(rows[Math.min(limit,rows.length)-1]!.data).seq)>1),
+      total:Number(this.db.prepare('SELECT COUNT(*) n FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0').get(owner,profile,sessionId)!.n)}
+  }
+}

+ 2 - 0
src/server/realtimeApi.ts

@@ -16,6 +16,7 @@ import { CHAT_MAX_PAYLOAD } from './realtimeProtocol.js'
 import { ChatPushRelayObserver, type ChatNotificationResolver, type PushEventCoordinator, type ChatPushTransportFactory } from './pushEvents.js'
 
 export class RealtimeAPI {
+  transcriptsAvailable = false
   readonly broker: RealtimeBroker
   private observers = new Map<string, ChatPushRelayObserver>()
   constructor(readonly config: ServerConfig, readonly auth: LocalAuthStore, readonly csrf: CsrfProtection,
@@ -169,6 +170,7 @@ export class RealtimeAPI {
       const path = match![2]!
       if (ctx.method === 'GET' && path === '/capabilities') {
         ctx.body = { protocolVersion: 1, channels: ['chat'], brokerEpoch: this.broker.epoch,
+          features: this.transcriptsAvailable && !device && !ctx.state.hermesBotNative && !ctx.state.workspaceAgentExport ? ['ordinary-chat-transcript-v1'] : [],
           ...(device ? {} : { csrfToken: this.csrf.issue(ctx) }) }
         return
       }

+ 3 - 3
src/server/realtimeBroker.ts

@@ -50,7 +50,7 @@ export class RealtimeBroker {
   onNativeEvent: (owner: string, profile: string, storedId: string, frame: Frame) => void = () => {}
   onNativeGlobalEvent: (owner: string, type: string, upstreamInvalidation?: boolean) => void = () => {}
   onNativeCommand: (owner: string, profile: string, storedId: string, method: string, params: Frame) => void = () => {}
-  onNativeRoute: (owner: string, profile: string, storedId: string, runtimeId: string) => void = () => {}
+  onNativeRoute: (owner: string, profile: string, storedId: string, runtimeId: string, running?: boolean) => void = () => {}
   readonly epoch = randomUUID()
   readonly channels = new Map<string, RealtimeChannel>()
   private upstreams = new Map<string, Upstream>()
@@ -283,7 +283,7 @@ export class RealtimeBroker {
         r.cwd = typeof info.cwd === 'string' ? info.cwd : undefined
         u.routes.set(key, r); u.byRuntime.set(runtime, r); c.routes.add(key)
         r.observers.set(c.principal.key, c.principal)
-        if (nativeOwner) this.onNativeRoute(nativeOwner, profile, stored, runtime)
+        if (nativeOwner) this.onNativeRoute(nativeOwner, profile, stored, runtime, r.active)
         if (nativeOwner && (method === 'session.create' || method === 'session.branch')) {
           this.onNativeCommand(nativeOwner, profile, stored, method, {...p,_delivery_id:deliveryID})
         }
@@ -419,7 +419,7 @@ export class RealtimeBroker {
             for(const channel of this.channels.values())if(channel.principal.upstreamKey===u.principal.upstreamKey&&channel.routes.delete(oldKey))channel.routes.add(newKey)
             for(const principal of r.observers.values()){
               const owner=this.nativeOwner(principal)
-              if(owner&&principal.valid())this.onNativeRoute(owner,r.profile,r.stored,r.runtime)
+              if(owner&&principal.valid())this.onNativeRoute(owner,r.profile,r.stored,r.runtime,r.active)
             }
           }
         }

+ 60 - 0
src/shared/chatTranscript.ts

@@ -0,0 +1,60 @@
+/** Ordinary-chat display protocol. Upstream identities remain server metadata. */
+export const CHAT_TRANSCRIPT_FEATURE = 'ordinary-chat-transcript-v1'
+export interface TranscriptMessage {
+  id: string
+  seq: number
+  revision: number
+  source_message_id: string
+  client_message_id?: string
+  role: string
+  content: unknown
+  reasoning?: string
+  status?: string
+  [key: string]: unknown
+}
+export interface TranscriptPatch {
+  id: string; seq: number; baseRevision: number; revision: number
+  contentAppend: string; reasoningAppend: string
+}
+export interface TranscriptEvent {
+  cursor: number; profile: string; sessionId: string
+  type: 'message.upsert' | 'message.patch' | 'message.deleted' | 'session.changed' | 'session.deleted' | 'session.migrated'
+  data: unknown
+}
+export interface TranscriptSnapshot {
+  protocol: typeof CHAT_TRANSCRIPT_FEATURE; epoch: string; cursor: number
+  profile: string; sessionId: string; messages: TranscriptMessage[]
+  session: Record<string, unknown>; hasOlder: boolean; total: number
+  state: string
+  deletedIds?: string[]
+  running?: boolean
+  queued?: boolean
+}
+export class TranscriptGap extends Error {
+  constructor() { super('普通聊天消息版本不连续,需要重新同步') }
+}
+export function applyTranscriptEvent(messages: TranscriptMessage[], event: TranscriptEvent): TranscriptMessage[] {
+  const data = event.data as TranscriptMessage & TranscriptPatch
+  const index = messages.findIndex(message => message.id === data.id)
+  const old = messages[index]
+  if (event.type === 'message.deleted') {
+    if(!Number.isSafeInteger(data.revision)||data.revision<1)throw new TranscriptGap()
+    return old&&old.revision>=data.revision?messages:messages.filter(message => message.id !== data.id)
+  }
+  if (event.type === 'message.upsert') {
+    if (!data.id || !Number.isSafeInteger(data.seq) || !Number.isSafeInteger(data.revision) || data.revision < 1) throw new TranscriptGap()
+    if (old && old.revision >= data.revision) return messages
+    return [...messages.filter(message => message.id !== data.id), data].sort((a,b) => a.seq-b.seq || a.id.localeCompare(b.id))
+  }
+  if (event.type === 'message.patch') {
+    if (old && old.revision >= data.revision) return messages
+    if (!old || old.revision !== data.baseRevision || data.revision !== data.baseRevision+1
+      || old.seq !== data.seq || typeof old.content !== 'string'
+      || typeof data.contentAppend !== 'string' || typeof data.reasoningAppend !== 'string') throw new TranscriptGap()
+    const next = [...messages]
+    next[index] = {...old, revision:data.revision, content:old.content+data.contentAppend,
+      reasoning:String(old.reasoning??'')+data.reasoningAppend}
+    return next
+  }
+  return messages
+}

+ 104 - 0
tests/server/chatTranscript.test.ts

@@ -0,0 +1,104 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { ChatCacheStore, ChatCacheCoordinator } from '../../src/server/chatCache'
+import { applyTranscriptEvent, type TranscriptSnapshot } from '../../src/shared/chatTranscript'
+import type { UpstreamServiceSession } from '../../src/server/localAuth'
+
+const fixtures:Array<{home:string;store:ChatCacheStore;cache:ChatCacheCoordinator}>=[]
+function fixture(){const home=mkdtempSync(join(tmpdir(),'ordinary-transcript-')),store=new ChatCacheStore(home),cache=new ChatCacheCoordinator(store,{request:vi.fn()} as unknown as UpstreamServiceSession);fixtures.push({home,store,cache});return {home,store,cache}}
+afterEach(()=>{for(const f of fixtures.splice(0)){f.cache.close();if(!f.store.isClosed)f.store.close();rmSync(f.home,{recursive:true,force:true})}})
+function snapshot(store:ChatCacheStore,id='s',owner='owner'):TranscriptSnapshot {
+  store.transcripts.seed(owner,'p',id)
+  const detail=store.localDetail(owner,'p',id)!
+  return store.transcripts.snapshot(owner,'p',id,JSON.parse(detail.response.body.toString()),detail.state)
+}
+describe('ordinary server-owned transcript',()=>{
+  it('preserves interim text, reasoning, tool results and final text as separate records',()=>{
+    const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
+    store.recordCommand('owner','p','s','prompt.submit',{text:'问题',_delivery_id:'ios:prompt:user-one'})
+    let n=0
+    const send=(type:string,payload:object)=>cache.observe('owner','p','s',{type,payload,delivery_id:`event-${++n}`})
+    send('message.start',{})
+    send('reasoning.delta',{text:'先思考'})
+    send('message.interim',{text:'先检查文件'})
+    send('tool.start',{tool_id:'tool-one',name:'read_file'})
+    send('tool.complete',{tool_id:'tool-one',result:'完整工具输出'})
+    send('message.delta',{text:'最终答案'})
+    send('message.complete',{text:'最终答案'})
+    const value=snapshot(store)
+    expect(value.messages.map(m=>m.content)).toEqual(['问题','先检查文件','最终答案'])
+    expect(value.messages[1]!.reasoning).toBe('先思考')
+    expect(value.messages[2]!.tool_calls).toMatchObject([{id:'tool-one',result:'完整工具输出'}])
+    expect(new Set(value.messages.map(m=>m.id)).size).toBe(3)
+    expect(value.messages.map(m=>m.seq)).toEqual([1,2,3])
+  })
+  it('keeps a queued prompt from detaching the currently streaming assistant',()=>{
+    const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
+    store.recordCommand('owner','p','s','prompt.submit',{text:'第一问',_delivery_id:'ios:prompt:first'})
+    cache.observe('owner','p','s',{type:'message.delta',payload:{text:'前半'},delivery_id:'one'})
+    const assistant=snapshot(store).messages.find(m=>m.role==='assistant')!
+    store.recordCommand('owner','p','s','prompt.submit',{text:'排队的第二问',_delivery_id:'ios:prompt:second'})
+    cache.observe('owner','p','s',{type:'message.delta',payload:{text:'后半'},delivery_id:'two'})
+    expect(snapshot(store).messages.find(m=>m.id===assistant.id)?.content).toBe('前半后半')
+    expect(snapshot(store).messages.filter(m=>m.role==='assistant')).toHaveLength(1)
+  })
+  it('commits replayable append patches and ignores duplicate deliveries',()=>{
+    const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
+    cache.observe('owner','p','s',{type:'message.delta',payload:{text:'中文'},delivery_id:'one'})
+    const base=snapshot(store),id=base.messages[0]!.id
+    cache.observe('owner','p','s',{type:'message.delta',payload:{text:'🙂'},delivery_id:'two'})
+    cache.observe('owner','p','s',{type:'message.delta',payload:{text:'🙂'},delivery_id:'two'})
+    const events=store.transcripts.events('owner','p','s',base.cursor)
+    expect(events).toHaveLength(1);expect(events[0]!.type).toBe('message.patch')
+    const replay=events.reduce(applyTranscriptEvent,base.messages)
+    expect(replay).toEqual(snapshot(store).messages)
+    expect(replay[0]).toMatchObject({id,content:'中文🙂',revision:2})
+    expect(applyTranscriptEvent(replay,events[0]!)).toEqual(replay)
+  })
+  it('retains stable message identity when an upstream row ID becomes known',()=>{
+    const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
+    cache.observe('owner','p','s',{type:'message.delta',payload:{text:'回复'},delivery_id:'one'})
+    const before=snapshot(store).messages[0]!
+    cache.observe('owner','p','s',{type:'message.complete',payload:{message_id:'upstream-42',text:'完整回复'},delivery_id:'two'})
+    expect(snapshot(store).messages).toHaveLength(1)
+    expect(snapshot(store).messages[0]).toMatchObject({id:before.id,seq:before.seq,source_message_id:'upstream-42',content:'完整回复',status:'complete'})
+  })
+  it('rolls back content and event log together when durable event storage fails',async()=>{
+    const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
+    const listener=vi.fn();store.transcripts.subscribe(listener)
+    store.db.exec("CREATE TRIGGER fail_transcript_event BEFORE INSERT ON chat_transcript_events BEGIN SELECT RAISE(ABORT,'disk full'); END")
+    expect(()=>cache.observe('owner','p','s',{type:'message.delta',payload:{text:'不能发布'},delivery_id:'fail'})).toThrow('disk full')
+    await Promise.resolve()
+    expect(store.db.prepare('SELECT COUNT(*) n FROM chat_messages').get()!.n).toBe(0)
+    expect(store.db.prepare('SELECT COUNT(*) n FROM chat_transcript_messages').get()!.n).toBe(0)
+    expect(store.transcripts.events('owner','p','s',0)).toEqual([])
+    expect(listener).not.toHaveBeenCalled()
+  })
+  it('replays committed events after server restart without resubmitting prompts',()=>{
+    const {home,store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
+    cache.observe('owner','p','s',{type:'message.delta',payload:{text:'开始'},delivery_id:'one'})
+    const before=snapshot(store)
+    cache.observe('owner','p','s',{type:'message.complete',payload:{text:'结束'},delivery_id:'two'})
+    const after=snapshot(store);cache.close();store.close()
+    const reopened=new ChatCacheStore(home)
+    try{expect(reopened.transcripts.epoch).toBe(before.epoch);expect(reopened.transcripts.events('owner','p','s',before.cursor).reduce(applyTranscriptEvent,before.messages)).toEqual(after.messages)}finally{reopened.close()}
+  })
+  it('keeps owner/session scopes and history pagination independent',()=>{
+    const {store,cache}=fixture()
+    for(const owner of ['owner','other'])for(const id of ['s','t']){
+      store.recordCommand(owner,'p',id,'session.create',{})
+      for(let i=0;i<5;i++)store.recordCommand(owner,'p',id,'prompt.submit',{text:`${owner}:${id}:${i}`,_delivery_id:`${owner}:${id}:${i}`})
+    }
+    snapshot(store)
+    const detail=store.localDetail('owner','p','s')!
+    const tail=store.transcripts.snapshot('owner','p','s',{},detail.state,Number.MAX_SAFE_INTEGER,2)
+    const old=store.transcripts.snapshot('owner','p','s',{},detail.state,tail.messages[0]!.seq,2)
+    expect(tail.messages.map(m=>m.content)).toEqual(['owner:s:3','owner:s:4'])
+    expect(old.messages.map(m=>m.content)).toEqual(['owner:s:1','owner:s:2'])
+    expect(store.transcripts.events('unknown','p','s',0)).toEqual([])
+    cache.close()
+  })
+})