|
@@ -1,4 +1,6 @@
|
|
|
-import { createHash } from 'node:crypto'
|
|
|
|
|
|
|
+import { projectChatEvent } from './chatEventProjection.js'
|
|
|
|
|
+import { HttpError } from './errors.js'
|
|
|
|
|
+import { createHash, randomUUID } from 'node:crypto'
|
|
|
import { chmodSync, createReadStream, existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
import { chmodSync, createReadStream, existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
|
import { join } from 'node:path'
|
|
import { join } from 'node:path'
|
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
@@ -136,6 +138,8 @@ function referencedPaths(value: unknown, output = new Set<string>()): Set<string
|
|
|
export class ChatCacheStore {
|
|
export class ChatCacheStore {
|
|
|
readonly db: DatabaseSync
|
|
readonly db: DatabaseSync
|
|
|
readonly assetsRoot: string
|
|
readonly assetsRoot: string
|
|
|
|
|
+ private closed = false
|
|
|
|
|
+ get isClosed(): boolean { return this.closed }
|
|
|
|
|
|
|
|
constructor(home: string, legacySingleUserID?: string) {
|
|
constructor(home: string, legacySingleUserID?: string) {
|
|
|
mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
@@ -179,6 +183,19 @@ export class ChatCacheStore {
|
|
|
saved_at INTEGER NOT NULL, PRIMARY KEY(owner,cache_key));
|
|
saved_at INTEGER NOT NULL, PRIMARY KEY(owner,cache_key));
|
|
|
CREATE TABLE IF NOT EXISTS chat_meta(
|
|
CREATE TABLE IF NOT EXISTS chat_meta(
|
|
|
key TEXT PRIMARY KEY, value TEXT NOT NULL);`)
|
|
key TEXT PRIMARY KEY, value TEXT NOT NULL);`)
|
|
|
|
|
+ this.db.exec(`CREATE TABLE IF NOT EXISTS chat_local_state(
|
|
|
|
|
+ owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
|
|
|
+ pinned INTEGER, read_count INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
+ assistant_id TEXT, PRIMARY KEY(owner,profile,session_id));
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS chat_session_aliases(owner TEXT NOT NULL,profile TEXT NOT NULL,alias_id TEXT NOT NULL,session_id TEXT NOT NULL,PRIMARY KEY(owner,profile,alias_id));
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS chat_recovery_jobs(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,force INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(owner,profile,session_id));
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS chat_event_receipts(
|
|
|
|
|
+ owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL, event_id TEXT NOT NULL,
|
|
|
|
|
+ PRIMARY KEY(owner,profile,session_id,event_id));`)
|
|
|
|
|
+ const localColumns=this.db.prepare('PRAGMA table_info(chat_local_state)').all() as {name:string}[]
|
|
|
|
|
+ for(const [name,type] of [['title','TEXT'],['archived','INTEGER'],['read_initialized','INTEGER NOT NULL DEFAULT 0']]){
|
|
|
|
|
+ if(!localColumns.some(c=>c.name===name))this.db.exec(`ALTER TABLE chat_local_state ADD COLUMN ${name} ${type}`)
|
|
|
|
|
+ }
|
|
|
const sessionColumns = this.db.prepare('PRAGMA table_info(chat_sessions)').all() as Array<{ name: string }>
|
|
const sessionColumns = this.db.prepare('PRAGMA table_info(chat_sessions)').all() as Array<{ name: string }>
|
|
|
this.db.exec('BEGIN IMMEDIATE')
|
|
this.db.exec('BEGIN IMMEDIATE')
|
|
|
try {
|
|
try {
|
|
@@ -286,6 +303,8 @@ export class ChatCacheStore {
|
|
|
this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE kind='list'").run()
|
|
this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE kind='list'").run()
|
|
|
this.db.prepare(`INSERT INTO chat_meta(key,value) VALUES('ownership-registry-v1','complete')
|
|
this.db.prepare(`INSERT INTO chat_meta(key,value) VALUES('ownership-registry-v1','complete')
|
|
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run()
|
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run()
|
|
|
|
|
+ this.db.prepare(`INSERT OR IGNORE INTO chat_local_state(owner,profile,session_id)
|
|
|
|
|
+ SELECT owner,profile,session_id FROM chat_sessions WHERE owned_at IS NOT NULL`).run()
|
|
|
this.db.exec('COMMIT')
|
|
this.db.exec('COMMIT')
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
this.db.exec('ROLLBACK')
|
|
this.db.exec('ROLLBACK')
|
|
@@ -293,7 +312,7 @@ export class ChatCacheStore {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- close(): void { this.db.close() }
|
|
|
|
|
|
|
+ close(): void { this.closed = true; this.db.close() }
|
|
|
|
|
|
|
|
stats(): Record<string, number> {
|
|
stats(): Record<string, number> {
|
|
|
const count = (table: string) => Number((this.db.prepare(`SELECT COUNT(*) count FROM ${table}`).get() as { count: number }).count)
|
|
const count = (table: string) => Number((this.db.prepare(`SELECT COUNT(*) count FROM ${table}`).get() as { count: number }).count)
|
|
@@ -315,10 +334,134 @@ export class ChatCacheStore {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
localList(owner: string, profile: string, page: { offset?: number; limit?: number; archived?: string }): UpstreamResponse {
|
|
localList(owner: string, profile: string, page: { offset?: number; limit?: number; archived?: string }): UpstreamResponse {
|
|
|
- return this.mergeOwnedSessionsIntoList(owner, profile, {
|
|
|
|
|
- status: 200, headers: new Headers({ 'content-type': 'application/json' }),
|
|
|
|
|
- body: Buffer.from('{"sessions":[]}'),
|
|
|
|
|
- }, page)
|
|
|
|
|
|
|
+ const offset = Math.max(0, Math.trunc(page.offset ?? 0)), limit = Math.max(1, Math.min(200, Math.trunc(page.limit ?? 100)))
|
|
|
|
|
+ const where = `s.owner=? AND s.owned_at IS NOT NULL AND (?='' OR s.profile=?)
|
|
|
|
|
+ AND s.source NOT IN ('cron','ios_group','yaoyao_workspace')
|
|
|
|
|
+ AND (?='include' OR COALESCE(l.archived,json_extract(s.data,'$.archived'),json_extract(s.data,'$.is_archived'),0)=CASE WHEN ?='only' THEN 1 ELSE 0 END)`
|
|
|
|
|
+ const args = [owner, profile, profile, page.archived ?? 'exclude', page.archived ?? 'exclude']
|
|
|
|
|
+ const total = Number((this.db.prepare(`SELECT count(*) n FROM chat_sessions s LEFT JOIN chat_local_state l USING(owner,profile,session_id) WHERE ${where}`).get(...args) as {n:number}).n)
|
|
|
|
|
+ const rows = this.db.prepare(`SELECT s.profile,s.session_id,s.data,s.sync_state,s.complete,l.title local_title,
|
|
|
|
|
+ COALESCE(l.pinned,json_extract(s.data,'$.pinned'),json_extract(s.data,'$.is_pinned'),0) pin
|
|
|
|
|
+ FROM chat_sessions s LEFT JOIN chat_local_state l USING(owner,profile,session_id) WHERE ${where}
|
|
|
|
|
+ ORDER BY pin DESC, COALESCE(json_extract(s.data,'$.last_active'),0) DESC, s.session_id
|
|
|
|
|
+ LIMIT ? OFFSET ?`).all(...args, limit, offset) as Array<Record<string,any>>
|
|
|
|
|
+ return {status:200,headers:new Headers({'content-type':'application/json'}),body:Buffer.from(JSON.stringify({
|
|
|
|
|
+ sessions:rows.map(r=>({...JSON.parse(r.data),...(r.local_title!=null?{title:r.local_title}:{}),id:r.session_id,profile:r.profile,owned:true,pinned:!!r.pin,
|
|
|
|
|
+ sync_state:r.sync_state,history_complete:!!r.complete})), total,offset,limit,
|
|
|
|
|
+ sync_state:this.needsListMetadata(owner,profile)?'syncing':'current'
|
|
|
|
|
+ }))}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ missingSessions(owner: string, profile: string): Array<{profile:string;session_id:string}> {
|
|
|
|
|
+ return (this.db.prepare(`SELECT profile,session_id FROM chat_sessions WHERE owner=? AND owned_at IS NOT NULL
|
|
|
|
|
+ AND (?='' OR profile=?) AND (metadata_complete=0 OR complete=0) LIMIT 100`)
|
|
|
|
|
+ .all(owner,profile,profile) as {profile:string;session_id:string}[])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ pins(owner: string, profile: string): string[] {
|
|
|
|
|
+ return (this.db.prepare(`SELECT s.session_id FROM chat_sessions s LEFT JOIN chat_local_state l USING(owner,profile,session_id)
|
|
|
|
|
+ WHERE s.owner=? AND s.owned_at IS NOT NULL AND (?='' OR s.profile=?)
|
|
|
|
|
+ AND COALESCE(l.pinned,json_extract(s.data,'$.pinned'),json_extract(s.data,'$.is_pinned'),0)=1 ORDER BY s.last_synced_at DESC`)
|
|
|
|
|
+ .all(owner,profile,profile) as {session_id:string}[]).map(r=>r.session_id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ unread(owner: string, profile: string) {
|
|
|
|
|
+ const sessions = (this.db.prepare(`SELECT s.profile,s.session_id,COALESCE(s.message_total,0) message_count,
|
|
|
|
|
+ CASE WHEN COALESCE(l.read_initialized,0)=1 THEN l.read_count ELSE COALESCE(s.message_total,0) END read_message_count FROM chat_sessions s LEFT JOIN chat_local_state l USING(owner,profile,session_id)
|
|
|
|
|
+ WHERE s.owner=? AND s.owned_at IS NOT NULL AND (?='' OR s.profile=?)`).all(owner,profile,profile) as any[])
|
|
|
|
|
+ .map(r=>({...r,updated_at:Date.now(),unread_count:Math.max(0,r.message_count-r.read_message_count)}))
|
|
|
|
|
+ return {profile,supported:true,total_unread:sessions.reduce((n,r)=>n+r.unread_count,0),sessions}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ markRead(owner: string, profile: string, id: string, count?: number) {
|
|
|
|
|
+ id=this.canonicalSessionID(owner,profile,id)
|
|
|
|
|
+ this.requireOwned(owner,profile,id)
|
|
|
|
|
+ const total = Number((this.db.prepare('SELECT message_total FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
|
|
|
|
|
+ .get(owner,profile,id) as any).message_total ?? 0)
|
|
|
|
|
+ const read = Math.max(0,Math.min(total, count ?? total))
|
|
|
|
|
+ this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,read_count,read_initialized) VALUES(?,?,?,?,1)
|
|
|
|
|
+ ON CONFLICT(owner,profile,session_id) DO UPDATE SET read_count=MAX(read_count,excluded.read_count),read_initialized=1`).run(owner,profile,id,read)
|
|
|
|
|
+ return this.unread(owner,profile).sessions.find(r=>r.session_id===id)!
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ requireOwned(owner: string, profile: string, id: string): void {
|
|
|
|
|
+ if (!this.ownsSession(owner,profile,id)) throw new HttpError(404,'会话不存在','session_not_found')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ patchLocal(owner: string, profile: string, id: string, patch: Record<string,unknown>): void {
|
|
|
|
|
+ id=this.canonicalSessionID(owner,profile,id)
|
|
|
|
|
+ this.requireOwned(owner,profile,id)
|
|
|
|
|
+ this.db.exec('SAVEPOINT local_chat_patch')
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (typeof patch.pinned==='boolean') this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,pinned) VALUES(?,?,?,?)
|
|
|
|
|
+ ON CONFLICT(owner,profile,session_id) DO UPDATE SET pinned=excluded.pinned`).run(owner,profile,id,patch.pinned?1:0)
|
|
|
|
|
+ const row = this.localDetail(owner,profile,id)!
|
|
|
|
|
+ const data = {...JSON.parse(row.response.body.toString()),...patch}
|
|
|
|
|
+ this.db.prepare('UPDATE chat_sessions SET data=? WHERE owner=? AND profile=? AND session_id=?').run(JSON.stringify(data),owner,profile,id)
|
|
|
|
|
+ for(const key of ['title','archived'] as const)if(patch[key]!==undefined){
|
|
|
|
|
+ const value=key==='archived'?(patch[key]?1:0):String(patch[key])
|
|
|
|
|
+ this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,${key}) VALUES(?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET ${key}=excluded.${key}`).run(owner,profile,id,value)
|
|
|
|
|
+ }
|
|
|
|
|
+ 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.db.exec('RELEASE local_chat_patch')
|
|
|
|
|
+ } catch(e) {this.db.exec('ROLLBACK TO local_chat_patch; RELEASE local_chat_patch');throw e}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private writeLiveMessage(owner:string,profile:string,id:string,message:Record<string,any>): void {
|
|
|
|
|
+ const prior = this.db.prepare('SELECT position FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?').get(owner,profile,id,message.id) as {position:number}|undefined
|
|
|
|
|
+ const row = this.db.prepare('SELECT message_total,complete,data FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,id) as any
|
|
|
|
|
+ const extent = Number((this.db.prepare('SELECT COALESCE(MAX(position)+1,0) n FROM chat_messages WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,id) as any).n)
|
|
|
|
|
+ const position = prior?.position ?? Math.max(extent,Number(row.message_total??0))
|
|
|
|
|
+ const data=JSON.stringify(message)
|
|
|
|
|
+ this.db.prepare(`INSERT INTO chat_messages(owner,profile,session_id,message_id,position,data,content_hash) VALUES(?,?,?,?,?,?,?)
|
|
|
|
|
+ ON CONFLICT(owner,profile,session_id,message_id) DO UPDATE SET data=excluded.data,content_hash=excluded.content_hash`).run(owner,profile,id,message.id,position,data,createHash('sha256').update(data).digest('hex'))
|
|
|
|
|
+ const total=Math.max(Number(row.message_total??0),position+1), summary=JSON.parse(row.data)
|
|
|
|
|
+ const now=Date.now()
|
|
|
|
|
+ summary.message_count=total; summary.last_active=now/1000
|
|
|
|
|
+ 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)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ projectedMessageID(owner:string,profile:string,id:string):string|undefined {
|
|
|
|
|
+ return (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,id) as {message_id:string}|undefined)?.message_id
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private projectEvent(owner:string,profile:string,id:string,type:string,p:Record<string,any>,eventID:string): void {
|
|
|
|
|
+ if(!this.ownsSession(owner,profile,id))return
|
|
|
|
|
+ if(type==='command.confirmed'||type==='command.rejected'){
|
|
|
|
|
+ const userID=`user:${p.delivery_id}`
|
|
|
|
|
+ const row=this.db.prepare('SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?').get(owner,profile,id,userID) as any
|
|
|
|
|
+ if(row)this.writeLiveMessage(owner,profile,id,{...JSON.parse(row.data),status:type==='command.confirmed'?'complete':'failed',...(p.error?{error:p.error}:{})})
|
|
|
|
|
+ this.db.prepare("UPDATE chat_sessions SET sync_state=CASE WHEN sync_state='gap' THEN 'gap' WHEN complete=1 THEN 'current' ELSE 'stale' END WHERE owner=? AND profile=? AND session_id=?").run(owner,profile,id)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (type==='message.user' || type==='peer_user_message') {
|
|
|
|
|
+ this.writeLiveMessage(owner,profile,id,{...p,id:String(p.message_id??p.id??eventID),role:'user',content:p.text??p.content??'',timestamp:Date.now()/1000});return
|
|
|
|
|
+ }
|
|
|
|
|
+ const state=this.db.prepare('SELECT assistant_id FROM chat_local_state WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,id) as any
|
|
|
|
|
+ const last=state?.assistant_id?this.db.prepare('SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?').get(owner,profile,id,state.assistant_id) as any:undefined
|
|
|
|
|
+ const previous=last?JSON.parse(last.data):undefined
|
|
|
|
|
+ // A new run must never append to the assistant row from the preceding turn.
|
|
|
|
|
+ if(['message.complete','run.completed'].includes(type) && !previous && !p.text && !p.content && !p.message){
|
|
|
|
|
+ this.db.prepare("UPDATE chat_sessions SET sync_state='gap' WHERE owner=? AND profile=? AND session_id=?").run(owner,profile,id);return
|
|
|
|
|
+ }
|
|
|
|
|
+ const startsNew=type==='message.start' && previous?.status!=='streaming'
|
|
|
|
|
+ const message=projectChatEvent(type,p,startsNew?undefined:previous,`event:${eventID}`,Date.now())
|
|
|
|
|
+ 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)
|
|
|
|
|
+ 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)
|
|
|
|
|
+ this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,assistant_id) VALUES(?,?,?,?)
|
|
|
|
|
+ ON CONFLICT(owner,profile,session_id) DO UPDATE SET assistant_id=excluded.assistant_id`).run(owner,profile,id,type==='message.interim'?null:message.id)
|
|
|
|
|
+ this.db.prepare(`UPDATE chat_sessions SET sync_state=CASE WHEN sync_state='gap' THEN 'gap' WHEN complete=1 THEN 'current' ELSE 'stale' END
|
|
|
|
|
+ WHERE owner=? AND profile=? AND session_id=?`).run(owner,profile,id)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
needsListMetadata(owner: string, profile: string): boolean {
|
|
needsListMetadata(owner: string, profile: string): boolean {
|
|
@@ -331,8 +474,9 @@ export class ChatCacheStore {
|
|
|
WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`)
|
|
WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`)
|
|
|
.get(owner, profile, sessionID) as { data: string; sync_state: ChatCacheState } | undefined
|
|
.get(owner, profile, sessionID) as { data: string; sync_state: ChatCacheState } | undefined
|
|
|
if (!row) return undefined
|
|
if (!row) return undefined
|
|
|
|
|
+ const local=this.db.prepare('SELECT title,archived,pinned FROM chat_local_state WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionID) as any
|
|
|
return { response: { status: 200, headers: new Headers({ 'content-type': 'application/json' }),
|
|
return { response: { status: 200, headers: new Headers({ 'content-type': 'application/json' }),
|
|
|
- body: Buffer.from(row.data) }, state: row.sync_state }
|
|
|
|
|
|
|
+ body: Buffer.from(JSON.stringify({...JSON.parse(row.data),...(local?.title!=null?{title:local.title}:{}),...(local?.archived!=null?{archived:!!local.archived}:{}),owned:true,pinned:local?.pinned!=null?!!local.pinned:bool(JSON.parse(row.data).pinned),sync_state:row.sync_state})) }, state: row.sync_state }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
revision(owner: string, profile: string, sessionID: string): number {
|
|
revision(owner: string, profile: string, sessionID: string): number {
|
|
@@ -381,8 +525,29 @@ export class ChatCacheStore {
|
|
|
this.db.exec('SAVEPOINT history_sync')
|
|
this.db.exec('SAVEPOINT history_sync')
|
|
|
try {
|
|
try {
|
|
|
if (force) this.db.prepare('DELETE FROM chat_messages WHERE owner=? AND profile=? AND session_id=?').run(owner, profile, sessionID)
|
|
if (force) this.db.prepare('DELETE FROM chat_messages WHERE owner=? AND profile=? AND session_id=?').run(owner, profile, sessionID)
|
|
|
|
|
+ // Recovery promotes locally generated stream identities to upstream row IDs.
|
|
|
|
|
+ // Remove only replaced rows inside the verified window; keep older coverage.
|
|
|
|
|
+ if(!force){
|
|
|
|
|
+ const windows=pages.filter(p=>p.kind==='messages').map(p=>parseResponse(p.response)).filter(Boolean) as Record<string,any>[]
|
|
|
|
|
+ for(const window of windows){
|
|
|
|
|
+ const pagination=window.pagination??{},messages=window.messages as Record<string,any>[]
|
|
|
|
|
+ if(!Array.isArray(messages)||!Number.isSafeInteger(pagination.total))continue
|
|
|
|
|
+ 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}[]
|
|
|
|
|
+ for(const row of existing){
|
|
|
|
|
+ if(ids.has(row.message_id))continue
|
|
|
|
|
+ const value=JSON.parse(row.data)
|
|
|
|
|
+ if(value.role==='user'&&value.status==='pending'&&!messages.some(m=>m.role==='user'&&m.content===value.content))
|
|
|
|
|
+ throw new Error('Unconfirmed submission must be reconciled before history replacement')
|
|
|
|
|
+ this.db.prepare('DELETE FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?').run(owner,profile,sessionID,row.message_id)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND profile=? AND session_id=? AND kind='messages'").run(owner, profile, sessionID)
|
|
this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND profile=? AND session_id=? AND kind='messages'").run(owner, profile, sessionID)
|
|
|
for (const page of pages) this.putSnapshot(owner, page.key, page.kind, profile, sessionID, page.response, false, page.startedAt)
|
|
for (const page of pages) this.putSnapshot(owner, page.key, page.kind, profile, sessionID, page.response, false, page.startedAt)
|
|
|
|
|
+ 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.db.exec('RELEASE history_sync')
|
|
this.db.exec('RELEASE history_sync')
|
|
|
return true
|
|
return true
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
@@ -407,7 +572,7 @@ export class ChatCacheStore {
|
|
|
if (messages.length !== end - start) return undefined // Fetch only an actually missing page.
|
|
if (messages.length !== end - start) return undefined // Fetch only an actually missing page.
|
|
|
const body = Buffer.from(JSON.stringify({
|
|
const body = Buffer.from(JSON.stringify({
|
|
|
session_id: sessionID,
|
|
session_id: sessionID,
|
|
|
- session: JSON.parse(String(session.data)),
|
|
|
|
|
|
|
+ session: JSON.parse(this.localDetail(owner,profile,sessionID)!.response.body.toString()),
|
|
|
messages,
|
|
messages,
|
|
|
pagination: {
|
|
pagination: {
|
|
|
total,
|
|
total,
|
|
@@ -427,20 +592,23 @@ export class ChatCacheStore {
|
|
|
const rawSeq = Number(frame.seq ?? frame.sequence ?? 0)
|
|
const rawSeq = Number(frame.seq ?? frame.sequence ?? 0)
|
|
|
const type = String(frame.type ?? 'unknown')
|
|
const type = String(frame.type ?? 'unknown')
|
|
|
const payload = JSON.stringify(frame.payload ?? {})
|
|
const payload = JSON.stringify(frame.payload ?? {})
|
|
|
- const seq = Number.isSafeInteger(rawSeq) && rawSeq > 0
|
|
|
|
|
- ? rawSeq
|
|
|
|
|
- : Number.parseInt(createHash('sha256').update(`${type}:${payload}`).digest('hex').slice(0, 12), 16)
|
|
|
|
|
|
|
+ const eventID = String(frame.delivery_id ?? (rawSeq > 0 ? `${frame.epoch ?? ''}:${rawSeq}:${type}` : randomUUID()))
|
|
|
|
|
+ const seq = Number.parseInt(createHash('sha256').update(eventID).digest('hex').slice(0,12),16)
|
|
|
const now = Date.now()
|
|
const now = Date.now()
|
|
|
|
|
+ this.db.exec('SAVEPOINT live_event')
|
|
|
|
|
+ try {
|
|
|
|
|
+ const receipt=this.db.prepare('INSERT OR IGNORE INTO chat_event_receipts VALUES(?,?,?,?)').run(owner,profile,sessionID,eventID)
|
|
|
|
|
+ if(!receipt.changes){this.db.exec('RELEASE live_event');return false}
|
|
|
const previous = this.db.prepare(`SELECT last_event_seq FROM chat_sessions
|
|
const previous = this.db.prepare(`SELECT last_event_seq FROM chat_sessions
|
|
|
WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as { last_event_seq?: number } | undefined
|
|
WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as { last_event_seq?: number } | undefined
|
|
|
const lastSeen = Number(previous?.last_event_seq ?? 0)
|
|
const lastSeen = Number(previous?.last_event_seq ?? 0)
|
|
|
const inserted = this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
|
|
const inserted = this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
|
|
|
VALUES(?,?,?,?,?,?,?)`).run(owner, profile, sessionID, Number.isSafeInteger(seq) ? seq : 0, type, payload, now)
|
|
VALUES(?,?,?,?,?,?,?)`).run(owner, profile, sessionID, Number.isSafeInteger(seq) ? seq : 0, type, payload, now)
|
|
|
- if (!inserted.changes) return false
|
|
|
|
|
|
|
+ if (!inserted.changes) {this.db.exec('RELEASE live_event');return false}
|
|
|
const state: ChatCacheState = type === 'gateway.reset' || type.includes('reset')
|
|
const state: ChatCacheState = type === 'gateway.reset' || type.includes('reset')
|
|
|
- || (rawSeq > 0 && lastSeen > 0 && rawSeq > lastSeen + 1) ? 'gap' : 'stale'
|
|
|
|
|
|
|
+ || (!frame.epoch && rawSeq > 0 && lastSeen > 0 && rawSeq > lastSeen + 1) ? 'gap' : 'stale'
|
|
|
const metadataOnly = ['session.info', 'session.title', 'session.title.updated', 'session.usage', 'gateway.ready'].includes(type) && state !== 'gap'
|
|
const metadataOnly = ['session.info', 'session.title', 'session.title.updated', 'session.usage', 'gateway.ready'].includes(type) && state !== 'gap'
|
|
|
- this.db.prepare(`UPDATE chat_sessions SET sync_state=CASE WHEN ? THEN sync_state ELSE ? END,
|
|
|
|
|
|
|
+ this.db.prepare(`UPDATE chat_sessions SET sync_state=CASE WHEN ? THEN sync_state WHEN sync_state='gap' THEN 'gap' ELSE ? END,
|
|
|
sync_revision=sync_revision+?,last_event_seq=MAX(last_event_seq,?),last_synced_at=?
|
|
sync_revision=sync_revision+?,last_event_seq=MAX(last_event_seq,?),last_synced_at=?
|
|
|
WHERE owner=? AND profile=? AND session_id=?`).run(metadataOnly ? 1 : 0, state, metadataOnly ? 0 : 1, rawSeq > 0 ? rawSeq : lastSeen, now, owner, profile, sessionID)
|
|
WHERE owner=? AND profile=? AND session_id=?`).run(metadataOnly ? 1 : 0, state, metadataOnly ? 0 : 1, rawSeq > 0 ? rawSeq : lastSeen, now, owner, profile, sessionID)
|
|
|
if (type === 'session.info') {
|
|
if (type === 'session.info') {
|
|
@@ -465,10 +633,39 @@ export class ChatCacheStore {
|
|
|
VALUES(?,?,?,?,?) ON CONFLICT(owner,profile) DO UPDATE SET event_cursor=MAX(event_cursor,excluded.event_cursor),
|
|
VALUES(?,?,?,?,?) ON CONFLICT(owner,profile) DO UPDATE SET event_cursor=MAX(event_cursor,excluded.event_cursor),
|
|
|
sync_state=excluded.sync_state,last_synced_at=excluded.last_synced_at`)
|
|
sync_state=excluded.sync_state,last_synced_at=excluded.last_synced_at`)
|
|
|
.run(owner, profile, rawSeq > 0 ? rawSeq : lastSeen, state, now)
|
|
.run(owner, profile, rawSeq > 0 ? rawSeq : lastSeen, state, now)
|
|
|
|
|
+ this.projectEvent(owner,profile,sessionID,type,object(frame.payload)??{},eventID)
|
|
|
|
|
+ this.db.exec('RELEASE live_event')
|
|
|
return true
|
|
return true
|
|
|
|
|
+ }catch(e){this.db.exec('ROLLBACK TO live_event; RELEASE live_event');throw e}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ canonicalSessionID(owner:string,profile:string,id:string):string {
|
|
|
|
|
+ let current=id
|
|
|
|
|
+ const seen=new Set<string>()
|
|
|
|
|
+ for(let n=0;n<32&&!seen.has(current);n++){
|
|
|
|
|
+ seen.add(current)
|
|
|
|
|
+ const row=this.db.prepare('SELECT session_id FROM chat_session_aliases WHERE owner=? AND profile=? AND alias_id=?').get(owner,profile,current) as {session_id:string}|undefined
|
|
|
|
|
+ if(!row)break
|
|
|
|
|
+ current=row.session_id
|
|
|
|
|
+ }
|
|
|
|
|
+ return current
|
|
|
|
|
+ }
|
|
|
|
|
+ private migrateRuntimeSession(owner:string,profile:string,id:string,runtime:string):void {
|
|
|
|
|
+ const old=this.db.prepare('SELECT session_id FROM chat_sessions WHERE owner=? AND profile=? AND runtime_id=? AND session_id<>? AND owned_at IS NOT NULL').get(owner,profile,runtime,id) as {session_id:string}|undefined
|
|
|
|
|
+ if(!old||this.ownsSession(owner,profile,id))return
|
|
|
|
|
+ this.db.exec('SAVEPOINT migrate_live_route')
|
|
|
|
|
+ try{
|
|
|
|
|
+ for(const table of ['chat_sessions','chat_messages','chat_events','chat_snapshots','chat_attachments','chat_local_state','chat_event_receipts','chat_recovery_jobs'])
|
|
|
|
|
+ this.db.prepare(`UPDATE ${table} SET session_id=? WHERE owner=? AND profile=? AND session_id=?`).run(id,owner,profile,old.session_id)
|
|
|
|
|
+ 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.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): void {
|
|
|
|
|
+ this.migrateRuntimeSession(owner,profile,sessionID,runtimeID)
|
|
|
const now = Date.now()
|
|
const now = Date.now()
|
|
|
const summary = { id: sessionID, profile, source: 'unknown', title: '新对话', started_at: now, last_active: now }
|
|
const summary = { id: sessionID, profile, source: 'unknown', title: '新对话', started_at: now, last_active: now }
|
|
|
this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at,owned_at,ownership_evidence)
|
|
this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at,owned_at,ownership_evidence)
|
|
@@ -488,11 +685,18 @@ export class ChatCacheStore {
|
|
|
this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,source,data,sync_state,complete,last_synced_at,owned_at,ownership_evidence)
|
|
this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,source,data,sync_state,complete,last_synced_at,owned_at,ownership_evidence)
|
|
|
VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET
|
|
VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET
|
|
|
source=CASE WHEN excluded.source='unknown' THEN chat_sessions.source ELSE excluded.source END,
|
|
source=CASE WHEN excluded.source='unknown' THEN chat_sessions.source ELSE excluded.source END,
|
|
|
- sync_state='stale',sync_revision=chat_sessions.sync_revision+1,last_synced_at=excluded.last_synced_at,
|
|
|
|
|
|
|
+ sync_state=CASE WHEN chat_sessions.sync_state='gap' THEN 'gap' ELSE 'stale' END,sync_revision=chat_sessions.sync_revision+1,last_synced_at=excluded.last_synced_at,
|
|
|
owned_at=COALESCE(chat_sessions.owned_at,excluded.owned_at),
|
|
owned_at=COALESCE(chat_sessions.owned_at,excluded.owned_at),
|
|
|
ownership_evidence=COALESCE(chat_sessions.ownership_evidence,excluded.ownership_evidence)`)
|
|
ownership_evidence=COALESCE(chat_sessions.ownership_evidence,excluded.ownership_evidence)`)
|
|
|
.run(owner, profile, sessionID, source, JSON.stringify(summary), 'stale', 0, now,
|
|
.run(owner, profile, sessionID, source, JSON.stringify(summary), 'stale', 0, now,
|
|
|
establishesOwnership ? now : null, establishesOwnership ? `command:${method}` : null)
|
|
establishesOwnership ? now : null, establishesOwnership ? `command:${method}` : null)
|
|
|
|
|
+ if(method==='session.create') this.db.prepare(`INSERT INTO chat_local_state(owner,profile,session_id,read_initialized) VALUES(?,?,?,1) ON CONFLICT(owner,profile,session_id) DO UPDATE SET read_initialized=1`).run(owner,profile,sessionID)
|
|
|
|
|
+ if(method==='session.create') this.db.prepare(`UPDATE chat_sessions SET complete=1,metadata_complete=1,message_total=0,sync_state='current' WHERE owner=? AND profile=? AND session_id=? AND message_total IS NULL`).run(owner,profile,sessionID)
|
|
|
|
|
+ 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 payload = JSON.stringify(params)
|
|
const payload = JSON.stringify(params)
|
|
|
const seq = Number.parseInt(createHash('sha256').update(`${method}:${payload}`).digest('hex').slice(0, 12), 16)
|
|
const seq = Number.parseInt(createHash('sha256').update(`${method}:${payload}`).digest('hex').slice(0, 12), 16)
|
|
|
this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
|
|
this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
|
|
@@ -525,7 +729,7 @@ export class ChatCacheStore {
|
|
|
.all(owner, profile, sessionID) as Array<{ local_path: string }>
|
|
.all(owner, profile, sessionID) as Array<{ local_path: string }>
|
|
|
this.db.exec('BEGIN IMMEDIATE')
|
|
this.db.exec('BEGIN IMMEDIATE')
|
|
|
try {
|
|
try {
|
|
|
- for (const table of ['chat_messages', 'chat_events', 'chat_attachments', 'chat_sessions']) {
|
|
|
|
|
|
|
+ for (const table of ['chat_messages', 'chat_events', 'chat_attachments', 'chat_local_state', 'chat_event_receipts', 'chat_recovery_jobs', 'chat_session_aliases', 'chat_sessions']) {
|
|
|
this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(owner, profile, sessionID)
|
|
this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(owner, profile, sessionID)
|
|
|
}
|
|
}
|
|
|
this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND ((profile=? AND session_id=?) OR kind='list')")
|
|
this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND ((profile=? AND session_id=?) OR kind='list')")
|
|
@@ -550,6 +754,7 @@ export class ChatCacheStore {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
ownsSession(owner: string, profile: string, sessionID: string): boolean {
|
|
ownsSession(owner: string, profile: string, sessionID: string): boolean {
|
|
|
|
|
+ sessionID=this.canonicalSessionID(owner,profile,sessionID)
|
|
|
return Boolean(this.db.prepare(
|
|
return Boolean(this.db.prepare(
|
|
|
'SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL',
|
|
'SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL',
|
|
|
).get(owner, profile, sessionID))
|
|
).get(owner, profile, sessionID))
|
|
@@ -562,7 +767,8 @@ export class ChatCacheStore {
|
|
|
const row = this.db.prepare(`SELECT authoritative_title FROM chat_sessions
|
|
const row = this.db.prepare(`SELECT authoritative_title FROM chat_sessions
|
|
|
WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`)
|
|
WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`)
|
|
|
.get(owner, profile, sessionID) as { authoritative_title?: string | null } | undefined
|
|
.get(owner, profile, sessionID) as { authoritative_title?: string | null } | undefined
|
|
|
- const authoritativeTitle = String(row?.authoritative_title ?? '').trim()
|
|
|
|
|
|
|
+ const local=this.db.prepare('SELECT title FROM chat_local_state WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,sessionID) as {title?:string}|undefined
|
|
|
|
|
+ const authoritativeTitle = String(local?.title ?? row?.authoritative_title ?? '').trim()
|
|
|
return {
|
|
return {
|
|
|
owned: Boolean(row),
|
|
owned: Boolean(row),
|
|
|
...(authoritativeTitle ? { authoritativeTitle } : {}),
|
|
...(authoritativeTitle ? { authoritativeTitle } : {}),
|
|
@@ -717,7 +923,8 @@ export class ChatCacheStore {
|
|
|
const row = this.db.prepare(`SELECT authoritative_title FROM chat_sessions
|
|
const row = this.db.prepare(`SELECT authoritative_title FROM chat_sessions
|
|
|
WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`).get(owner, profile, id) as { authoritative_title?: string | null } | undefined
|
|
WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`).get(owner, profile, id) as { authoritative_title?: string | null } | undefined
|
|
|
const owned = Boolean(row)
|
|
const owned = Boolean(row)
|
|
|
- const title = String(row?.authoritative_title ?? '').trim()
|
|
|
|
|
|
|
+ const local=this.db.prepare('SELECT title FROM chat_local_state WHERE owner=? AND profile=? AND session_id=?').get(owner,profile,id) as {title?:string}|undefined
|
|
|
|
|
+ const title = String(local?.title ?? row?.authoritative_title ?? '').trim()
|
|
|
const body = session
|
|
const body = session
|
|
|
? Buffer.from(JSON.stringify({
|
|
? Buffer.from(JSON.stringify({
|
|
|
...payload,
|
|
...payload,
|
|
@@ -928,7 +1135,92 @@ export class ChatCacheCoordinator {
|
|
|
private reconciling = new Map<string, Promise<void>>()
|
|
private reconciling = new Map<string, Promise<void>>()
|
|
|
onSynchronized: (owner: string, profile: string, sessionID: string) => void = () => {}
|
|
onSynchronized: (owner: string, profile: string, sessionID: string) => void = () => {}
|
|
|
constructor(readonly store: ChatCacheStore, readonly upstream: UpstreamServiceSession,
|
|
constructor(readonly store: ChatCacheStore, readonly upstream: UpstreamServiceSession,
|
|
|
- readonly mode: ChatCacheMode = 'prefer-local') {}
|
|
|
|
|
|
|
+ readonly mode: ChatCacheMode = 'prefer-local') {
|
|
|
|
|
+ const timer=setTimeout(()=>{
|
|
|
|
|
+ this.retryTimers.delete(timer)
|
|
|
|
|
+ if(this.store.isClosed||this.stopped)return
|
|
|
|
|
+ void this.restoreReadPositions()
|
|
|
|
|
+ for(const row of this.store.db.prepare('SELECT * FROM chat_recovery_jobs').all() as any[])this.schedule(row.owner,row.profile,row.session_id,!!row.force)
|
|
|
|
|
+ },0);timer.unref();this.retryTimers.add(timer)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ readonly diagnostics = {localReads:0, recoveryAttempts:0, recoveryFailures:0, recoveryCompleted:0}
|
|
|
|
|
+ private async restoreReadPositions():Promise<void>{
|
|
|
|
|
+ if(this.stopped||this.store.isClosed)return
|
|
|
|
|
+ const scopes=this.store.db.prepare('SELECT DISTINCT owner,profile FROM chat_local_state WHERE read_initialized=0').all() as {owner:string;profile:string}[]
|
|
|
|
|
+ let failed=false
|
|
|
|
|
+ for(let i=0;i<scopes.length;i+=2)await Promise.all(scopes.slice(i,i+2).map(async({owner,profile})=>{
|
|
|
|
|
+ if(this.stopped||this.store.isClosed)return
|
|
|
|
|
+ try{
|
|
|
|
|
+ const response=await this.upstream.request('/api/session-unread',{search:new URLSearchParams({profile})})
|
|
|
|
|
+ if(this.stopped||this.store.isClosed)return
|
|
|
|
|
+ if(response.status>=500)throw new Error('Read-position import unavailable')
|
|
|
|
|
+ const payload=parseResponse(response),rows=Array.isArray(payload?.sessions)?payload.sessions:[]
|
|
|
|
|
+ this.store.db.exec('SAVEPOINT read_position_import')
|
|
|
|
|
+ try{
|
|
|
|
|
+ for(const raw of rows){const r=object(raw);if(!r)continue;const id=String(r.session_id??r.sessionId??'')
|
|
|
|
|
+ const count=Math.max(0,Number(r.read_message_count??r.readMessageCount??0)||0)
|
|
|
|
|
+ this.store.db.prepare('UPDATE chat_local_state SET read_count=?,read_initialized=1 WHERE owner=? AND profile=? AND session_id=? AND read_initialized=0').run(count,owner,profile,id)
|
|
|
|
|
+ }
|
|
|
|
|
+ this.store.db.prepare(`UPDATE chat_local_state SET read_count=COALESCE((SELECT message_total FROM chat_sessions s WHERE s.owner=chat_local_state.owner AND s.profile=chat_local_state.profile AND s.session_id=chat_local_state.session_id),0),read_initialized=1 WHERE owner=? AND profile=? AND read_initialized=0`).run(owner,profile)
|
|
|
|
|
+ this.store.db.exec('RELEASE read_position_import')
|
|
|
|
|
+ }catch(e){this.store.db.exec('ROLLBACK TO read_position_import; RELEASE read_position_import');throw e}
|
|
|
|
|
+ }catch{failed=true}
|
|
|
|
|
+ }))
|
|
|
|
|
+ if(failed&&!this.stopped&&!this.store.isClosed){const timer=setTimeout(()=>{this.retryTimers.delete(timer);void this.restoreReadPositions()},30_000);timer.unref();this.retryTimers.add(timer)}
|
|
|
|
|
+ }
|
|
|
|
|
+ private jobs = new Map<string,{owner:string;profile:string;id:string;force:boolean;attempt:number}>()
|
|
|
|
|
+ private activeJobs = new Set<string>()
|
|
|
|
|
+ private retryTimers = new Set<ReturnType<typeof setTimeout>>()
|
|
|
|
|
+ private stopped = false
|
|
|
|
|
+ close(): void {this.stopped=true;for(const timer of this.retryTimers)clearTimeout(timer);this.retryTimers.clear();this.jobs.clear()}
|
|
|
|
|
+
|
|
|
|
|
+ schedule(owner:string,profile:string,id:string,force=false): void {
|
|
|
|
|
+ if(this.stopped||this.store.isClosed||!this.store.ownsSession(owner,profile,id))return
|
|
|
|
|
+ const key=JSON.stringify([owner,profile,id]),previous=this.jobs.get(key)
|
|
|
|
|
+ if(previous){previous.force ||= force;if(force)this.store.db.prepare('UPDATE chat_recovery_jobs SET force=1 WHERE owner=? AND profile=? AND session_id=?').run(owner,profile,id);return}
|
|
|
|
|
+ this.store.db.prepare('INSERT INTO chat_recovery_jobs VALUES(?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET force=MAX(force,excluded.force)').run(owner,profile,id,force?1:0)
|
|
|
|
|
+ this.jobs.set(key,{owner,profile,id,force,attempt:0})
|
|
|
|
|
+ const timer=setTimeout(()=>{this.retryTimers.delete(timer);this.drain()},0);timer.unref();this.retryTimers.add(timer)
|
|
|
|
|
+ }
|
|
|
|
|
+ private drain(): void {
|
|
|
|
|
+ if(this.stopped||this.store.isClosed)return
|
|
|
|
|
+ for(const [key,job] of this.jobs){
|
|
|
|
|
+ if(this.activeJobs.size>=2)break
|
|
|
|
|
+ if(this.activeJobs.has(key)||job.attempt<0)continue
|
|
|
|
|
+ this.activeJobs.add(key)
|
|
|
|
|
+ this.diagnostics.recoveryAttempts++
|
|
|
|
|
+ const force=job.force;job.force=false
|
|
|
|
|
+ void this.reconcile(job.owner,job.profile,job.id,force).then(()=>{
|
|
|
|
|
+ if(this.stopped||this.store.isClosed)return
|
|
|
|
|
+ if(job.force){job.attempt=0;return}
|
|
|
|
|
+ this.diagnostics.recoveryCompleted++
|
|
|
|
|
+ this.jobs.delete(key)
|
|
|
|
|
+ this.store.db.prepare('DELETE FROM chat_recovery_jobs WHERE owner=? AND profile=? AND session_id=?').run(job.owner,job.profile,job.id)
|
|
|
|
|
+ }).catch(()=>{
|
|
|
|
|
+ if(this.stopped||this.store.isClosed)return
|
|
|
|
|
+ this.diagnostics.recoveryFailures++
|
|
|
|
|
+ job.force ||= force
|
|
|
|
|
+ const attempt=Math.abs(job.attempt)+1;job.attempt=-attempt
|
|
|
|
|
+ const timer=setTimeout(()=>{this.retryTimers.delete(timer);job.attempt=attempt;this.drain()},Math.min(30_000,1000*2**Math.min(attempt-1,5)))
|
|
|
|
|
+ timer.unref();this.retryTimers.add(timer)
|
|
|
|
|
+ }).finally(()=>{this.activeJobs.delete(key);this.drain()})
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ readLocal(owner:string,kind:'list'|'detail'|'messages',profile:string,id:string|undefined,
|
|
|
|
|
+ page:{offset?:number;limit?:number;archived?:string},force=false): {response:UpstreamResponse;source:ChatCacheSource;state:ChatCacheState} {
|
|
|
|
|
+ this.diagnostics.localReads++
|
|
|
|
|
+ if(id)id=this.store.canonicalSessionID(owner,profile,id)
|
|
|
|
|
+ if(kind==='list'){
|
|
|
|
|
+ for(const row of this.store.missingSessions(owner,profile)) this.schedule(owner,row.profile,row.session_id,force)
|
|
|
|
|
+ return {response:this.store.localList(owner,profile,page),source:'local',state:this.store.needsListMetadata(owner,profile)?'syncing':'current'}
|
|
|
|
|
+ }
|
|
|
|
|
+ this.store.requireOwned(owner,profile,id!)
|
|
|
|
|
+ const local=kind==='detail'?this.store.localDetail(owner,profile,id!):this.store.messagePage(owner,profile,id!,page.offset??0,page.limit??100)
|
|
|
|
|
+ if(force||!local||local.state==='gap')this.schedule(owner,profile,id!,force)
|
|
|
|
|
+ if(!local)throw new HttpError(409,'历史正在后台补齐,请稍后重试','CHAT_HISTORY_SYNC_PENDING')
|
|
|
|
|
+ return {response:local.response,source:'local',state:this.jobs.has(JSON.stringify([owner,profile,id]))?'syncing':local.state}
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
async read(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
|
|
async read(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
|
|
|
load: () => Promise<UpstreamResponse>, ownedList = false,
|
|
load: () => Promise<UpstreamResponse>, ownedList = false,
|
|
@@ -994,8 +1286,9 @@ export class ChatCacheCoordinator {
|
|
|
|
|
|
|
|
observe(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): void {
|
|
observe(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): void {
|
|
|
if (!this.store.recordEvent(owner, profile, sessionID, frame)) return
|
|
if (!this.store.recordEvent(owner, profile, sessionID, frame)) return
|
|
|
- if (['message.complete', 'gateway.reset'].includes(String(frame.type))) {
|
|
|
|
|
- void this.reconcile(owner, profile, sessionID).catch(() => {})
|
|
|
|
|
|
|
+ if (String(frame.type).includes('reset') || this.store.localDetail(owner,profile,sessionID)?.state==='gap'
|
|
|
|
|
+ || (['message.complete','run.completed'].includes(String(frame.type)) && this.store.localDetail(owner,profile,sessionID)?.state!=='current')) {
|
|
|
|
|
+ this.schedule(owner, profile, sessionID)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1013,11 +1306,13 @@ export class ChatCacheCoordinator {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async reconcile(owner: string, profile: string, sessionID: string, force = false): Promise<void> {
|
|
async reconcile(owner: string, profile: string, sessionID: string, force = false): Promise<void> {
|
|
|
- if (this.mode === 'upstream-only' || !this.store.ownsSession(owner, profile, sessionID)) return
|
|
|
|
|
|
|
+ if(this.store.isClosed)return
|
|
|
|
|
+ sessionID=this.store.canonicalSessionID(owner,profile,sessionID)
|
|
|
|
|
+ if (this.stopped || this.store.isClosed || !this.store.ownsSession(owner, profile, sessionID)) return
|
|
|
const key = JSON.stringify([owner, profile, sessionID])
|
|
const key = JSON.stringify([owner, profile, sessionID])
|
|
|
const pending = this.reconciling.get(key)
|
|
const pending = this.reconciling.get(key)
|
|
|
if (pending) {
|
|
if (pending) {
|
|
|
- await pending
|
|
|
|
|
|
|
+ await pending.catch(() => {})
|
|
|
if (force || this.store.localDetail(owner, profile, sessionID)?.state !== 'current') {
|
|
if (force || this.store.localDetail(owner, profile, sessionID)?.state !== 'current') {
|
|
|
return this.reconcile(owner, profile, sessionID, force)
|
|
return this.reconcile(owner, profile, sessionID, force)
|
|
|
}
|
|
}
|
|
@@ -1030,6 +1325,7 @@ export class ChatCacheCoordinator {
|
|
|
const detailPath = `/api/sessions/${encodeURIComponent(sessionID)}`
|
|
const detailPath = `/api/sessions/${encodeURIComponent(sessionID)}`
|
|
|
const detailStartedAt = Date.now()
|
|
const detailStartedAt = Date.now()
|
|
|
const detail = await this.upstream.request(detailPath, { search: new URLSearchParams({ profile }), cache: 'reload' })
|
|
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 (detail.status !== 200) throw new Error('Unable to synchronize conversation metadata')
|
|
|
const detailPayload = parseResponse(detail) ?? {}
|
|
const detailPayload = parseResponse(detail) ?? {}
|
|
|
const detailSummary = object(detailPayload.session) ?? detailPayload
|
|
const detailSummary = object(detailPayload.session) ?? detailPayload
|
|
@@ -1045,6 +1341,7 @@ export class ChatCacheCoordinator {
|
|
|
const search = new URLSearchParams({ profile, offset: String(offset), limit: '100', order: 'latest', include_compacted: 'true' })
|
|
const search = new URLSearchParams({ profile, offset: String(offset), limit: '100', order: 'latest', include_compacted: 'true' })
|
|
|
const startedAt = Date.now()
|
|
const startedAt = Date.now()
|
|
|
const response = await this.upstream.request(`${detailPath}/messages`, { search, cache: 'reload' })
|
|
const response = await this.upstream.request(`${detailPath}/messages`, { search, cache: 'reload' })
|
|
|
|
|
+ if (this.stopped || this.store.isClosed) return
|
|
|
const payload = parseResponse(response)
|
|
const payload = parseResponse(response)
|
|
|
if (response.status !== 200 || !Array.isArray(payload?.messages)) throw new Error('Unable to synchronize conversation messages')
|
|
if (response.status !== 200 || !Array.isArray(payload?.messages)) throw new Error('Unable to synchronize conversation messages')
|
|
|
const messages = payload.messages.flatMap(value => object(value) ? [object(value)!] : [])
|
|
const messages = payload.messages.flatMap(value => object(value) ? [object(value)!] : [])
|
|
@@ -1093,7 +1390,7 @@ export class ChatCacheCoordinator {
|
|
|
if (!this.store.applySync(owner, profile, sessionID, revision, pages, force)) {
|
|
if (!this.store.applySync(owner, profile, sessionID, revision, pages, force)) {
|
|
|
// Preserve the invalidation when a newer event arrived during the read.
|
|
// Preserve the invalidation when a newer event arrived during the read.
|
|
|
// Its completion joins this flight and schedules the next tail read.
|
|
// Its completion joins this flight and schedules the next tail read.
|
|
|
- if (force) throw new Error('Conversation changed during refresh; retry after the reply completes')
|
|
|
|
|
|
|
+ throw new Error('Conversation changed during refresh; retry after the reply completes')
|
|
|
} else {
|
|
} else {
|
|
|
this.onSynchronized(owner, profile, sessionID)
|
|
this.onSynchronized(owner, profile, sessionID)
|
|
|
}
|
|
}
|