@tanstack/ai-devtools-core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/dist/esm/components/ConversationDetails.d.ts +2 -0
  4. package/dist/esm/components/ConversationDetails.js +134 -0
  5. package/dist/esm/components/ConversationDetails.js.map +1 -0
  6. package/dist/esm/components/ConversationsList.d.ts +2 -0
  7. package/dist/esm/components/ConversationsList.js +30 -0
  8. package/dist/esm/components/ConversationsList.js.map +1 -0
  9. package/dist/esm/components/Shell.d.ts +1 -0
  10. package/dist/esm/components/Shell.js +94 -0
  11. package/dist/esm/components/Shell.js.map +1 -0
  12. package/dist/esm/components/conversation/ChunkBadges.d.ts +7 -0
  13. package/dist/esm/components/conversation/ChunkBadges.js +54 -0
  14. package/dist/esm/components/conversation/ChunkBadges.js.map +1 -0
  15. package/dist/esm/components/conversation/ChunkItem.d.ts +9 -0
  16. package/dist/esm/components/conversation/ChunkItem.js +247 -0
  17. package/dist/esm/components/conversation/ChunkItem.js.map +1 -0
  18. package/dist/esm/components/conversation/ChunksCollapsible.d.ts +7 -0
  19. package/dist/esm/components/conversation/ChunksCollapsible.js +77 -0
  20. package/dist/esm/components/conversation/ChunksCollapsible.js.map +1 -0
  21. package/dist/esm/components/conversation/ChunksTab.d.ts +8 -0
  22. package/dist/esm/components/conversation/ChunksTab.js +87 -0
  23. package/dist/esm/components/conversation/ChunksTab.js.map +1 -0
  24. package/dist/esm/components/conversation/ConversationHeader.d.ts +7 -0
  25. package/dist/esm/components/conversation/ConversationHeader.js +187 -0
  26. package/dist/esm/components/conversation/ConversationHeader.js.map +1 -0
  27. package/dist/esm/components/conversation/ConversationTabs.d.ts +10 -0
  28. package/dist/esm/components/conversation/ConversationTabs.js +88 -0
  29. package/dist/esm/components/conversation/ConversationTabs.js.map +1 -0
  30. package/dist/esm/components/conversation/EmbeddingsTab.d.ts +7 -0
  31. package/dist/esm/components/conversation/EmbeddingsTab.js +90 -0
  32. package/dist/esm/components/conversation/EmbeddingsTab.js.map +1 -0
  33. package/dist/esm/components/conversation/MessageCard.d.ts +7 -0
  34. package/dist/esm/components/conversation/MessageCard.js +152 -0
  35. package/dist/esm/components/conversation/MessageCard.js.map +1 -0
  36. package/dist/esm/components/conversation/MessageGroup.d.ts +15 -0
  37. package/dist/esm/components/conversation/MessageGroup.js +119 -0
  38. package/dist/esm/components/conversation/MessageGroup.js.map +1 -0
  39. package/dist/esm/components/conversation/MessagesTab.d.ts +7 -0
  40. package/dist/esm/components/conversation/MessagesTab.js +37 -0
  41. package/dist/esm/components/conversation/MessagesTab.js.map +1 -0
  42. package/dist/esm/components/conversation/SummariesTab.d.ts +7 -0
  43. package/dist/esm/components/conversation/SummariesTab.js +110 -0
  44. package/dist/esm/components/conversation/SummariesTab.js.map +1 -0
  45. package/dist/esm/components/conversation/ToolCallDisplay.d.ts +7 -0
  46. package/dist/esm/components/conversation/ToolCallDisplay.js +123 -0
  47. package/dist/esm/components/conversation/ToolCallDisplay.js.map +1 -0
  48. package/dist/esm/components/conversation/index.d.ts +7 -0
  49. package/dist/esm/components/list/ConversationRow.d.ts +7 -0
  50. package/dist/esm/components/list/ConversationRow.js +122 -0
  51. package/dist/esm/components/list/ConversationRow.js.map +1 -0
  52. package/dist/esm/components/list/index.d.ts +1 -0
  53. package/dist/esm/components/utils/format.d.ts +6 -0
  54. package/dist/esm/components/utils/format.js +68 -0
  55. package/dist/esm/components/utils/format.js.map +1 -0
  56. package/dist/esm/components/utils/index.d.ts +1 -0
  57. package/dist/esm/core.d.ts +18 -0
  58. package/dist/esm/core.js +9 -0
  59. package/dist/esm/core.js.map +1 -0
  60. package/dist/esm/index.d.ts +9 -0
  61. package/dist/esm/index.js +6 -0
  62. package/dist/esm/index.js.map +1 -0
  63. package/dist/esm/production.d.ts +2 -0
  64. package/dist/esm/production.js +5 -0
  65. package/dist/esm/production.js.map +1 -0
  66. package/dist/esm/store/ai-context.d.ts +126 -0
  67. package/dist/esm/store/ai-context.js +1081 -0
  68. package/dist/esm/store/ai-context.js.map +1 -0
  69. package/dist/esm/store/ai-store.d.ts +1 -0
  70. package/dist/esm/styles/tokens.d.ts +298 -0
  71. package/dist/esm/styles/tokens.js +107 -0
  72. package/dist/esm/styles/tokens.js.map +1 -0
  73. package/dist/esm/styles/use-styles.d.ts +226 -0
  74. package/dist/esm/styles/use-styles.js +1538 -0
  75. package/dist/esm/styles/use-styles.js.map +1 -0
  76. package/package.json +61 -0
  77. package/src/components/ConversationDetails.tsx +89 -0
  78. package/src/components/ConversationsList.tsx +21 -0
  79. package/src/components/Shell.tsx +93 -0
  80. package/src/components/conversation/ChunkBadges.tsx +51 -0
  81. package/src/components/conversation/ChunkItem.tsx +273 -0
  82. package/src/components/conversation/ChunksCollapsible.tsx +58 -0
  83. package/src/components/conversation/ChunksTab.tsx +85 -0
  84. package/src/components/conversation/ConversationHeader.tsx +130 -0
  85. package/src/components/conversation/ConversationTabs.tsx +104 -0
  86. package/src/components/conversation/EmbeddingsTab.tsx +80 -0
  87. package/src/components/conversation/MessageCard.tsx +137 -0
  88. package/src/components/conversation/MessageGroup.tsx +101 -0
  89. package/src/components/conversation/MessagesTab.tsx +30 -0
  90. package/src/components/conversation/SummariesTab.tsx +91 -0
  91. package/src/components/conversation/ToolCallDisplay.tsx +99 -0
  92. package/src/components/conversation/index.ts +8 -0
  93. package/src/components/list/ConversationRow.tsx +85 -0
  94. package/src/components/list/index.ts +1 -0
  95. package/src/components/utils/format.ts +61 -0
  96. package/src/components/utils/index.ts +1 -0
  97. package/src/core.tsx +10 -0
  98. package/src/index.ts +8 -0
  99. package/src/production.ts +3 -0
  100. package/src/store/ai-context.tsx +1784 -0
  101. package/src/store/ai-store.ts +2 -0
  102. package/src/styles/tokens.ts +305 -0
  103. package/src/styles/use-styles.ts +1537 -0
@@ -0,0 +1,1784 @@
1
+ import { batch, createContext, onCleanup, onMount, useContext } from 'solid-js'
2
+ import { createStore, produce } from 'solid-js/store'
3
+ import { aiEventClient } from '@tanstack/ai/event-client'
4
+ import type { ParentComponent } from 'solid-js'
5
+
6
+ interface MessagePart {
7
+ type: 'text' | 'tool-call' | 'tool-result'
8
+ content?: string
9
+ toolCallId?: string
10
+ toolName?: string
11
+ arguments?: string
12
+ state?: string
13
+ output?: unknown
14
+ error?: string
15
+ }
16
+
17
+ export interface ToolCall {
18
+ id: string
19
+ name: string
20
+ arguments: string
21
+ state: string
22
+ result?: unknown
23
+ approvalRequired?: boolean
24
+ approvalId?: string
25
+ /** Duration of tool execution in milliseconds */
26
+ duration?: number
27
+ }
28
+
29
+ interface TokenUsage {
30
+ promptTokens: number
31
+ completionTokens: number
32
+ totalTokens: number
33
+ }
34
+
35
+ export interface Message {
36
+ id: string
37
+ role: 'user' | 'assistant' | 'system' | 'tool'
38
+ content: string
39
+ timestamp: number
40
+ parts?: Array<MessagePart>
41
+ toolCalls?: Array<ToolCall>
42
+ /** Consolidated chunks - consecutive same-type chunks are merged into one entry */
43
+ chunks?: Array<Chunk>
44
+ /** Total number of raw chunks received (before consolidation) */
45
+ totalChunkCount?: number
46
+ model?: string
47
+ usage?: TokenUsage
48
+ thinkingContent?: string
49
+ /** Source of the message: 'client' for aggregated client-side data, 'server' for individual server chunks */
50
+ source?: 'client' | 'server'
51
+ }
52
+
53
+ /**
54
+ * Consolidated chunk - represents one or more raw chunks of the same type.
55
+ * Consecutive content/thinking chunks are merged into a single entry with accumulated content.
56
+ */
57
+ export interface Chunk {
58
+ id: string
59
+ type:
60
+ | 'content'
61
+ | 'tool_call'
62
+ | 'tool_result'
63
+ | 'done'
64
+ | 'error'
65
+ | 'approval'
66
+ | 'thinking'
67
+ timestamp: number
68
+ messageId?: string
69
+ /** Accumulated content from all merged chunks */
70
+ content?: string
71
+ /** The last delta received (kept for debugging) */
72
+ delta?: string
73
+ toolName?: string
74
+ toolCallId?: string
75
+ finishReason?: string
76
+ error?: string
77
+ approvalId?: string
78
+ input?: unknown
79
+ /** Tool arguments for tool_call chunks */
80
+ arguments?: string
81
+ /** Tool result data for tool_result chunks */
82
+ result?: unknown
83
+ /** Duration in ms for tool execution */
84
+ duration?: number
85
+ /** Number of raw chunks that were merged into this consolidated chunk */
86
+ chunkCount: number
87
+ }
88
+
89
+ export interface EmbeddingOperation {
90
+ id: string
91
+ model: string
92
+ inputCount: number
93
+ duration: number
94
+ timestamp: number
95
+ status: 'started' | 'completed'
96
+ }
97
+
98
+ export interface SummarizeOperation {
99
+ id: string
100
+ model: string
101
+ inputLength: number
102
+ outputLength?: number
103
+ duration?: number
104
+ timestamp: number
105
+ status: 'started' | 'completed'
106
+ }
107
+
108
+ export interface Conversation {
109
+ id: string
110
+ type: 'client' | 'server'
111
+ label: string
112
+ messages: Array<Message>
113
+ chunks: Array<Chunk>
114
+ model?: string
115
+ provider?: string
116
+ status: 'active' | 'completed' | 'error'
117
+ startedAt: number
118
+ completedAt?: number
119
+ usage?: TokenUsage
120
+ iterationCount?: number
121
+ toolNames?: Array<string>
122
+ options?: Record<string, unknown>
123
+ providerOptions?: Record<string, unknown>
124
+ /** Flags for which operation types this conversation has */
125
+ hasChat?: boolean
126
+ hasEmbedding?: boolean
127
+ hasSummarize?: boolean
128
+ /** Embedding operations in this conversation */
129
+ embeddings?: Array<EmbeddingOperation>
130
+ /** Summarize operations in this conversation */
131
+ summaries?: Array<SummarizeOperation>
132
+ }
133
+
134
+ interface AIStoreState {
135
+ conversations: Record<string, Conversation>
136
+ activeConversationId: string | null
137
+ }
138
+
139
+ interface AIContextValue {
140
+ state: AIStoreState
141
+ clearAllConversations: () => void
142
+ selectConversation: (id: string) => void
143
+ }
144
+
145
+ const AIContext = createContext<AIContextValue>()
146
+
147
+ export function useAIStore(): AIContextValue {
148
+ const context = useContext(AIContext)
149
+ if (!context) {
150
+ throw new Error('useAIStore must be used within an AIProvider')
151
+ }
152
+ return context
153
+ }
154
+
155
+ export const AIProvider: ParentComponent = (props) => {
156
+ const [state, setState] = createStore<AIStoreState>({
157
+ conversations: {},
158
+ activeConversationId: null,
159
+ })
160
+
161
+ const streamToConversation = new Map<string, string>()
162
+ const requestToConversation = new Map<string, string>()
163
+
164
+ // Batching system for high-frequency chunk updates with consolidated chunk merging
165
+ // Stores: conversationId -> { chunks to merge, totalNewChunks count }
166
+ const pendingConversationChunks = new Map<
167
+ string,
168
+ { chunks: Array<Chunk>; newChunkCount: number }
169
+ >()
170
+ // Stores: conversationId -> messageIndex -> { chunks to merge, totalNewChunks count }
171
+ const pendingMessageChunks = new Map<
172
+ string,
173
+ Map<number, { chunks: Array<Chunk>; newChunkCount: number }>
174
+ >()
175
+ let batchScheduled = false
176
+
177
+ function scheduleBatchFlush(): void {
178
+ if (batchScheduled) return
179
+ batchScheduled = true
180
+ queueMicrotask(flushPendingChunks)
181
+ }
182
+
183
+ /** Check if a chunk type can be merged with consecutive same-type chunks */
184
+ function isMergeableChunkType(type: Chunk['type']): boolean {
185
+ return type === 'content' || type === 'thinking'
186
+ }
187
+
188
+ /** Merge pending chunks into existing chunks array, consolidating consecutive same-type chunks */
189
+ function mergeChunks(existing: Array<Chunk>, pending: Array<Chunk>): void {
190
+ for (const chunk of pending) {
191
+ const lastChunk = existing[existing.length - 1]
192
+
193
+ // If last chunk exists, is the same type, and both are mergeable types, merge them
194
+ if (
195
+ lastChunk &&
196
+ lastChunk.type === chunk.type &&
197
+ isMergeableChunkType(chunk.type) &&
198
+ lastChunk.messageId === chunk.messageId
199
+ ) {
200
+ // Merge: use the new chunk's content (it's already accumulated), update delta, increment count
201
+ lastChunk.content = chunk.content || lastChunk.content
202
+ lastChunk.delta = chunk.delta
203
+ lastChunk.chunkCount += chunk.chunkCount
204
+ } else {
205
+ // Different type or not mergeable - add as new entry
206
+ existing.push(chunk)
207
+ }
208
+ }
209
+ }
210
+
211
+ function flushPendingChunks(): void {
212
+ batchScheduled = false
213
+
214
+ batch(() => {
215
+ // Flush conversation-level chunks
216
+ for (const [
217
+ conversationId,
218
+ { chunks, newChunkCount },
219
+ ] of pendingConversationChunks) {
220
+ const conv = state.conversations[conversationId]
221
+ if (conv) {
222
+ setState(
223
+ 'conversations',
224
+ conversationId,
225
+ 'chunks',
226
+ produce((arr: Array<Chunk>) => {
227
+ mergeChunks(arr, chunks)
228
+ }),
229
+ )
230
+ }
231
+ }
232
+ pendingConversationChunks.clear()
233
+
234
+ // Flush message-level chunks
235
+ for (const [conversationId, messageMap] of pendingMessageChunks) {
236
+ const conv = state.conversations[conversationId]
237
+ if (!conv) continue
238
+
239
+ for (const [messageIndex, { chunks, newChunkCount }] of messageMap) {
240
+ const message = conv.messages[messageIndex]
241
+ if (message) {
242
+ // Update chunks array with merging
243
+ setState(
244
+ 'conversations',
245
+ conversationId,
246
+ 'messages',
247
+ messageIndex,
248
+ 'chunks',
249
+ produce((arr: Array<Chunk> | undefined) => {
250
+ if (!arr) {
251
+ // First time - just set the chunks (they're already consolidated in pending)
252
+ return chunks
253
+ }
254
+ mergeChunks(arr, chunks)
255
+ return arr
256
+ }),
257
+ )
258
+ // Update total chunk count
259
+ const currentTotal = message.totalChunkCount || 0
260
+ setState(
261
+ 'conversations',
262
+ conversationId,
263
+ 'messages',
264
+ messageIndex,
265
+ 'totalChunkCount',
266
+ currentTotal + newChunkCount,
267
+ )
268
+ }
269
+ }
270
+ }
271
+ pendingMessageChunks.clear()
272
+ })
273
+ }
274
+
275
+ function queueChunk(conversationId: string, chunk: Chunk): void {
276
+ if (!pendingConversationChunks.has(conversationId)) {
277
+ pendingConversationChunks.set(conversationId, {
278
+ chunks: [],
279
+ newChunkCount: 0,
280
+ })
281
+ }
282
+ const pending = pendingConversationChunks.get(conversationId)!
283
+
284
+ // Pre-merge in pending buffer to reduce array operations during flush
285
+ const lastPending = pending.chunks[pending.chunks.length - 1]
286
+ if (
287
+ lastPending &&
288
+ lastPending.type === chunk.type &&
289
+ isMergeableChunkType(chunk.type) &&
290
+ lastPending.messageId === chunk.messageId
291
+ ) {
292
+ // Merge into pending buffer - only append delta (incremental content)
293
+ // Use the new chunk's content as the accumulated content (it's already accumulated)
294
+ lastPending.content = chunk.content || lastPending.content
295
+ lastPending.delta = chunk.delta
296
+ lastPending.chunkCount += chunk.chunkCount
297
+ } else {
298
+ pending.chunks.push(chunk)
299
+ }
300
+ pending.newChunkCount += chunk.chunkCount
301
+ scheduleBatchFlush()
302
+ }
303
+
304
+ function queueMessageChunk(
305
+ conversationId: string,
306
+ messageIndex: number,
307
+ chunk: Chunk,
308
+ ): void {
309
+ if (!pendingMessageChunks.has(conversationId)) {
310
+ pendingMessageChunks.set(conversationId, new Map())
311
+ }
312
+ const messageMap = pendingMessageChunks.get(conversationId)!
313
+ if (!messageMap.has(messageIndex)) {
314
+ messageMap.set(messageIndex, { chunks: [], newChunkCount: 0 })
315
+ }
316
+ const pending = messageMap.get(messageIndex)!
317
+
318
+ // Pre-merge in pending buffer
319
+ const lastPending = pending.chunks[pending.chunks.length - 1]
320
+ if (
321
+ lastPending &&
322
+ lastPending.type === chunk.type &&
323
+ isMergeableChunkType(chunk.type) &&
324
+ lastPending.messageId === chunk.messageId
325
+ ) {
326
+ // Merge into pending buffer - only append delta (incremental content)
327
+ // Use the new chunk's content as the accumulated content (it's already accumulated)
328
+ lastPending.content = chunk.content || lastPending.content
329
+ lastPending.delta = chunk.delta
330
+ lastPending.chunkCount += chunk.chunkCount
331
+ } else {
332
+ pending.chunks.push(chunk)
333
+ }
334
+ pending.newChunkCount += chunk.chunkCount
335
+ scheduleBatchFlush()
336
+ }
337
+
338
+ // Optimized helper functions using path-based updates
339
+ function getOrCreateConversation(
340
+ id: string,
341
+ type: 'client' | 'server',
342
+ label: string,
343
+ ): void {
344
+ if (!state.conversations[id]) {
345
+ setState('conversations', id, {
346
+ id,
347
+ type,
348
+ label,
349
+ messages: [],
350
+ chunks: [],
351
+ status: 'active',
352
+ startedAt: Date.now(),
353
+ })
354
+ if (!state.activeConversationId) {
355
+ setState('activeConversationId', id)
356
+ }
357
+ }
358
+ }
359
+
360
+ function addMessage(conversationId: string, message: Message): void {
361
+ const conv = state.conversations[conversationId]
362
+ if (!conv) return
363
+ setState(
364
+ 'conversations',
365
+ conversationId,
366
+ 'messages',
367
+ conv.messages.length,
368
+ message,
369
+ )
370
+ }
371
+
372
+ function addChunkToMessage(conversationId: string, chunk: Chunk): void {
373
+ const conv = state.conversations[conversationId]
374
+ if (!conv) return
375
+
376
+ if (chunk.messageId) {
377
+ const messageIndex = conv.messages.findIndex(
378
+ (msg) => msg.id === chunk.messageId,
379
+ )
380
+
381
+ if (messageIndex !== -1) {
382
+ queueMessageChunk(conversationId, messageIndex, chunk)
383
+ return
384
+ } else {
385
+ // Create new message with the chunk
386
+ const newMessage: Message = {
387
+ id: chunk.messageId,
388
+ role: 'assistant',
389
+ content: '',
390
+ timestamp: chunk.timestamp,
391
+ model: conv.model,
392
+ chunks: [chunk],
393
+ }
394
+ setState(
395
+ 'conversations',
396
+ conversationId,
397
+ 'messages',
398
+ conv.messages.length,
399
+ newMessage,
400
+ )
401
+ return
402
+ }
403
+ }
404
+
405
+ // Find last assistant message
406
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
407
+ const message = conv.messages[i]
408
+ if (message && message.role === 'assistant') {
409
+ queueMessageChunk(conversationId, i, chunk)
410
+ return
411
+ }
412
+ }
413
+ }
414
+
415
+ function updateMessageUsage(
416
+ conversationId: string,
417
+ messageId: string | undefined,
418
+ cumulativeUsage: TokenUsage,
419
+ ): void {
420
+ const conv = state.conversations[conversationId]
421
+ if (!conv) return
422
+
423
+ // Calculate the sum of usage from all previous messages (excluding the target message)
424
+ let previousPromptTokens = 0
425
+ let previousCompletionTokens = 0
426
+
427
+ // Find the target message index
428
+ let targetMessageIndex = -1
429
+ if (messageId) {
430
+ targetMessageIndex = conv.messages.findIndex(
431
+ (msg) => msg.id === messageId,
432
+ )
433
+ } else {
434
+ // Find last assistant message
435
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
436
+ if (conv.messages[i]?.role === 'assistant') {
437
+ targetMessageIndex = i
438
+ break
439
+ }
440
+ }
441
+ }
442
+
443
+ if (targetMessageIndex === -1) return
444
+
445
+ // Sum up usage from all previous assistant messages
446
+ for (let i = 0; i < targetMessageIndex; i++) {
447
+ const msg = conv.messages[i]
448
+ if (msg?.role === 'assistant' && msg.usage) {
449
+ previousPromptTokens += msg.usage.promptTokens
450
+ previousCompletionTokens += msg.usage.completionTokens
451
+ }
452
+ }
453
+
454
+ // Calculate delta usage for this message
455
+ const deltaUsage: TokenUsage = {
456
+ promptTokens: Math.max(
457
+ 0,
458
+ cumulativeUsage.promptTokens - previousPromptTokens,
459
+ ),
460
+ completionTokens: Math.max(
461
+ 0,
462
+ cumulativeUsage.completionTokens - previousCompletionTokens,
463
+ ),
464
+ totalTokens: 0,
465
+ }
466
+ deltaUsage.totalTokens =
467
+ deltaUsage.promptTokens + deltaUsage.completionTokens
468
+
469
+ setState(
470
+ 'conversations',
471
+ conversationId,
472
+ 'messages',
473
+ targetMessageIndex,
474
+ 'usage',
475
+ deltaUsage,
476
+ )
477
+ }
478
+
479
+ // Public actions
480
+ function clearAllConversations() {
481
+ setState('conversations', {})
482
+ setState('activeConversationId', null)
483
+ streamToConversation.clear()
484
+ requestToConversation.clear()
485
+ pendingConversationChunks.clear()
486
+ pendingMessageChunks.clear()
487
+ }
488
+
489
+ function selectConversation(id: string) {
490
+ setState('activeConversationId', id)
491
+ }
492
+
493
+ // Additional optimized helper functions
494
+ function updateConversation(
495
+ conversationId: string,
496
+ updates: Partial<Conversation>,
497
+ ): void {
498
+ if (!state.conversations[conversationId]) return
499
+ for (const [key, value] of Object.entries(updates)) {
500
+ setState(
501
+ 'conversations',
502
+ conversationId,
503
+ key as keyof Conversation,
504
+ value as Conversation[keyof Conversation],
505
+ )
506
+ }
507
+ }
508
+
509
+ function updateMessage(
510
+ conversationId: string,
511
+ messageIndex: number,
512
+ updates: Partial<Message>,
513
+ ): void {
514
+ const conv = state.conversations[conversationId]
515
+ if (!conv || !conv.messages[messageIndex]) return
516
+ for (const [key, value] of Object.entries(updates)) {
517
+ setState(
518
+ 'conversations',
519
+ conversationId,
520
+ 'messages',
521
+ messageIndex,
522
+ key as keyof Message,
523
+ value as Message[keyof Message],
524
+ )
525
+ }
526
+ }
527
+
528
+ function updateToolCall(
529
+ conversationId: string,
530
+ messageIndex: number,
531
+ toolCallIndex: number,
532
+ updates: Partial<ToolCall>,
533
+ ): void {
534
+ const conv = state.conversations[conversationId]
535
+ if (!conv?.messages[messageIndex]?.toolCalls?.[toolCallIndex]) return
536
+ setState(
537
+ 'conversations',
538
+ conversationId,
539
+ 'messages',
540
+ messageIndex,
541
+ 'toolCalls',
542
+ toolCallIndex,
543
+ produce((tc: ToolCall) => Object.assign(tc, updates)),
544
+ )
545
+ }
546
+
547
+ function setToolCalls(
548
+ conversationId: string,
549
+ messageIndex: number,
550
+ toolCalls: Array<ToolCall>,
551
+ ): void {
552
+ if (!state.conversations[conversationId]?.messages[messageIndex]) return
553
+ setState(
554
+ 'conversations',
555
+ conversationId,
556
+ 'messages',
557
+ messageIndex,
558
+ 'toolCalls',
559
+ toolCalls,
560
+ )
561
+ }
562
+
563
+ function addChunk(conversationId: string, chunk: Chunk): void {
564
+ if (!state.conversations[conversationId]) return
565
+ queueChunk(conversationId, chunk)
566
+ }
567
+
568
+ /**
569
+ * For server conversations, ensure a message exists for the given messageId.
570
+ * This creates a placeholder message that will be updated as chunks arrive.
571
+ */
572
+ function ensureMessageForChunk(
573
+ conversationId: string,
574
+ messageId: string | undefined,
575
+ timestamp: number,
576
+ ): void {
577
+ if (!messageId) return
578
+ const conv = state.conversations[conversationId]
579
+ if (!conv || conv.type === 'client') return
580
+
581
+ // Check if message already exists
582
+ const existingMessage = conv.messages.find((m) => m.id === messageId)
583
+ if (existingMessage) return
584
+
585
+ // Create a new message for this messageId (server-side message)
586
+ addMessage(conversationId, {
587
+ id: messageId,
588
+ role: 'assistant',
589
+ content: '',
590
+ timestamp,
591
+ source: 'server',
592
+ })
593
+ }
594
+
595
+ // Register all event listeners on mount
596
+ onMount(() => {
597
+ const cleanupFns: Array<() => void> = []
598
+
599
+ // ============= Client Events =============
600
+
601
+ cleanupFns.push(
602
+ aiEventClient.on(
603
+ 'client:created',
604
+ (e) => {
605
+ const clientId = e.payload.clientId
606
+ getOrCreateConversation(
607
+ clientId,
608
+ 'client',
609
+ `Client Chat (${clientId.substring(0, 8)})`,
610
+ )
611
+ updateConversation(clientId, { model: undefined, provider: 'Client' })
612
+ },
613
+ { withEventTarget: false },
614
+ ),
615
+ )
616
+
617
+ cleanupFns.push(
618
+ aiEventClient.on(
619
+ 'client:message-sent',
620
+ (e) => {
621
+ const clientId = e.payload.clientId
622
+ if (!state.conversations[clientId]) {
623
+ getOrCreateConversation(
624
+ clientId,
625
+ 'client',
626
+ `Client Chat (${clientId.substring(0, 8)})`,
627
+ )
628
+ }
629
+ addMessage(clientId, {
630
+ id: e.payload.messageId,
631
+ role: 'user',
632
+ content: e.payload.content,
633
+ timestamp: e.payload.timestamp,
634
+ source: 'client',
635
+ })
636
+ updateConversation(clientId, { status: 'active' })
637
+ },
638
+ { withEventTarget: false },
639
+ ),
640
+ )
641
+
642
+ cleanupFns.push(
643
+ aiEventClient.on(
644
+ 'client:message-appended',
645
+ (e) => {
646
+ const clientId = e.payload.clientId
647
+ const role = e.payload.role
648
+
649
+ if (role === 'user') return
650
+ if (!state.conversations[clientId]) return
651
+
652
+ if (role === 'assistant') {
653
+ addMessage(clientId, {
654
+ id: e.payload.messageId,
655
+ role: 'assistant',
656
+ content: e.payload.contentPreview,
657
+ timestamp: e.payload.timestamp,
658
+ source: 'client',
659
+ })
660
+ } else if (role === 'tool') {
661
+ // Tool result message from the LLM
662
+ addMessage(clientId, {
663
+ id: e.payload.messageId,
664
+ role: 'tool',
665
+ content: e.payload.contentPreview,
666
+ timestamp: e.payload.timestamp,
667
+ source: 'client',
668
+ })
669
+ }
670
+ },
671
+ { withEventTarget: false },
672
+ ),
673
+ )
674
+
675
+ cleanupFns.push(
676
+ aiEventClient.on(
677
+ 'client:loading-changed',
678
+ (e) => {
679
+ const clientId = e.payload.clientId
680
+ if (state.conversations[clientId]) {
681
+ updateConversation(clientId, {
682
+ status: e.payload.isLoading ? 'active' : 'completed',
683
+ })
684
+ }
685
+ },
686
+ { withEventTarget: false },
687
+ ),
688
+ )
689
+
690
+ cleanupFns.push(
691
+ aiEventClient.on(
692
+ 'client:stopped',
693
+ (e) => {
694
+ const clientId = e.payload.clientId
695
+ if (state.conversations[clientId]) {
696
+ updateConversation(clientId, {
697
+ status: 'completed',
698
+ completedAt: e.payload.timestamp,
699
+ })
700
+ }
701
+ },
702
+ { withEventTarget: false },
703
+ ),
704
+ )
705
+
706
+ cleanupFns.push(
707
+ aiEventClient.on(
708
+ 'client:messages-cleared',
709
+ (e) => {
710
+ const clientId = e.payload.clientId
711
+ if (state.conversations[clientId]) {
712
+ updateConversation(clientId, {
713
+ messages: [],
714
+ chunks: [],
715
+ usage: undefined,
716
+ })
717
+ }
718
+ },
719
+ { withEventTarget: false },
720
+ ),
721
+ )
722
+
723
+ cleanupFns.push(
724
+ aiEventClient.on(
725
+ 'client:reloaded',
726
+ (e) => {
727
+ const clientId = e.payload.clientId
728
+ const conv = state.conversations[clientId]
729
+ if (conv) {
730
+ updateConversation(clientId, {
731
+ messages: conv.messages.slice(0, e.payload.fromMessageIndex),
732
+ status: 'active',
733
+ })
734
+ }
735
+ },
736
+ { withEventTarget: false },
737
+ ),
738
+ )
739
+
740
+ cleanupFns.push(
741
+ aiEventClient.on(
742
+ 'client:error-changed',
743
+ (e) => {
744
+ const clientId = e.payload.clientId
745
+ if (state.conversations[clientId] && e.payload.error) {
746
+ updateConversation(clientId, { status: 'error' })
747
+ }
748
+ },
749
+ { withEventTarget: false },
750
+ ),
751
+ )
752
+
753
+ cleanupFns.push(
754
+ aiEventClient.on(
755
+ 'client:assistant-message-updated',
756
+ (e) => {
757
+ const clientId = e.payload.clientId
758
+ const messageId = e.payload.messageId
759
+ const content = e.payload.content
760
+
761
+ if (!state.conversations[clientId]) return
762
+
763
+ const conv = state.conversations[clientId]
764
+
765
+ // Find message by ID anywhere in the list, not just the last one
766
+ const messageIndex = conv.messages.findIndex(
767
+ (m: Message) => m.id === messageId,
768
+ )
769
+
770
+ // Only update existing messages, don't create new ones
771
+ // (client:message-appended is responsible for creating messages)
772
+ if (messageIndex >= 0) {
773
+ updateMessage(clientId, messageIndex, {
774
+ content,
775
+ model: conv.model,
776
+ })
777
+ }
778
+ },
779
+ { withEventTarget: false },
780
+ ),
781
+ )
782
+
783
+ cleanupFns.push(
784
+ aiEventClient.on(
785
+ 'client:tool-call-updated',
786
+ (e) => {
787
+ const {
788
+ clientId,
789
+ messageId,
790
+ toolCallId,
791
+ toolName,
792
+ state: toolCallState,
793
+ arguments: args,
794
+ } = e.payload as {
795
+ clientId: string
796
+ messageId: string
797
+ toolCallId: string
798
+ toolName: string
799
+ state: string
800
+ arguments: unknown
801
+ timestamp: number
802
+ }
803
+
804
+ if (!state.conversations[clientId]) return
805
+
806
+ const conv = state.conversations[clientId]
807
+ const messageIndex = conv.messages.findIndex(
808
+ (m: Message) => m.id === messageId,
809
+ )
810
+ if (messageIndex === -1) return
811
+
812
+ const message = conv.messages[messageIndex]
813
+ if (!message) return
814
+
815
+ const toolCalls = message.toolCalls || []
816
+ const existingToolIndex = toolCalls.findIndex(
817
+ (t: ToolCall) => t.id === toolCallId,
818
+ )
819
+
820
+ const toolCall: ToolCall = {
821
+ id: toolCallId,
822
+ name: toolName,
823
+ arguments: JSON.stringify(args, null, 2),
824
+ state: toolCallState,
825
+ }
826
+
827
+ if (existingToolIndex >= 0) {
828
+ updateToolCall(clientId, messageIndex, existingToolIndex, toolCall)
829
+ } else {
830
+ setToolCalls(clientId, messageIndex, [...toolCalls, toolCall])
831
+ }
832
+ },
833
+ { withEventTarget: false },
834
+ ),
835
+ )
836
+
837
+ cleanupFns.push(
838
+ aiEventClient.on(
839
+ 'client:approval-requested',
840
+ (e) => {
841
+ const { clientId, messageId, toolCallId, approvalId } = e.payload
842
+
843
+ if (!state.conversations[clientId]) return
844
+
845
+ const conv = state.conversations[clientId]
846
+ const messageIndex = conv.messages.findIndex(
847
+ (m) => m.id === messageId,
848
+ )
849
+ if (messageIndex === -1) return
850
+
851
+ const message = conv.messages[messageIndex]
852
+ if (!message?.toolCalls) return
853
+
854
+ const toolCallIndex = message.toolCalls.findIndex(
855
+ (t) => t.id === toolCallId,
856
+ )
857
+ if (toolCallIndex === -1) return
858
+
859
+ updateToolCall(clientId, messageIndex, toolCallIndex, {
860
+ approvalRequired: true,
861
+ approvalId,
862
+ state: 'approval-requested',
863
+ })
864
+ },
865
+ { withEventTarget: false },
866
+ ),
867
+ )
868
+
869
+ // ============= Tool Events =============
870
+
871
+ cleanupFns.push(
872
+ aiEventClient.on(
873
+ 'tool:result-added',
874
+ (e) => {
875
+ const {
876
+ clientId,
877
+ toolCallId,
878
+ toolName,
879
+ output,
880
+ state: resultState,
881
+ timestamp,
882
+ } = e.payload
883
+
884
+ if (!state.conversations[clientId]) return
885
+
886
+ const conv = state.conversations[clientId]
887
+
888
+ // Find the message with the tool call and update it
889
+ for (
890
+ let messageIndex = conv.messages.length - 1;
891
+ messageIndex >= 0;
892
+ messageIndex--
893
+ ) {
894
+ const message = conv.messages[messageIndex]
895
+ if (!message?.toolCalls) continue
896
+
897
+ const toolCallIndex = message.toolCalls.findIndex(
898
+ (t: ToolCall) => t.id === toolCallId,
899
+ )
900
+ if (toolCallIndex >= 0) {
901
+ // Update the tool call state
902
+ updateToolCall(clientId, messageIndex, toolCallIndex, {
903
+ result: output,
904
+ state: resultState === 'output-error' ? 'error' : 'complete',
905
+ })
906
+
907
+ // Also add a chunk to show the tool result in the chunks view
908
+ const chunk: Chunk = {
909
+ id: `chunk-tool-result-${toolCallId}-${Date.now()}`,
910
+ type: 'tool_result',
911
+ messageId: message.id,
912
+ toolCallId,
913
+ toolName,
914
+ result: output,
915
+ timestamp,
916
+ chunkCount: 1,
917
+ }
918
+ addChunkToMessage(clientId, chunk)
919
+ return
920
+ }
921
+ }
922
+ },
923
+ { withEventTarget: false },
924
+ ),
925
+ )
926
+
927
+ cleanupFns.push(
928
+ aiEventClient.on(
929
+ 'tool:approval-responded',
930
+ (e) => {
931
+ const { clientId, toolCallId, approved } = e.payload
932
+
933
+ if (!state.conversations[clientId]) return
934
+
935
+ const conv = state.conversations[clientId]
936
+
937
+ for (
938
+ let messageIndex = conv.messages.length - 1;
939
+ messageIndex >= 0;
940
+ messageIndex--
941
+ ) {
942
+ const message = conv.messages[messageIndex]
943
+ if (!message?.toolCalls) continue
944
+
945
+ const toolCallIndex = message.toolCalls.findIndex(
946
+ (t: ToolCall) => t.id === toolCallId,
947
+ )
948
+ if (toolCallIndex >= 0) {
949
+ updateToolCall(clientId, messageIndex, toolCallIndex, {
950
+ state: approved ? 'approved' : 'denied',
951
+ approvalRequired: false,
952
+ })
953
+ return
954
+ }
955
+ }
956
+ },
957
+ { withEventTarget: false },
958
+ ),
959
+ )
960
+
961
+ // ============= Stream Events =============
962
+
963
+ cleanupFns.push(
964
+ aiEventClient.on(
965
+ 'stream:chunk:content',
966
+ (e) => {
967
+ const streamId = e.payload.streamId
968
+ const conversationId = streamToConversation.get(streamId)
969
+ if (!conversationId) return
970
+
971
+ const chunk: Chunk = {
972
+ id: `chunk-${Date.now()}-${Math.random()}`,
973
+ type: 'content',
974
+ messageId: e.payload.messageId,
975
+ content: e.payload.content,
976
+ delta: e.payload.delta,
977
+ timestamp: e.payload.timestamp,
978
+ chunkCount: 1,
979
+ }
980
+
981
+ const conv = state.conversations[conversationId]
982
+ if (conv?.type === 'client') {
983
+ addChunkToMessage(conversationId, chunk)
984
+ } else {
985
+ ensureMessageForChunk(
986
+ conversationId,
987
+ e.payload.messageId,
988
+ e.payload.timestamp,
989
+ )
990
+ addChunk(conversationId, chunk)
991
+ }
992
+ },
993
+ { withEventTarget: false },
994
+ ),
995
+ )
996
+
997
+ cleanupFns.push(
998
+ aiEventClient.on(
999
+ 'stream:chunk:tool-call',
1000
+ (e) => {
1001
+ const streamId = e.payload.streamId
1002
+ const conversationId = streamToConversation.get(streamId)
1003
+ if (!conversationId) return
1004
+
1005
+ const chunk: Chunk = {
1006
+ id: `chunk-${Date.now()}-${Math.random()}`,
1007
+ type: 'tool_call',
1008
+ messageId: e.payload.messageId,
1009
+ toolCallId: e.payload.toolCallId,
1010
+ toolName: e.payload.toolName,
1011
+ arguments: e.payload.arguments,
1012
+ timestamp: e.payload.timestamp,
1013
+ chunkCount: 1,
1014
+ }
1015
+
1016
+ const conv = state.conversations[conversationId]
1017
+ if (conv?.type === 'client') {
1018
+ addChunkToMessage(conversationId, chunk)
1019
+ } else {
1020
+ ensureMessageForChunk(
1021
+ conversationId,
1022
+ e.payload.messageId,
1023
+ e.payload.timestamp,
1024
+ )
1025
+ addChunk(conversationId, chunk)
1026
+ }
1027
+ },
1028
+ { withEventTarget: false },
1029
+ ),
1030
+ )
1031
+
1032
+ cleanupFns.push(
1033
+ aiEventClient.on(
1034
+ 'stream:chunk:tool-result',
1035
+ (e) => {
1036
+ const streamId = e.payload.streamId
1037
+ const conversationId = streamToConversation.get(streamId)
1038
+ if (!conversationId) return
1039
+
1040
+ const chunk: Chunk = {
1041
+ id: `chunk-${Date.now()}-${Math.random()}`,
1042
+ type: 'tool_result',
1043
+ messageId: e.payload.messageId,
1044
+ toolCallId: e.payload.toolCallId,
1045
+ content: e.payload.result,
1046
+ timestamp: e.payload.timestamp,
1047
+ chunkCount: 1,
1048
+ }
1049
+
1050
+ const conv = state.conversations[conversationId]
1051
+ if (conv?.type === 'client') {
1052
+ addChunkToMessage(conversationId, chunk)
1053
+ } else {
1054
+ ensureMessageForChunk(
1055
+ conversationId,
1056
+ e.payload.messageId,
1057
+ e.payload.timestamp,
1058
+ )
1059
+ addChunk(conversationId, chunk)
1060
+ }
1061
+
1062
+ // Also update the toolCalls array with the result
1063
+ if (conv && e.payload.toolCallId) {
1064
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
1065
+ const message = conv.messages[i]
1066
+ if (!message?.toolCalls) continue
1067
+
1068
+ const toolCallIndex = message.toolCalls.findIndex(
1069
+ (t) => t.id === e.payload.toolCallId,
1070
+ )
1071
+ if (toolCallIndex >= 0) {
1072
+ updateToolCall(conversationId, i, toolCallIndex, {
1073
+ result: e.payload.result,
1074
+ state: 'complete',
1075
+ })
1076
+ break
1077
+ }
1078
+ }
1079
+ }
1080
+ },
1081
+ { withEventTarget: false },
1082
+ ),
1083
+ )
1084
+
1085
+ cleanupFns.push(
1086
+ aiEventClient.on(
1087
+ 'stream:chunk:thinking',
1088
+ (e) => {
1089
+ const streamId = e.payload.streamId
1090
+ const conversationId = streamToConversation.get(streamId)
1091
+ if (!conversationId) return
1092
+
1093
+ const chunk: Chunk = {
1094
+ id: `chunk-${Date.now()}-${Math.random()}`,
1095
+ type: 'thinking',
1096
+ messageId: e.payload.messageId,
1097
+ content: e.payload.content,
1098
+ delta: e.payload.delta,
1099
+ timestamp: e.payload.timestamp,
1100
+ chunkCount: 1,
1101
+ }
1102
+
1103
+ const conv = state.conversations[conversationId]
1104
+ if (conv?.type === 'client') {
1105
+ addChunkToMessage(conversationId, chunk)
1106
+
1107
+ if (e.payload.messageId) {
1108
+ const messageIndex = conv.messages.findIndex(
1109
+ (msg) => msg.id === e.payload.messageId,
1110
+ )
1111
+ if (messageIndex !== -1) {
1112
+ updateMessage(conversationId, messageIndex, {
1113
+ thinkingContent: e.payload.content,
1114
+ })
1115
+ }
1116
+ }
1117
+ } else {
1118
+ ensureMessageForChunk(
1119
+ conversationId,
1120
+ e.payload.messageId,
1121
+ e.payload.timestamp,
1122
+ )
1123
+ addChunk(conversationId, chunk)
1124
+ }
1125
+ },
1126
+ { withEventTarget: false },
1127
+ ),
1128
+ )
1129
+
1130
+ cleanupFns.push(
1131
+ aiEventClient.on(
1132
+ 'stream:chunk:done',
1133
+ (e) => {
1134
+ const streamId = e.payload.streamId
1135
+ const conversationId = streamToConversation.get(streamId)
1136
+ if (!conversationId) return
1137
+
1138
+ const chunk: Chunk = {
1139
+ id: `chunk-${Date.now()}-${Math.random()}`,
1140
+ type: 'done',
1141
+ messageId: e.payload.messageId,
1142
+ finishReason: e.payload.finishReason || undefined,
1143
+ timestamp: e.payload.timestamp,
1144
+ chunkCount: 1,
1145
+ }
1146
+
1147
+ if (e.payload.usage) {
1148
+ updateConversation(conversationId, { usage: e.payload.usage })
1149
+ updateMessageUsage(
1150
+ conversationId,
1151
+ e.payload.messageId,
1152
+ e.payload.usage,
1153
+ )
1154
+ }
1155
+
1156
+ const conv = state.conversations[conversationId]
1157
+ if (conv?.type === 'client') {
1158
+ addChunkToMessage(conversationId, chunk)
1159
+ } else {
1160
+ ensureMessageForChunk(
1161
+ conversationId,
1162
+ e.payload.messageId,
1163
+ e.payload.timestamp,
1164
+ )
1165
+ addChunk(conversationId, chunk)
1166
+ }
1167
+
1168
+ updateConversation(conversationId, {
1169
+ status: 'completed',
1170
+ completedAt: e.payload.timestamp,
1171
+ })
1172
+ },
1173
+ { withEventTarget: false },
1174
+ ),
1175
+ )
1176
+
1177
+ cleanupFns.push(
1178
+ aiEventClient.on(
1179
+ 'stream:chunk:error',
1180
+ (e) => {
1181
+ const streamId = e.payload.streamId
1182
+ const conversationId = streamToConversation.get(streamId)
1183
+ if (!conversationId) return
1184
+
1185
+ const chunk: Chunk = {
1186
+ id: `chunk-${Date.now()}-${Math.random()}`,
1187
+ type: 'error',
1188
+ messageId: e.payload.messageId,
1189
+ error: e.payload.error,
1190
+ timestamp: e.payload.timestamp,
1191
+ chunkCount: 1,
1192
+ }
1193
+
1194
+ const conv = state.conversations[conversationId]
1195
+ if (conv?.type === 'client') {
1196
+ addChunkToMessage(conversationId, chunk)
1197
+ } else {
1198
+ ensureMessageForChunk(
1199
+ conversationId,
1200
+ e.payload.messageId,
1201
+ e.payload.timestamp,
1202
+ )
1203
+ addChunk(conversationId, chunk)
1204
+ }
1205
+
1206
+ updateConversation(conversationId, {
1207
+ status: 'error',
1208
+ completedAt: e.payload.timestamp,
1209
+ })
1210
+ },
1211
+ { withEventTarget: false },
1212
+ ),
1213
+ )
1214
+
1215
+ cleanupFns.push(
1216
+ aiEventClient.on(
1217
+ 'stream:ended',
1218
+ (e) => {
1219
+ const streamId = e.payload.streamId
1220
+ const conversationId = streamToConversation.get(streamId)
1221
+ if (!conversationId) return
1222
+
1223
+ updateConversation(conversationId, {
1224
+ status: 'completed',
1225
+ completedAt: e.payload.timestamp,
1226
+ })
1227
+ },
1228
+ { withEventTarget: false },
1229
+ ),
1230
+ )
1231
+
1232
+ cleanupFns.push(
1233
+ aiEventClient.on(
1234
+ 'stream:approval-requested',
1235
+ (e) => {
1236
+ const {
1237
+ streamId,
1238
+ messageId,
1239
+ toolCallId,
1240
+ toolName,
1241
+ input,
1242
+ approvalId,
1243
+ timestamp,
1244
+ } = e.payload
1245
+
1246
+ const conversationId = streamToConversation.get(streamId)
1247
+ if (!conversationId) return
1248
+
1249
+ const conv = state.conversations[conversationId]
1250
+ if (!conv) return
1251
+
1252
+ const chunk: Chunk = {
1253
+ id: `chunk-${Date.now()}-${Math.random()}`,
1254
+ type: 'approval',
1255
+ messageId: messageId,
1256
+ toolCallId,
1257
+ toolName,
1258
+ approvalId,
1259
+ input,
1260
+ timestamp,
1261
+ chunkCount: 1,
1262
+ }
1263
+
1264
+ if (conv.type === 'client') {
1265
+ addChunkToMessage(conversationId, chunk)
1266
+ } else {
1267
+ addChunk(conversationId, chunk)
1268
+ }
1269
+
1270
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
1271
+ const message = conv.messages[i]
1272
+ if (!message) continue
1273
+
1274
+ if (message.role === 'assistant' && message.toolCalls) {
1275
+ const toolCallIndex = message.toolCalls.findIndex(
1276
+ (t: ToolCall) => t.id === toolCallId,
1277
+ )
1278
+ if (toolCallIndex >= 0) {
1279
+ updateToolCall(conversationId, i, toolCallIndex, {
1280
+ approvalRequired: true,
1281
+ approvalId,
1282
+ state: 'approval-requested',
1283
+ })
1284
+ return
1285
+ }
1286
+ }
1287
+ }
1288
+ },
1289
+ { withEventTarget: false },
1290
+ ),
1291
+ )
1292
+
1293
+ // ============= Processor Events =============
1294
+
1295
+ cleanupFns.push(
1296
+ aiEventClient.on(
1297
+ 'processor:text-updated',
1298
+ (e) => {
1299
+ const streamId = e.payload.streamId
1300
+
1301
+ let conversationId = streamToConversation.get(streamId)
1302
+
1303
+ if (!conversationId) {
1304
+ const activeClients = Object.values(state.conversations)
1305
+ .filter((c) => c.type === 'client' && c.status === 'active')
1306
+ .sort((a, b) => b.startedAt - a.startedAt)
1307
+
1308
+ if (activeClients.length > 0 && activeClients[0]) {
1309
+ conversationId = activeClients[0].id
1310
+ streamToConversation.set(streamId, conversationId)
1311
+ }
1312
+ }
1313
+
1314
+ if (!conversationId) return
1315
+
1316
+ const conv = state.conversations[conversationId]
1317
+ if (!conv) return
1318
+
1319
+ // Only update existing assistant messages, don't create new ones
1320
+ // (client:message-appended is responsible for creating messages)
1321
+ const lastMessage = conv.messages[conv.messages.length - 1]
1322
+ if (lastMessage && lastMessage.role === 'assistant') {
1323
+ updateMessage(conversationId, conv.messages.length - 1, {
1324
+ content: e.payload.content,
1325
+ })
1326
+ }
1327
+ },
1328
+ { withEventTarget: false },
1329
+ ),
1330
+ )
1331
+
1332
+ cleanupFns.push(
1333
+ aiEventClient.on(
1334
+ 'processor:tool-call-state-changed',
1335
+ (e) => {
1336
+ const streamId = e.payload.streamId
1337
+ const conversationId = streamToConversation.get(streamId)
1338
+
1339
+ if (!conversationId || !state.conversations[conversationId]) return
1340
+
1341
+ const conv = state.conversations[conversationId]
1342
+ const lastMessage = conv.messages[conv.messages.length - 1]
1343
+
1344
+ if (lastMessage && lastMessage.role === 'assistant') {
1345
+ const toolCalls = lastMessage.toolCalls || []
1346
+ const existingToolIndex = toolCalls.findIndex(
1347
+ (t) => t.id === e.payload.toolCallId,
1348
+ )
1349
+
1350
+ const toolCall: ToolCall = {
1351
+ id: e.payload.toolCallId,
1352
+ name: e.payload.toolName,
1353
+ arguments: JSON.stringify(e.payload.arguments, null, 2),
1354
+ state: e.payload.state,
1355
+ }
1356
+
1357
+ if (existingToolIndex >= 0) {
1358
+ updateToolCall(
1359
+ conversationId,
1360
+ conv.messages.length - 1,
1361
+ existingToolIndex,
1362
+ toolCall,
1363
+ )
1364
+ } else {
1365
+ setToolCalls(conversationId, conv.messages.length - 1, [
1366
+ ...toolCalls,
1367
+ toolCall,
1368
+ ])
1369
+ }
1370
+ }
1371
+ },
1372
+ { withEventTarget: false },
1373
+ ),
1374
+ )
1375
+
1376
+ cleanupFns.push(
1377
+ aiEventClient.on(
1378
+ 'processor:tool-result-state-changed',
1379
+ (e) => {
1380
+ const streamId = e.payload.streamId
1381
+ const conversationId = streamToConversation.get(streamId)
1382
+
1383
+ if (!conversationId || !state.conversations[conversationId]) return
1384
+
1385
+ const conv = state.conversations[conversationId]
1386
+
1387
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
1388
+ const message = conv.messages[i]
1389
+ if (!message?.toolCalls) continue
1390
+
1391
+ const toolCallIndex = message.toolCalls.findIndex(
1392
+ (t) => t.id === e.payload.toolCallId,
1393
+ )
1394
+ if (toolCallIndex >= 0) {
1395
+ updateToolCall(conversationId, i, toolCallIndex, {
1396
+ result: e.payload.content,
1397
+ state: e.payload.error ? 'error' : e.payload.state,
1398
+ })
1399
+ return
1400
+ }
1401
+ }
1402
+ },
1403
+ { withEventTarget: false },
1404
+ ),
1405
+ )
1406
+
1407
+ // ============= Chat Events (for usage tracking) =============
1408
+
1409
+ cleanupFns.push(
1410
+ aiEventClient.on(
1411
+ 'chat:started',
1412
+ (e) => {
1413
+ const streamId = e.payload.streamId
1414
+ const model = e.payload.model
1415
+ const provider = e.payload.provider
1416
+ const clientId = e.payload.clientId
1417
+
1418
+ if (clientId && state.conversations[clientId]) {
1419
+ streamToConversation.set(streamId, clientId)
1420
+ updateConversation(clientId, { status: 'active', ...e.payload })
1421
+ return
1422
+ }
1423
+
1424
+ const activeClient = Object.values(state.conversations).find(
1425
+ (c) => c.type === 'client' && c.status === 'active' && !c.model,
1426
+ )
1427
+
1428
+ if (activeClient) {
1429
+ streamToConversation.set(streamId, activeClient.id)
1430
+ updateConversation(activeClient.id, { ...e.payload })
1431
+ } else {
1432
+ const existingServerConv = Object.values(state.conversations).find(
1433
+ (c) => c.type === 'server' && c.model === model,
1434
+ )
1435
+
1436
+ if (existingServerConv) {
1437
+ streamToConversation.set(streamId, existingServerConv.id)
1438
+ updateConversation(existingServerConv.id, {
1439
+ status: 'active',
1440
+ ...e.payload,
1441
+ })
1442
+ } else {
1443
+ const serverId = `server-${model}`
1444
+ getOrCreateConversation(serverId, 'server', `${model} Server`)
1445
+ streamToConversation.set(streamId, serverId)
1446
+ updateConversation(serverId, { ...e.payload })
1447
+ }
1448
+ }
1449
+ },
1450
+ { withEventTarget: false },
1451
+ ),
1452
+ )
1453
+
1454
+ cleanupFns.push(
1455
+ aiEventClient.on(
1456
+ 'chat:completed',
1457
+ (e) => {
1458
+ const { requestId, usage } = e.payload
1459
+
1460
+ const conversationId = requestToConversation.get(requestId)
1461
+ if (conversationId && state.conversations[conversationId]) {
1462
+ const updates: Partial<Conversation> = {
1463
+ status: 'completed',
1464
+ completedAt: e.payload.timestamp,
1465
+ }
1466
+ if (usage) {
1467
+ updates.usage = usage
1468
+ }
1469
+ updateConversation(conversationId, updates)
1470
+ if (usage) {
1471
+ updateMessageUsage(conversationId, e.payload.messageId, usage)
1472
+ }
1473
+ }
1474
+ },
1475
+ { withEventTarget: false },
1476
+ ),
1477
+ )
1478
+
1479
+ cleanupFns.push(
1480
+ aiEventClient.on(
1481
+ 'chat:iteration',
1482
+ (e) => {
1483
+ const { requestId, iterationNumber } = e.payload
1484
+
1485
+ const conversationId = requestToConversation.get(requestId)
1486
+ if (conversationId && state.conversations[conversationId]) {
1487
+ updateConversation(conversationId, {
1488
+ iterationCount: iterationNumber,
1489
+ })
1490
+ }
1491
+ },
1492
+ { withEventTarget: false },
1493
+ ),
1494
+ )
1495
+
1496
+ cleanupFns.push(
1497
+ aiEventClient.on(
1498
+ 'usage:tokens',
1499
+ (e) => {
1500
+ const { requestId, usage, messageId } = e.payload
1501
+
1502
+ const conversationId = requestToConversation.get(requestId)
1503
+ if (conversationId && state.conversations[conversationId]) {
1504
+ updateConversation(conversationId, { usage })
1505
+ updateMessageUsage(conversationId, messageId, usage)
1506
+ }
1507
+ },
1508
+ { withEventTarget: false },
1509
+ ),
1510
+ )
1511
+
1512
+ // ============= Tool Call Completed (with duration) =============
1513
+
1514
+ cleanupFns.push(
1515
+ aiEventClient.on(
1516
+ 'tool:call-completed',
1517
+ (e) => {
1518
+ const {
1519
+ streamId,
1520
+ toolCallId,
1521
+ toolName,
1522
+ result,
1523
+ duration,
1524
+ messageId,
1525
+ timestamp,
1526
+ } = e.payload
1527
+
1528
+ const conversationId = streamToConversation.get(streamId)
1529
+ if (!conversationId || !state.conversations[conversationId]) return
1530
+
1531
+ const conv = state.conversations[conversationId]
1532
+
1533
+ // Add a tool_result chunk to show the result in the chunks view
1534
+ const chunk: Chunk = {
1535
+ id: `chunk-tool-result-${toolCallId}-${Date.now()}`,
1536
+ type: 'tool_result',
1537
+ messageId: messageId,
1538
+ toolCallId,
1539
+ toolName,
1540
+ result,
1541
+ duration,
1542
+ timestamp,
1543
+ chunkCount: 1,
1544
+ }
1545
+
1546
+ // Add chunk to message if it's a client conversation, otherwise to conversation
1547
+ if (conv.type === 'client' && messageId) {
1548
+ const messageIndex = conv.messages.findIndex(
1549
+ (m) => m.id === messageId,
1550
+ )
1551
+ if (messageIndex !== -1) {
1552
+ queueMessageChunk(conversationId, messageIndex, chunk)
1553
+ } else {
1554
+ // If message not found, add to last assistant message
1555
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
1556
+ if (conv.messages[i]?.role === 'assistant') {
1557
+ queueMessageChunk(conversationId, i, chunk)
1558
+ break
1559
+ }
1560
+ }
1561
+ }
1562
+ } else {
1563
+ addChunk(conversationId, chunk)
1564
+ }
1565
+
1566
+ // Update the tool call with duration
1567
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
1568
+ const message = conv.messages[i]
1569
+ if (!message?.toolCalls) continue
1570
+
1571
+ const toolCallIndex = message.toolCalls.findIndex(
1572
+ (t) => t.id === toolCallId,
1573
+ )
1574
+ if (toolCallIndex >= 0) {
1575
+ updateToolCall(conversationId, i, toolCallIndex, {
1576
+ duration,
1577
+ result,
1578
+ })
1579
+ return
1580
+ }
1581
+ }
1582
+ },
1583
+ { withEventTarget: false },
1584
+ ),
1585
+ )
1586
+
1587
+ // ============= Embedding Events =============
1588
+
1589
+ cleanupFns.push(
1590
+ aiEventClient.on(
1591
+ 'embedding:started',
1592
+ (e) => {
1593
+ const { requestId, model, inputCount, timestamp, clientId } =
1594
+ e.payload
1595
+
1596
+ // Try to find an active conversation to attach to, or create a new one
1597
+ let conversationId = clientId
1598
+ if (!conversationId || !state.conversations[conversationId]) {
1599
+ // Find most recent active client conversation
1600
+ const activeClients = Object.values(state.conversations)
1601
+ .filter((c) => c.type === 'client' && c.status === 'active')
1602
+ .sort((a, b) => b.startedAt - a.startedAt)
1603
+
1604
+ if (activeClients.length > 0 && activeClients[0]) {
1605
+ conversationId = activeClients[0].id
1606
+ } else {
1607
+ // Create a new conversation for embeddings
1608
+ conversationId = `embedding-${requestId}`
1609
+ getOrCreateConversation(
1610
+ conversationId,
1611
+ 'server',
1612
+ `Embedding (${model})`,
1613
+ )
1614
+ updateConversation(conversationId, { model })
1615
+ }
1616
+ }
1617
+
1618
+ requestToConversation.set(requestId, conversationId)
1619
+
1620
+ const embeddingOp: EmbeddingOperation = {
1621
+ id: requestId,
1622
+ model,
1623
+ inputCount,
1624
+ duration: 0,
1625
+ timestamp,
1626
+ status: 'started',
1627
+ }
1628
+
1629
+ const conv = state.conversations[conversationId]
1630
+ if (conv) {
1631
+ const embeddings = conv.embeddings || []
1632
+ setState('conversations', conversationId, 'embeddings', [
1633
+ ...embeddings,
1634
+ embeddingOp,
1635
+ ])
1636
+ setState('conversations', conversationId, 'hasEmbedding', true)
1637
+ }
1638
+ },
1639
+ { withEventTarget: false },
1640
+ ),
1641
+ )
1642
+
1643
+ cleanupFns.push(
1644
+ aiEventClient.on(
1645
+ 'embedding:completed',
1646
+ (e) => {
1647
+ const { requestId, duration } = e.payload
1648
+
1649
+ const conversationId = requestToConversation.get(requestId)
1650
+ if (!conversationId || !state.conversations[conversationId]) return
1651
+
1652
+ const conv = state.conversations[conversationId]
1653
+ if (!conv.embeddings) return
1654
+
1655
+ const embeddingIndex = conv.embeddings.findIndex(
1656
+ (op) => op.id === requestId,
1657
+ )
1658
+ if (embeddingIndex >= 0) {
1659
+ setState(
1660
+ 'conversations',
1661
+ conversationId,
1662
+ 'embeddings',
1663
+ embeddingIndex,
1664
+ produce((op: EmbeddingOperation) => {
1665
+ op.duration = duration
1666
+ op.status = 'completed'
1667
+ }),
1668
+ )
1669
+ }
1670
+ },
1671
+ { withEventTarget: false },
1672
+ ),
1673
+ )
1674
+
1675
+ // ============= Summarize Events =============
1676
+
1677
+ cleanupFns.push(
1678
+ aiEventClient.on(
1679
+ 'summarize:started',
1680
+ (e) => {
1681
+ const { requestId, model, inputLength, timestamp, clientId } =
1682
+ e.payload
1683
+
1684
+ // Try to find an active conversation to attach to, or create a new one
1685
+ let conversationId = clientId
1686
+ if (!conversationId || !state.conversations[conversationId]) {
1687
+ // Find most recent active client conversation
1688
+ const activeClients = Object.values(state.conversations)
1689
+ .filter((c) => c.type === 'client' && c.status === 'active')
1690
+ .sort((a, b) => b.startedAt - a.startedAt)
1691
+
1692
+ if (activeClients.length > 0 && activeClients[0]) {
1693
+ conversationId = activeClients[0].id
1694
+ } else {
1695
+ // Create a new conversation for summaries
1696
+ conversationId = `summarize-${requestId}`
1697
+ getOrCreateConversation(
1698
+ conversationId,
1699
+ 'server',
1700
+ `Summarize (${model})`,
1701
+ )
1702
+ updateConversation(conversationId, { model })
1703
+ }
1704
+ }
1705
+
1706
+ requestToConversation.set(requestId, conversationId)
1707
+
1708
+ const summarizeOp: SummarizeOperation = {
1709
+ id: requestId,
1710
+ model,
1711
+ inputLength,
1712
+ timestamp,
1713
+ status: 'started',
1714
+ }
1715
+
1716
+ const conv = state.conversations[conversationId]
1717
+ if (conv) {
1718
+ const summaries = conv.summaries || []
1719
+ setState('conversations', conversationId, 'summaries', [
1720
+ ...summaries,
1721
+ summarizeOp,
1722
+ ])
1723
+ setState('conversations', conversationId, 'hasSummarize', true)
1724
+ }
1725
+ },
1726
+ { withEventTarget: false },
1727
+ ),
1728
+ )
1729
+
1730
+ cleanupFns.push(
1731
+ aiEventClient.on(
1732
+ 'summarize:completed',
1733
+ (e) => {
1734
+ const { requestId, outputLength, duration } = e.payload
1735
+
1736
+ const conversationId = requestToConversation.get(requestId)
1737
+ if (!conversationId || !state.conversations[conversationId]) return
1738
+
1739
+ const conv = state.conversations[conversationId]
1740
+ if (!conv.summaries) return
1741
+
1742
+ const summaryIndex = conv.summaries.findIndex(
1743
+ (op) => op.id === requestId,
1744
+ )
1745
+ if (summaryIndex >= 0) {
1746
+ setState(
1747
+ 'conversations',
1748
+ conversationId,
1749
+ 'summaries',
1750
+ summaryIndex,
1751
+ produce((op: SummarizeOperation) => {
1752
+ op.duration = duration
1753
+ op.outputLength = outputLength
1754
+ op.status = 'completed'
1755
+ }),
1756
+ )
1757
+ }
1758
+ },
1759
+ { withEventTarget: false },
1760
+ ),
1761
+ )
1762
+
1763
+ // Cleanup all listeners on unmount
1764
+ onCleanup(() => {
1765
+ for (const cleanup of cleanupFns) {
1766
+ cleanup()
1767
+ }
1768
+ streamToConversation.clear()
1769
+ requestToConversation.clear()
1770
+ })
1771
+ })
1772
+
1773
+ const contextValue: AIContextValue = {
1774
+ state,
1775
+ clearAllConversations,
1776
+ selectConversation,
1777
+ }
1778
+
1779
+ return (
1780
+ <AIContext.Provider value={contextValue}>
1781
+ {props.children}
1782
+ </AIContext.Provider>
1783
+ )
1784
+ }