chatTranscriptApi.ts 5.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
  1. import Router from '@koa/router'
  2. import type Koa from 'koa'
  3. import type { ChatCacheCoordinator } from './chatCache.js'
  4. import type { LocalAuthStore } from './localAuth.js'
  5. import { HttpError } from './errors.js'
  6. import { CHAT_TRANSCRIPT_FEATURE } from '../shared/chatTranscript.js'
  7. export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthStore):Router {
  8. const router=new Router(),store=cache.store,transcripts=store.transcripts
  9. const scope=(ctx:Koa.Context)=>{
  10. const owner=auth.require(ctx).id,profile=String(ctx.query.profile??'default'),requested=ctx.params.id
  11. if(!profile||profile.length>256||!requested||requested.length>256)throw new HttpError(400,'会话标识无效','invalid_session')
  12. const id=store.canonicalSessionID(owner,profile,requested)
  13. store.requireOwned(owner,profile,id)
  14. if(!auth.canUseSource(owner,'local',profile))throw new HttpError(403,'没有此 Profile 的权限','profile_forbidden')
  15. ctx.set('Cache-Control','no-store')
  16. return {owner,profile,id}
  17. }
  18. 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}})
  19. router.get('/api/app/chat/sessions/:id/snapshot',ctx=>{
  20. const {owner,profile,id}=scope(ctx)
  21. const before=ctx.query.before===undefined?Number.MAX_SAFE_INTEGER:Number(ctx.query.before),limit=ctx.query.limit===undefined?150:Number(ctx.query.limit)
  22. if(!Number.isSafeInteger(before)||before<1||!Number.isSafeInteger(limit)||limit<1||limit>500)throw new HttpError(400,'历史分页参数无效','invalid_page')
  23. const total=Number(JSON.parse(store.localDetail(owner,profile,id)!.response.body.toString()).message_count??0)
  24. const offset=before===Number.MAX_SAFE_INTEGER?0:Math.max(0,total-(before-1))
  25. 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')}
  26. cache.readLocal(owner,'messages',profile,id,{limit:1})
  27. store.db.exec('SAVEPOINT transcript_snapshot')
  28. try{
  29. transcripts.seed(owner,profile,id)
  30. const detail=store.localDetail(owner,profile,id)!
  31. ctx.body=transcripts.snapshot(owner,profile,id,JSON.parse(detail.response.body.toString()),detail.state,before,limit)
  32. store.db.exec('RELEASE transcript_snapshot')
  33. }catch(error){store.db.exec('ROLLBACK TO transcript_snapshot; RELEASE transcript_snapshot');throw error}
  34. })
  35. router.get('/api/app/chat/sessions/:id/events',ctx=>{
  36. const {owner,profile,id}=scope(ctx),version=auth.pushAuthorizationVersion(owner)
  37. const raw=ctx.get('last-event-id')||String(ctx.query.after??''),parts=raw.split(':')
  38. let cursor=Number(parts.at(-1)),closed=false,lastFlush=0
  39. 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')
  40. const res=ctx.res
  41. let timer:ReturnType<typeof setTimeout>|undefined,heartbeat:ReturnType<typeof setInterval>|undefined,off=()=>{}
  42. 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)
  43. const close=()=>{if(closed)return;closed=true;clearTimeout(timer);clearInterval(heartbeat);off();if(!res.writableEnded)res.end()}
  44. const write=(value:string)=>{
  45. if(closed||res.destroyed||!valid()){close();return false}
  46. if(res.writableLength+Buffer.byteLength(value)>4*1024*1024){res.write('event: reset\ndata: {}\n\n');close();return false}
  47. res.write(value);return true
  48. }
  49. const flush=()=>{
  50. clearTimeout(timer);timer=undefined
  51. if(!valid()){close();return}
  52. const events=transcripts.events(owner,profile,id,cursor)
  53. for(const event of events){if(!write(`id: ${transcripts.epoch}:${event.cursor}\nevent: transcript\ndata: ${JSON.stringify(event)}\n\n`))return;cursor=event.cursor}
  54. if(events.length)lastFlush=Date.now()
  55. if(events.length===250)timer=setTimeout(flush,0)
  56. }
  57. const changed=()=>{
  58. if(closed)return
  59. const events=transcripts.events(owner,profile,id,cursor)
  60. if(!events.length)return
  61. if(events.some(event=>event.type!=='message.patch')||Date.now()-lastFlush>=33)flush()
  62. else if(timer===undefined)timer=setTimeout(flush,Math.max(0,33-(Date.now()-lastFlush)))
  63. }
  64. ctx.respond=false
  65. res.writeHead(200,{'Content-Type':'text/event-stream; charset=utf-8','Cache-Control':'no-store','X-Accel-Buffering':'no'})
  66. res.flushHeaders();res.once('close',close);res.once('error',close)
  67. off=transcripts.subscribe(changed)
  68. flush();write(`event: ready\ndata: ${JSON.stringify({epoch:transcripts.epoch,cursor})}\n\n`)
  69. heartbeat=setInterval(()=>{if(valid())write(': heartbeat\n\n');else close()},15_000);heartbeat.unref()
  70. })
  71. return router
  72. }