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

修复消息缓存覆盖会话时间并恢复最新对话排序

LSamien 1 месяц назад
Родитель
Сommit
d326f3b2b5
2 измененных файлов с 174 добавлено и 16 удалено
  1. 101 16
      src/server/chatCache.ts
  2. 73 0
      tests/server/chatCache.test.ts

+ 101 - 16
src/server/chatCache.ts

@@ -57,6 +57,53 @@ function sessionTitle(value: Record<string, unknown>): string {
   return String(value.title ?? '').trim()
 }
 
+// Hermes uses Unix seconds; local route registration uses milliseconds. Keep
+// the public summary in seconds so Web and iOS compare the same activity time.
+function timestampSeconds(value: unknown): number | undefined {
+  if (typeof value !== 'number' && typeof value !== 'string') return undefined
+  const numeric = Number(value)
+  const seconds = Number.isFinite(numeric)
+    ? numeric > 10_000_000_000 ? numeric / 1_000 : numeric
+    : typeof value === 'string' ? Date.parse(value) / 1_000 : NaN
+  return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined
+}
+
+function firstTimestamp(value: Record<string, unknown>, keys: string[]): number | undefined {
+  for (const key of keys) {
+    const timestamp = timestampSeconds(value[key])
+    if (timestamp !== undefined) return timestamp
+  }
+  return undefined
+}
+
+const START_TIME_KEYS = ['started_at', 'startedAt', 'session_started', 'created_at', 'createdAt']
+const ACTIVITY_TIME_KEYS = ['last_active', 'last_active_at', 'last_activity_at', 'lastActive', 'updated_at', 'updatedAt']
+
+function mergeSessionMetadata(previous: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
+  const summary = { ...previous, ...incoming }
+  // A messages response can carry title/model metadata, but its envelope is
+  // not a replacement for the session detail.
+  delete summary.messages
+  delete summary.pagination
+  const started = firstTimestamp(incoming, START_TIME_KEYS) ?? firstTimestamp(previous, START_TIME_KEYS)
+  const activity = firstTimestamp(incoming, ACTIVITY_TIME_KEYS) ?? firstTimestamp(previous, ACTIVITY_TIME_KEYS)
+  if (started !== undefined) summary.started_at = started
+  if (activity !== undefined) summary.last_active = activity
+  return summary
+}
+
+function messageTimeBounds(messages: Record<string, unknown>[]): { first?: number; last?: number } {
+  let first: number | undefined
+  let last: number | undefined
+  for (const message of messages) {
+    const time = firstTimestamp(message, ['timestamp', 'created_at', 'createdAt'])
+    if (time === undefined) continue
+    first = Math.min(first ?? time, time)
+    last = Math.max(last ?? time, time)
+  }
+  return { first, last }
+}
+
 function isPlaceholderTitle(value: string): boolean {
   return new Set([
     '', '新对话', '新会话', '未命名对话', '未命名会话', 'new conversation', 'new session', 'untitled',
@@ -203,6 +250,36 @@ export class ChatCacheStore {
         const title = sessionTitle(summary)
         if (!isPlaceholderTitle(title)) backfillTitle.run(title, row.owner, row.profile, row.session_id)
       }
+      if (!this.db.prepare("SELECT 1 FROM chat_meta WHERE key='session-activity-v1'").get()) {
+        const rows = this.db.prepare(`SELECT owner,profile,session_id,source,data,owned_at,message_total
+          FROM chat_sessions WHERE owned_at IS NOT NULL`).all() as Array<{
+            owner: string; profile: string; session_id: string; source: string; data: string
+            owned_at: number; message_total: number | null
+          }>
+        const update = this.db.prepare(`UPDATE chat_sessions SET data=? WHERE owner=? AND profile=? AND session_id=?`)
+        for (const row of rows) {
+          let stored: Record<string, unknown>
+          try { stored = object(JSON.parse(row.data)) ?? {} } catch { continue }
+          const summary = mergeSessionMetadata({}, stored)
+          if (Array.isArray(stored.messages) || !firstTimestamp(summary, START_TIME_KEYS)
+            || !firstTimestamp(summary, ACTIVITY_TIME_KEYS)) {
+            const messages = this.db.prepare(`SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=?`)
+              .all(row.owner, row.profile, row.session_id) as Array<{ data: string }>
+            const bounds = messageTimeBounds(messages.flatMap(message => {
+              try { return [object(JSON.parse(message.data)) ?? {}] } catch { return [] }
+            }))
+            summary.started_at = firstTimestamp(summary, START_TIME_KEYS) ?? bounds.first ?? timestampSeconds(row.owned_at)
+            summary.last_active = Math.max(firstTimestamp(summary, ACTIVITY_TIME_KEYS) ?? 0,
+              bounds.last ?? 0) || summary.started_at
+            if (row.message_total !== null) summary.message_count = row.message_total
+          }
+          summary.id = row.session_id
+          summary.profile = row.profile
+          summary.source = sessionSource(summary) || row.source
+          update.run(JSON.stringify(summary), row.owner, row.profile, row.session_id)
+        }
+        this.db.prepare("INSERT INTO chat_meta(key,value) VALUES('session-activity-v1','complete')").run()
+      }
       // List bodies written before ownership became an explicit projection do
       // not carry `owned` and may have been filtered by `source`. Rebuild them on
       // first use after every restart instead of serving a semantically old page.
@@ -546,14 +623,8 @@ export class ChatCacheStore {
         })
       }
     }
-    const activity = (session: Record<string, unknown>): number => Number(
-      session.last_active_at
-        ?? session.last_active
-        ?? session.updated_at
-        ?? session.started_at
-        ?? session._yaoyao_registry_activity
-        ?? 0,
-    ) || 0
+    const activity = (session: Record<string, unknown>): number =>
+      firstTimestamp(session, ACTIVITY_TIME_KEYS) ?? firstTimestamp(session, START_TIME_KEYS) ?? 0
     const archived = (session: Record<string, unknown>): boolean => bool(
       session.is_archived ?? session.isArchived ?? session.archived,
     )
@@ -569,7 +640,7 @@ export class ChatCacheStore {
     const offset = Math.max(0, Math.trunc(page.offset ?? 0))
     const limit = Math.max(1, Math.min(500, Math.trunc(page.limit ?? 100)))
     const sessions = allSessions.slice(offset, offset + limit).map((session) => {
-      const publicSession = { ...session }
+      const publicSession = mergeSessionMetadata({}, session)
       delete publicSession._yaoyao_registry_activity
       return publicSession
     })
@@ -746,9 +817,9 @@ export class ChatCacheStore {
       const registered = this.ownsSession(owner, profile, id)
       const source = sessionSource(session)
       if (!registered) continue
-      const titleRow = this.db.prepare(`SELECT authoritative_title,authoritative_title_at FROM chat_sessions
+      const titleRow = this.db.prepare(`SELECT data,authoritative_title,authoritative_title_at FROM chat_sessions
         WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, id) as {
-          authoritative_title?: string | null; authoritative_title_at?: number
+          data: string; authoritative_title?: string | null; authoritative_title_at?: number
         } | undefined
       const authoritativeTitle = String(titleRow?.authoritative_title ?? '').trim()
       const authoritativeTitleAt = Number(titleRow?.authoritative_title_at ?? 0)
@@ -756,14 +827,17 @@ export class ChatCacheStore {
       const preservesAuthoritativeTitle = Boolean(authoritativeTitle)
         && (kind !== 'detail' || isPlaceholderTitle(incomingTitle)
           || requestStartedAt <= authoritativeTitleAt)
-      const storedSession = preservesAuthoritativeTitle
-        ? { ...session, title: authoritativeTitle }
-        : session
+      let previous: Record<string, unknown> = {}
+      try { previous = object(JSON.parse(titleRow?.data ?? '{}')) ?? {} } catch { /* Recover from incoming metadata. */ }
+      const storedSession = mergeSessionMetadata(previous, session)
+      if (preservesAuthoritativeTitle) storedSession.title = authoritativeTitle
+      const metadataComplete = kind !== 'messages' || direct ? 1 : 0
       this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at,metadata_complete)
-        VALUES(?,?,?,?,?,?,?,?,?,1) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data,metadata_complete=1,
+        VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data,
+        metadata_complete=MAX(chat_sessions.metadata_complete,excluded.metadata_complete),
         source=CASE WHEN excluded.source='unknown' THEN chat_sessions.source ELSE excluded.source END,
         sync_state='current',last_synced_at=excluded.last_synced_at`)
-        .run(owner, profile, id, null, source || 'unknown', JSON.stringify(storedSession), 'current', 0, now)
+        .run(owner, profile, id, null, source || 'unknown', JSON.stringify(storedSession), 'current', 0, now, metadataComplete)
       const acceptedTitle = sessionTitle(storedSession)
       if (acceptedTitle && !isPlaceholderTitle(acceptedTitle)
         && (!authoritativeTitle || kind === 'detail')) {
@@ -802,6 +876,17 @@ export class ChatCacheStore {
       FROM chat_messages WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as Record<string, number>
     const complete = total === 0 ? coverage.count === 0
       : coverage.count === total && coverage.positions === total && coverage.first === 0 && coverage.last === total - 1
+    const row = this.db.prepare('SELECT data FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
+      .get(owner, profile, sessionID) as { data: string }
+    const summary = mergeSessionMetadata({}, object(JSON.parse(row.data)) ?? {})
+    const bounds = messageTimeBounds(messages)
+    if (!firstTimestamp(summary, START_TIME_KEYS) && bounds.first !== undefined) summary.started_at = bounds.first
+    if (bounds.last !== undefined) {
+      summary.last_active = Math.max(firstTimestamp(summary, ACTIVITY_TIME_KEYS) ?? 0, bounds.last)
+    }
+    summary.message_count = total
+    this.db.prepare('UPDATE chat_sessions SET data=? WHERE owner=? AND profile=? AND session_id=?')
+      .run(JSON.stringify(summary), owner, profile, sessionID)
     this.db.prepare(`UPDATE chat_sessions SET complete=?,message_total=?,sync_state='current',last_synced_at=?
       WHERE owner=? AND profile=? AND session_id=?`).run(complete ? 1 : 0, total, now, owner, profile, sessionID)
   }

+ 73 - 0
tests/server/chatCache.test.ts

@@ -30,6 +30,79 @@ afterEach(() => {
 })
 
 describe('durable source=web chat cache', () => {
+  it('keeps session metadata when message envelopes only contain session_id and advances activity from messages', () => {
+    const f = fixture()
+    f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
+    f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({
+      id: sessionID, profile, source: 'ios', title: '准备 GPT-6 详细 Markdown 文档',
+      started_at: 1_788_661_637, last_active: 1_788_661_640, pinned: true, message_count: 1,
+    }))
+    f.store.putSnapshot(owner, 'tail', 'messages', profile, sessionID, response({
+      session_id: sessionID, model: 'test-model',
+      messages: [{ id: 'latest', timestamp: 1_788_663_697.824613, role: 'assistant', content: '完成' }],
+      pagination: { total: 2, offset: 0 },
+    }))
+    f.store.putSnapshot(owner, 'older', 'messages', profile, sessionID, response({
+      session_id: sessionID,
+      messages: [{ id: 'first', timestamp: 1_788_661_640_000, role: 'user', content: '开始' }],
+      pagination: { total: 2, offset: 1 },
+    }))
+    const summary = JSON.parse(f.store.localDetail(owner, profile, sessionID)!.response.body.toString())
+    expect(summary).toMatchObject({
+      id: sessionID, profile, source: 'ios', title: '准备 GPT-6 详细 Markdown 文档',
+      started_at: 1_788_661_637, last_active: 1_788_663_697.824613,
+      pinned: true, model: 'test-model', message_count: 2,
+    })
+    expect(summary).not.toHaveProperty('messages')
+    expect(summary).not.toHaveProperty('pagination')
+    f.store.close()
+  })
+
+  it('repairs overwritten summaries on restart using message time instead of cache refresh time', () => {
+    const f = fixture()
+    f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
+    f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'ios' })
+    f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
+      session_id: sessionID,
+      messages: [
+        { id: 'first', timestamp: '2026-09-06T03:00:00Z', role: 'user', content: '开始' },
+        { id: 'last', timestamp: 1_788_663_697.824613, role: 'assistant', content: '完成' },
+      ],
+      pagination: { total: 2, offset: 0 },
+    }))
+    f.store.db.prepare(`UPDATE chat_sessions SET data=?,owned_at=?,last_synced_at=?
+      WHERE owner=? AND profile=? AND session_id=?`).run(JSON.stringify({
+      session_id: sessionID, title: '准备 GPT-6 详细 Markdown 文档', model: 'test-model',
+      messages: [], pagination: { total: 2 },
+    }), 1_788_661_637_003, 1_900_000_000_000, owner, profile, sessionID)
+    f.store.db.prepare("DELETE FROM chat_meta WHERE key='session-activity-v1'").run()
+    f.store.close()
+
+    const restored = new ChatCacheStore(f.home)
+    const summary = JSON.parse(restored.localList(owner, profile, {}).body.toString()).sessions[0]
+    expect(summary).toMatchObject({
+      id: sessionID, source: 'ios', title: '准备 GPT-6 详细 Markdown 文档',
+      started_at: 1_788_663_600, last_active: 1_788_663_697.824613, message_count: 2,
+    })
+    expect(summary).not.toHaveProperty('messages')
+    expect(summary).not.toHaveProperty('pagination')
+    restored.close()
+    const again = new ChatCacheStore(f.home)
+    expect(JSON.parse(again.localList(owner, profile, {}).body.toString()).sessions[0]).toEqual(summary)
+    again.close()
+  })
+
+  it('orders owned conversations by real time across seconds and milliseconds', () => {
+    const f = fixture()
+    for (const [id, activity] of [['older', 1_788_561_050_759], ['newer', 1_788_663_697.824613]] as const) {
+      f.store.recordRoute(owner, profile, id, `runtime-${id}`)
+      f.store.putSnapshot(owner, id, 'detail', profile, id, response({ id, last_active: activity }))
+    }
+    expect(JSON.parse(f.store.localList(owner, profile, {}).body.toString()).sessions.map((s: { id: string }) => s.id))
+      .toEqual(['newer', 'older'])
+    f.store.close()
+  })
+
   it('serves a warm list without a second 9119 request and survives restart', async () => {
     const f = fixture()
     f.store.recordRoute(owner, profile, sessionID, 'runtime-web')