Browse Source

修复普通聊天列表失效后的后台标题同步

Samien 1 tháng trước cách đây
mục cha
commit
cf9c2ccacf

+ 1 - 1
src/server/app.ts

@@ -278,7 +278,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
       attachments: [], tools: [], createdAt: file.createdAt,
     }
   }
-  realtime.broker.onNativeGlobalEvent = (owner, type) => chatCache?.observeGlobal(owner, type)
+  realtime.broker.onNativeGlobalEvent = (owner, type, upstreamInvalidation) => chatCache?.observeGlobal(owner, type, upstreamInvalidation)
   chatCache.onSynchronized = (owner, profile, sessionID) => {
     realtime.broker.publishOwnerSessionsChanged(owner, 'cache.synced', profile, sessionID)
   }

+ 32 - 2
src/server/chatCache.ts

@@ -715,6 +715,30 @@ export class ChatCacheStore {
     this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE owner=? AND kind='list'").run(owner)
   }
 
+  invalidateOwnedMetadata(owner: string): Array<{profile:string;session_id:string}> {
+    return this.db.prepare(`UPDATE chat_sessions SET metadata_complete=0,sync_revision=sync_revision+1
+      WHERE owner=? AND owned_at IS NOT NULL AND source NOT IN ('cron','ios_group','yaoyao_workspace')
+      RETURNING profile,session_id`).all(owner) as Array<{profile:string;session_id:string}>
+  }
+
+  refreshMetadata(owner:string,profile:string,id:string,revision:number,response:UpstreamResponse,startedAt:number):boolean {
+    const payload=parseResponse(response),summary=object(payload?.session)??payload
+    const total=summary?.message_count??summary?.messageCount
+    const row=this.db.prepare('SELECT metadata_complete,complete,message_total,sync_state FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
+      .get(owner,profile,id) as {metadata_complete:number;complete:number;message_total:number;sync_state:string}|undefined
+    if(!row||row.metadata_complete||!row.complete||total==null||Number(total)!==row.message_total||this.revision(owner,profile,id)!==revision)return false
+    this.db.exec('SAVEPOINT metadata_refresh')
+    try {
+      const metadata={...summary};delete metadata.messages
+      const headers=new Headers(response.headers);headers.delete('content-length')
+      const metadataResponse={...response,headers,body:Buffer.from(JSON.stringify(metadata))}
+      if(!this.putSnapshot(owner,`detail:${profile}:${id}`,'detail',profile,id,metadataResponse,false,startedAt))throw new Error('Invalid conversation metadata')
+      // A metadata invalidation must not replace or downgrade the durable live tail.
+      this.db.prepare('UPDATE chat_sessions SET sync_state=? WHERE owner=? AND profile=? AND session_id=?').run(row.sync_state,owner,profile,id)
+      this.db.exec('RELEASE metadata_refresh');return true
+    }catch(error){this.db.exec('ROLLBACK TO metadata_refresh; RELEASE metadata_refresh');throw error}
+  }
+
   recordAuthoritativeTitle(owner: string, profile: string, sessionID: string, title: string): void {
     const normalized = title.trim()
     if (!normalized) return
@@ -1292,8 +1316,10 @@ export class ChatCacheCoordinator {
     }
   }
 
-  observeGlobal(owner: string, type: string): void {
-    if (type === 'sessions.changed') this.store.markListsStale(owner)
+  observeGlobal(owner: string, type: string, upstreamInvalidation=false): void {
+    if (type !== 'sessions.changed' || this.stopped || this.store.isClosed) return
+    this.store.markListsStale(owner)
+    if(upstreamInvalidation)for(const row of this.store.invalidateOwnedMetadata(owner))this.schedule(owner,row.profile,row.session_id)
   }
 
   command(owner: string, profile: string, sessionID: string, method: string,
@@ -1327,6 +1353,10 @@ export class ChatCacheCoordinator {
       const detail = await this.upstream.request(detailPath, { search: new URLSearchParams({ profile }), cache: 'reload' })
       if (this.stopped || this.store.isClosed) return
       if (detail.status !== 200) throw new Error('Unable to synchronize conversation metadata')
+      if(!force&&this.store.refreshMetadata(owner,profile,sessionID,revision,detail,detailStartedAt)) {
+        this.onSynchronized(owner,profile,sessionID)
+        return
+      }
       const detailPayload = parseResponse(detail) ?? {}
       const detailSummary = object(detailPayload.session) ?? detailPayload
       const summaryTotal = Math.max(0, Number(detailSummary.message_count ?? detailSummary.messageCount ?? previousTotal) || 0)

+ 2 - 2
src/server/realtimeBroker.ts

@@ -48,7 +48,7 @@ export class RealtimeBroker {
   }
   protectedSession: (id: string) => boolean = () => false
   onNativeEvent: (owner: string, profile: string, storedId: string, frame: Frame) => void = () => {}
-  onNativeGlobalEvent: (owner: string, type: string) => 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 = () => {}
   readonly epoch = randomUUID()
@@ -447,7 +447,7 @@ export class RealtimeBroker {
         const owner = this.nativeOwner(channel.principal)
         return owner && channel.principal.valid() ? [owner] : []
       }))
-      for (const owner of owners) this.onNativeGlobalEvent(owner, p.type)
+      for (const owner of owners) this.onNativeGlobalEvent(owner, p.type, true)
     }
     for (const c of this.channels.values()) {
       if (c.kind !== 'chat' || c.principal.upstreamKey !== u.principal.upstreamKey || !c.principal.valid()) continue

+ 2 - 1
tests/e2e/workspace-chat.spec.ts

@@ -341,6 +341,7 @@ test('created roles and editable teams share a durable chat list without plugin
   await page.getByRole('button',{name:'查看来源',exact:true}).first().click()
   await expect(page).toHaveURL(groupURL)
   await groupRow.click({ button: 'right' })
+  page.once('dialog', dialog => dialog.accept())
   await page.getByRole('menuitem', { name: '归档聊天', exact: true }).click()
   await expect(page.locator('.desktop-sidebar .sidebar-item')).toHaveCount(3)
   await page.locator('.desktop-sidebar .sidebar-search-trigger').click()
@@ -445,7 +446,7 @@ test('subaccount allocation and Bot-only navigation work through the browser', a
   await settings.locator('input[name=managed-temporary-password]').fill('temporary-pass')
   await settings.getByRole('listbox', { name: '新子账号的基础机器人' }).selectOption('default')
   await settings.getByRole('button', { name: '创建用户', exact: true }).click()
-  await expect(settings.getByRole('listbox', { name: `分配给 ${username} 的基础 Agent`, exact: true })).toHaveValues(['default'])
+  await expect(settings.getByRole('listbox', { name: `分配给 ${username} 的基础机器人`, exact: true })).toHaveValues(['default'])
   await page.screenshot({ path: testInfo.outputPath('admin-agent-allocation.png'), fullPage: true })
 
   const childContext = await browser.newContext({ viewport: { width: 1280, height: 800 } })

+ 22 - 0
tests/server/ordinaryChatLocal.test.ts

@@ -49,6 +49,28 @@ describe('ordinary chat authoritative local reads',()=>{
 })
 
 describe('ordinary chat migration and bounded recovery',()=>{
+ it('refreshes invalidated remote titles in the background without refetching complete message history',async()=>{
+   const {store,coordinator,request}=setup()
+   store.recordCommand('o','p','s','session.create',{})
+   store.recordCommand('other','p','private','session.create',{})
+   request.mockResolvedValue({status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({id:'s',title:'远端自动标题',message_count:0,messages:[{id:'unrelated',role:'assistant',content:'do not import a metadata response as history'}]}))})
+   coordinator.observeGlobal('o','sessions.changed',true)
+   expect(json(coordinator.readLocal('o','list','p',undefined,{})).sessions).toHaveLength(1)
+   expect(request).not.toHaveBeenCalled()
+   await vi.waitFor(()=>expect(json(coordinator.readLocal('o','list','p',undefined,{})).sessions[0].title).toBe('远端自动标题'))
+   expect(request).toHaveBeenCalledTimes(1)
+   expect(request.mock.calls[0]![0]).toBe('/api/sessions/s')
+   expect(store.db.prepare('SELECT * FROM chat_messages').all()).toHaveLength(0)
+   store.patchLocal('o','p','s',{title:'本地手动标题'})
+   coordinator.observeGlobal('o','sessions.changed',true)
+   await vi.waitFor(()=>expect(store.db.prepare('SELECT * FROM chat_recovery_jobs').all()).toHaveLength(0))
+   expect(json(coordinator.readLocal('o','list','p',undefined,{})).sessions[0].title).toBe('本地手动标题')
+   const reads=request.mock.calls.length
+   coordinator.observeGlobal('o','sessions.changed')
+   coordinator.readLocal('o','list','p',undefined,{})
+   await new Promise(resolve=>setTimeout(resolve,10))
+   expect(request).toHaveBeenCalledTimes(reads)
+ })
  it('preserves local messages and pins when a live session becomes a compressed tip',()=>{
    const {store,coordinator}=setup();store.recordRoute('o','p','old','runtime');store.recordCommand('o','p','old','session.create',{})
    coordinator.observe('o','p','old',{type:'message.delta',payload:{text:'kept'},delivery_id:'one'})