transcriptStore.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
  2. import type { WorkspaceAgent, WorkspaceConversation, WorkspaceEvent, WorkspaceInteraction, WorkspaceMessage, WorkspaceRun, WorkspaceTask } from '@shared/workspace'
  3. export interface WorkspaceDetail {
  4. conversation: WorkspaceConversation
  5. messages: WorkspaceMessage[]
  6. task?: WorkspaceTask | null
  7. tasks?: WorkspaceTask[]
  8. assignments?: AgentAssignment[]
  9. run: WorkspaceRun | null
  10. interactions: WorkspaceInteraction[]
  11. context: Record<string, unknown> | null
  12. hiddenMessageIds?: string[]
  13. hasOlder?: boolean
  14. cursor?: number
  15. }
  16. export interface WorkspaceSnapshot {
  17. agents: WorkspaceAgent[]
  18. conversations: WorkspaceConversation[]
  19. details: WorkspaceDetail[]
  20. cursor: number
  21. }
  22. export const transcriptKey = (id: string, taskId?: string | null) => `${id}:${taskId ?? ''}`
  23. function upsert<T extends { id: string }>(rows: T[], value: T): T[] {
  24. const index = rows.findIndex(row => row.id === value.id)
  25. if (index < 0) return [...rows, value]
  26. const next = rows.slice(); next[index] = value; return next
  27. }
  28. /** Apply only the entity named by an event; settled message objects keep their identity. */
  29. export function foldDetail(detail: WorkspaceDetail, event: WorkspaceEvent): WorkspaceDetail {
  30. if (event.conversationId !== detail.conversation.id) return detail
  31. const data = event.data as Record<string, unknown>
  32. const inTask = (task: unknown) => (task ?? null) === (detail.task?.id ?? null)
  33. switch (event.type) {
  34. case 'conversation.changed': return { ...detail, conversation: event.data as WorkspaceConversation }
  35. case 'message.changed': {
  36. const message = event.data as WorkspaceMessage
  37. if (!inTask(message.conversationTaskId)) return detail
  38. const messages = message.visible === false ? detail.messages.filter(m => m.id !== message.id)
  39. : upsert(detail.messages, message).sort((a, b) => a.seq - b.seq)
  40. return { ...detail, messages }
  41. }
  42. case 'task.changed': {
  43. const task = event.data as WorkspaceTask
  44. return { ...detail, tasks: upsert(detail.tasks ?? [], task), task: detail.task?.id === task.id ? task : detail.task }
  45. }
  46. case 'task.deleted': return { ...detail, tasks: (detail.tasks ?? []).filter(t => t.id !== data.id) }
  47. case 'run.changed': return inTask(data.conversationTaskId) ? { ...detail, run: event.data as WorkspaceRun } : detail
  48. case 'context.changed': return inTask(data.conversationTaskId) ? { ...detail, context: data } : detail
  49. case 'interaction.changed': return inTask(data.conversationTaskId) ? { ...detail, interactions: data.resolved
  50. ? detail.interactions.filter(i => i.id !== data.id) : upsert(detail.interactions, event.data as WorkspaceInteraction) } : detail
  51. case 'assignment.changed': return data.goalId === detail.task?.id
  52. ? { ...detail, assignments: upsert(detail.assignments ?? [], event.data as AgentAssignment) } : detail
  53. case 'goal.changed': return data.id === detail.task?.id
  54. ? { ...detail, task: { ...detail.task!, goal: event.data as AgentGoal } } : detail
  55. default: return detail
  56. }
  57. }
  58. export class WorkspaceTranscriptStore {
  59. agents: WorkspaceAgent[] = []
  60. conversations: WorkspaceConversation[] = []
  61. readonly details = new Map<string, WorkspaceDetail>()
  62. cursor = 0
  63. readonly selectedTasks = new Map<string, string>()
  64. private reads = new Map<symbol, WorkspaceEvent[]>()
  65. beginRead(): symbol { const token = Symbol(); this.reads.set(token, []); return token }
  66. cancelRead(token: symbol): void { this.reads.delete(token) }
  67. finishRead(token: symbol, incoming: WorkspaceDetail, preserveHistory = true): WorkspaceDetail {
  68. const key = transcriptKey(incoming.conversation.id, incoming.task?.id)
  69. const previous = this.details.get(key)
  70. let detail = incoming
  71. if (previous && preserveHistory) {
  72. const messages = new Map(previous.messages.map(message => [message.id, message]))
  73. for (const message of incoming.messages) messages.set(message.id, message)
  74. detail = { ...incoming, hasOlder: previous.hasOlder,
  75. messages: [...messages.values()].filter(m => m.visible !== false && !incoming.hiddenMessageIds?.includes(m.id)).sort((a, b) => a.seq - b.seq) }
  76. }
  77. // A response that started before a live event must never overwrite that event.
  78. for (const event of this.reads.get(token) ?? []) {
  79. if (event.seq <= (incoming.cursor ?? 0)) continue
  80. const id = (event.data as { id?: string }).id
  81. if ((event.type === 'conversation.deleted' && id === incoming.conversation.id)
  82. || (event.type === 'task.deleted' && id === incoming.task?.id)) {
  83. this.reads.delete(token)
  84. throw new Error('会话或任务已被删除')
  85. }
  86. detail = foldDetail(detail, event)
  87. }
  88. this.reads.delete(token)
  89. this.details.set(key, detail)
  90. return detail
  91. }
  92. hydrate(snapshot: WorkspaceSnapshot): void {
  93. this.agents = snapshot.agents; this.conversations = snapshot.conversations; this.cursor = snapshot.cursor
  94. this.details.clear()
  95. for (const detail of snapshot.details) this.details.set(transcriptKey(detail.conversation.id, detail.task?.id), detail)
  96. }
  97. get(id: string, taskId?: string | null): WorkspaceDetail | undefined {
  98. if (taskId) return this.details.get(transcriptKey(id, taskId))
  99. const remembered = this.selectedTasks.get(id)
  100. return (remembered ? this.details.get(transcriptKey(id, remembered)) : undefined)
  101. ?? [...this.details.values()].find(detail => detail.conversation.id === id)
  102. }
  103. apply(event: WorkspaceEvent): void {
  104. if (event.seq <= this.cursor) return
  105. for (const events of this.reads.values()) events.push(event)
  106. const data = event.data as { id: string }
  107. if (event.type === 'agent.changed') this.agents = upsert(this.agents, event.data as WorkspaceAgent)
  108. if (event.type === 'agent.deleted') this.agents = this.agents.filter(a => a.id !== data.id)
  109. if (event.type === 'conversation.changed') this.conversations = upsert(this.conversations, event.data as WorkspaceConversation)
  110. if (event.type === 'conversation.deleted') this.conversations = this.conversations.filter(c => c.id !== data.id)
  111. for (const [key, detail] of this.details) {
  112. if ((event.type === 'conversation.deleted' && detail.conversation.id === data.id)
  113. || (event.type === 'task.deleted' && detail.task?.id === data.id)) this.details.delete(key)
  114. else this.details.set(key, foldDetail(detail, event))
  115. }
  116. this.cursor = event.seq
  117. }
  118. }