diff --git a/.changeset/fluffy-sites-exist.md b/.changeset/fluffy-sites-exist.md new file mode 100644 index 00000000..63a694b2 --- /dev/null +++ b/.changeset/fluffy-sites-exist.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai-devtools-core': patch +'@tanstack/ai': patch +--- + +update event client diff --git a/.github/instructions/copilot-instructions.md b/.github/instructions/copilot-instructions.md index e2e0a3f4..54b05ed3 100644 --- a/.github/instructions/copilot-instructions.md +++ b/.github/instructions/copilot-instructions.md @@ -24,8 +24,7 @@ Write tests for any new functionality. When defining new types, first check if the types exist somewhere and re-use them, do not create new types that are similar to existing ones. When modifying existing functionality, ensure backward compatibility unless there's a strong reason to introduce breaking changes. If breaking changes are necessary, document them clearly in the relevant documentation files. - -When subscribing to an event using `aiEventClient.on` in the devtools packages, always add the option `{ withEventTarget: false }` as the second argument to prevent over-subscriptions in the devtools. + Under no circumstances should casting `as any` be used in the codebase. Always strive to find or create the appropriate type definitions. Avoid casting unless absolutely neccessary, and even then, prefer using `satisfies` for type assertions to maintain type safety. diff --git a/docs/config.json b/docs/config.json index 7cf47840..d75a3b7b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -19,7 +19,7 @@ }, { "label": "Devtools", - "to": "getting-started/devtools" + "to": "getting-started/devtools" } ] }, @@ -62,6 +62,10 @@ "label": "Connection Adapters", "to": "guides/connection-adapters" }, + { + "label": "Observability", + "to": "guides/observability" + }, { "label": "Per-Model Type Safety", "to": "guides/per-model-type-safety" @@ -542,4 +546,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/docs/guides/observability.md b/docs/guides/observability.md new file mode 100644 index 00000000..30c36d20 --- /dev/null +++ b/docs/guides/observability.md @@ -0,0 +1,52 @@ +# Event client + +The `@tanstack/ai` package offers you an event client for observability and debugging purposes. +It's a fully type-safe decoupled event-driven system that emits events whenever they are internally +triggered and you can subscribe to those events for observability. + +Because the same event client is used for both the TanStack Devtools system and observability locally it will work +by subscribing to the event bus and emitting events to/from the event bus into the listeners by default. If you +want to subscribe to events in production as well you need to pass in a third argument to the `on` function, +the `{ withEventTarget: true }` option. + +This will not only emit to the event bus (which is not present in production), but to the current eventTarget that +you will be able to listen to. + +## Server events + +There are both events that happen on the server and on the client, if you want to listen to either side you just need to +subscribe on the server/client respectfully. + +Here is an example for the server: +```ts +import { aiEventClient } from "@tanstack/ai/event-client"; + +// server.ts file or wherever the root of your server is +aiEventClient.on("chat:started", e => { + // implement whatever you need to here +}) +// rest of your server logic +const app = new Server(); +app.get() +``` + +## Client events + +Listening on the client is the same approach, just subscribe to the events: + +```tsx +// App.tsx +import { aiEventClient } from "@tanstack/ai/event-client"; + +const App = () => { + useEffect(() => { + const cleanup = aiEventClient.on("client:tool-call-updated", e => { + // do whatever you need to do + }) + return cleanup; + },[]) + return
+} +``` + + \ No newline at end of file diff --git a/docs/reference/variables/aiEventClient.md b/docs/reference/variables/aiEventClient.md index b7d52031..8c37775a 100644 --- a/docs/reference/variables/aiEventClient.md +++ b/docs/reference/variables/aiEventClient.md @@ -9,4 +9,4 @@ title: aiEventClient const aiEventClient: AiEventClient; ``` -Defined in: [event-client.ts:387](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/event-client.ts#L387) +Defined in: [event-client.ts:307](https://github.com/TanStack/ai/blob/main/packages/typescript/ai/src/event-client.ts#L307) diff --git a/examples/ts-solid-chat/package.json b/examples/ts-solid-chat/package.json index f69e021a..87920223 100644 --- a/examples/ts-solid-chat/package.json +++ b/examples/ts-solid-chat/package.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@solidjs/testing-library": "^0.8.10", - "@tanstack/devtools-event-client": "^0.3.5", + "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/devtools-vite": "^0.3.11", "@testing-library/dom": "^10.4.1", "@types/node": "^24.10.1", diff --git a/packages/typescript/ai-devtools/src/store/ai-context.tsx b/packages/typescript/ai-devtools/src/store/ai-context.tsx index 08e1853c..42af6917 100644 --- a/packages/typescript/ai-devtools/src/store/ai-context.tsx +++ b/packages/typescript/ai-devtools/src/store/ai-context.tsx @@ -599,1165 +599,1029 @@ export const AIProvider: ParentComponent = (props) => { // ============= Client Events ============= cleanupFns.push( - aiEventClient.on( - 'client:created', - (e) => { - const clientId = e.payload.clientId + aiEventClient.on('client:created', (e) => { + const clientId = e.payload.clientId + getOrCreateConversation( + clientId, + 'client', + `Client Chat (${clientId.substring(0, 8)})`, + ) + updateConversation(clientId, { model: undefined, provider: 'Client' }) + }), + ) + + cleanupFns.push( + aiEventClient.on('client:message-sent', (e) => { + const clientId = e.payload.clientId + if (!state.conversations[clientId]) { getOrCreateConversation( clientId, 'client', `Client Chat (${clientId.substring(0, 8)})`, ) - updateConversation(clientId, { model: undefined, provider: 'Client' }) - }, - { withEventTarget: false }, - ), + } + addMessage(clientId, { + id: e.payload.messageId, + role: 'user', + content: e.payload.content, + timestamp: e.payload.timestamp, + source: 'client', + }) + updateConversation(clientId, { status: 'active' }) + }), ) cleanupFns.push( - aiEventClient.on( - 'client:message-sent', - (e) => { - const clientId = e.payload.clientId - if (!state.conversations[clientId]) { - getOrCreateConversation( - clientId, - 'client', - `Client Chat (${clientId.substring(0, 8)})`, - ) - } + aiEventClient.on('client:message-appended', (e) => { + const clientId = e.payload.clientId + const role = e.payload.role + + if (role === 'user') return + if (!state.conversations[clientId]) return + + if (role === 'assistant') { addMessage(clientId, { id: e.payload.messageId, - role: 'user', - content: e.payload.content, + role: 'assistant', + content: e.payload.contentPreview, timestamp: e.payload.timestamp, source: 'client', }) - updateConversation(clientId, { status: 'active' }) - }, - { withEventTarget: false }, - ), - ) - - cleanupFns.push( - aiEventClient.on( - 'client:message-appended', - (e) => { - const clientId = e.payload.clientId - const role = e.payload.role - - if (role === 'user') return - if (!state.conversations[clientId]) return - - if (role === 'assistant') { - addMessage(clientId, { - id: e.payload.messageId, - role: 'assistant', - content: e.payload.contentPreview, - timestamp: e.payload.timestamp, - source: 'client', - }) - } else if (role === 'tool') { - // Tool result message from the LLM - addMessage(clientId, { - id: e.payload.messageId, - role: 'tool', - content: e.payload.contentPreview, - timestamp: e.payload.timestamp, - source: 'client', - }) - } - }, - { withEventTarget: false }, - ), + } else if (role === 'tool') { + // Tool result message from the LLM + addMessage(clientId, { + id: e.payload.messageId, + role: 'tool', + content: e.payload.contentPreview, + timestamp: e.payload.timestamp, + source: 'client', + }) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'client:loading-changed', - (e) => { - const clientId = e.payload.clientId - if (state.conversations[clientId]) { - updateConversation(clientId, { - status: e.payload.isLoading ? 'active' : 'completed', - }) - } - }, - { withEventTarget: false }, - ), + aiEventClient.on('client:loading-changed', (e) => { + const clientId = e.payload.clientId + if (state.conversations[clientId]) { + updateConversation(clientId, { + status: e.payload.isLoading ? 'active' : 'completed', + }) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'client:stopped', - (e) => { - const clientId = e.payload.clientId - if (state.conversations[clientId]) { - updateConversation(clientId, { - status: 'completed', - completedAt: e.payload.timestamp, - }) - } - }, - { withEventTarget: false }, - ), + aiEventClient.on('client:stopped', (e) => { + const clientId = e.payload.clientId + if (state.conversations[clientId]) { + updateConversation(clientId, { + status: 'completed', + completedAt: e.payload.timestamp, + }) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'client:messages-cleared', - (e) => { - const clientId = e.payload.clientId - if (state.conversations[clientId]) { - updateConversation(clientId, { - messages: [], - chunks: [], - usage: undefined, - }) - } - }, - { withEventTarget: false }, - ), + aiEventClient.on('client:messages-cleared', (e) => { + const clientId = e.payload.clientId + if (state.conversations[clientId]) { + updateConversation(clientId, { + messages: [], + chunks: [], + usage: undefined, + }) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'client:reloaded', - (e) => { - const clientId = e.payload.clientId - const conv = state.conversations[clientId] - if (conv) { - updateConversation(clientId, { - messages: conv.messages.slice(0, e.payload.fromMessageIndex), - status: 'active', - }) - } - }, - { withEventTarget: false }, - ), + aiEventClient.on('client:reloaded', (e) => { + const clientId = e.payload.clientId + const conv = state.conversations[clientId] + if (conv) { + updateConversation(clientId, { + messages: conv.messages.slice(0, e.payload.fromMessageIndex), + status: 'active', + }) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'client:error-changed', - (e) => { - const clientId = e.payload.clientId - if (state.conversations[clientId] && e.payload.error) { - updateConversation(clientId, { status: 'error' }) - } - }, - { withEventTarget: false }, - ), + aiEventClient.on('client:error-changed', (e) => { + const clientId = e.payload.clientId + if (state.conversations[clientId] && e.payload.error) { + updateConversation(clientId, { status: 'error' }) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'client:assistant-message-updated', - (e) => { - const clientId = e.payload.clientId - const messageId = e.payload.messageId - const content = e.payload.content + aiEventClient.on('client:assistant-message-updated', (e) => { + const clientId = e.payload.clientId + const messageId = e.payload.messageId + const content = e.payload.content - if (!state.conversations[clientId]) return + if (!state.conversations[clientId]) return - const conv = state.conversations[clientId] + const conv = state.conversations[clientId] - // Find message by ID anywhere in the list, not just the last one - const messageIndex = conv.messages.findIndex( - (m: Message) => m.id === messageId, - ) + // Find message by ID anywhere in the list, not just the last one + const messageIndex = conv.messages.findIndex( + (m: Message) => m.id === messageId, + ) - // Only update existing messages, don't create new ones - // (client:message-appended is responsible for creating messages) - if (messageIndex >= 0) { - updateMessage(clientId, messageIndex, { - content, - model: conv.model, - }) - } - }, - { withEventTarget: false }, - ), + // Only update existing messages, don't create new ones + // (client:message-appended is responsible for creating messages) + if (messageIndex >= 0) { + updateMessage(clientId, messageIndex, { + content, + model: conv.model, + }) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'client:tool-call-updated', - (e) => { - const { - clientId, - messageId, - toolCallId, - toolName, - state: toolCallState, - arguments: args, - } = e.payload as { - clientId: string - messageId: string - toolCallId: string - toolName: string - state: string - arguments: unknown - timestamp: number - } + aiEventClient.on('client:tool-call-updated', (e) => { + const { + clientId, + messageId, + toolCallId, + toolName, + state: toolCallState, + arguments: args, + } = e.payload as { + clientId: string + messageId: string + toolCallId: string + toolName: string + state: string + arguments: unknown + timestamp: number + } - if (!state.conversations[clientId]) return + if (!state.conversations[clientId]) return - const conv = state.conversations[clientId] - const messageIndex = conv.messages.findIndex( - (m: Message) => m.id === messageId, - ) - if (messageIndex === -1) return + const conv = state.conversations[clientId] + const messageIndex = conv.messages.findIndex( + (m: Message) => m.id === messageId, + ) + if (messageIndex === -1) return - const message = conv.messages[messageIndex] - if (!message) return + const message = conv.messages[messageIndex] + if (!message) return - const toolCalls = message.toolCalls || [] - const existingToolIndex = toolCalls.findIndex( - (t: ToolCall) => t.id === toolCallId, - ) + const toolCalls = message.toolCalls || [] + const existingToolIndex = toolCalls.findIndex( + (t: ToolCall) => t.id === toolCallId, + ) - const toolCall: ToolCall = { - id: toolCallId, - name: toolName, - arguments: JSON.stringify(args, null, 2), - state: toolCallState, - } + const toolCall: ToolCall = { + id: toolCallId, + name: toolName, + arguments: JSON.stringify(args, null, 2), + state: toolCallState, + } - if (existingToolIndex >= 0) { - updateToolCall(clientId, messageIndex, existingToolIndex, toolCall) - } else { - setToolCalls(clientId, messageIndex, [...toolCalls, toolCall]) - } - }, - { withEventTarget: false }, - ), + if (existingToolIndex >= 0) { + updateToolCall(clientId, messageIndex, existingToolIndex, toolCall) + } else { + setToolCalls(clientId, messageIndex, [...toolCalls, toolCall]) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'client:approval-requested', - (e) => { - const { clientId, messageId, toolCallId, approvalId } = e.payload + aiEventClient.on('client:approval-requested', (e) => { + const { clientId, messageId, toolCallId, approvalId } = e.payload - if (!state.conversations[clientId]) return + if (!state.conversations[clientId]) return - const conv = state.conversations[clientId] - const messageIndex = conv.messages.findIndex( - (m) => m.id === messageId, - ) - if (messageIndex === -1) return + const conv = state.conversations[clientId] + const messageIndex = conv.messages.findIndex((m) => m.id === messageId) + if (messageIndex === -1) return - const message = conv.messages[messageIndex] - if (!message?.toolCalls) return + const message = conv.messages[messageIndex] + if (!message?.toolCalls) return - const toolCallIndex = message.toolCalls.findIndex( - (t) => t.id === toolCallId, - ) - if (toolCallIndex === -1) return - - updateToolCall(clientId, messageIndex, toolCallIndex, { - approvalRequired: true, - approvalId, - state: 'approval-requested', - }) - }, - { withEventTarget: false }, - ), + const toolCallIndex = message.toolCalls.findIndex( + (t) => t.id === toolCallId, + ) + if (toolCallIndex === -1) return + + updateToolCall(clientId, messageIndex, toolCallIndex, { + approvalRequired: true, + approvalId, + state: 'approval-requested', + }) + }), ) // ============= Tool Events ============= cleanupFns.push( - aiEventClient.on( - 'tool:result-added', - (e) => { - const { - clientId, - toolCallId, - toolName, - output, - state: resultState, - timestamp, - } = e.payload - - if (!state.conversations[clientId]) return - - const conv = state.conversations[clientId] - - // Find the message with the tool call and update it - for ( - let messageIndex = conv.messages.length - 1; - messageIndex >= 0; - messageIndex-- - ) { - const message = conv.messages[messageIndex] - if (!message?.toolCalls) continue + aiEventClient.on('tool:result-added', (e) => { + const { + clientId, + toolCallId, + toolName, + output, + state: resultState, + timestamp, + } = e.payload + + if (!state.conversations[clientId]) return + + const conv = state.conversations[clientId] + + // Find the message with the tool call and update it + for ( + let messageIndex = conv.messages.length - 1; + messageIndex >= 0; + messageIndex-- + ) { + const message = conv.messages[messageIndex] + if (!message?.toolCalls) continue - const toolCallIndex = message.toolCalls.findIndex( - (t: ToolCall) => t.id === toolCallId, - ) - if (toolCallIndex >= 0) { - // Update the tool call state - updateToolCall(clientId, messageIndex, toolCallIndex, { - result: output, - state: resultState === 'output-error' ? 'error' : 'complete', - }) + const toolCallIndex = message.toolCalls.findIndex( + (t: ToolCall) => t.id === toolCallId, + ) + if (toolCallIndex >= 0) { + // Update the tool call state + updateToolCall(clientId, messageIndex, toolCallIndex, { + result: output, + state: resultState === 'output-error' ? 'error' : 'complete', + }) - // Also add a chunk to show the tool result in the chunks view - const chunk: Chunk = { - id: `chunk-tool-result-${toolCallId}-${Date.now()}`, - type: 'tool_result', - messageId: message.id, - toolCallId, - toolName, - result: output, - timestamp, - chunkCount: 1, - } - addChunkToMessage(clientId, chunk) - return + // Also add a chunk to show the tool result in the chunks view + const chunk: Chunk = { + id: `chunk-tool-result-${toolCallId}-${Date.now()}`, + type: 'tool_result', + messageId: message.id, + toolCallId, + toolName, + result: output, + timestamp, + chunkCount: 1, } + addChunkToMessage(clientId, chunk) + return } - }, - { withEventTarget: false }, - ), + } + }), ) cleanupFns.push( - aiEventClient.on( - 'tool:approval-responded', - (e) => { - const { clientId, toolCallId, approved } = e.payload + aiEventClient.on('tool:approval-responded', (e) => { + const { clientId, toolCallId, approved } = e.payload - if (!state.conversations[clientId]) return + if (!state.conversations[clientId]) return - const conv = state.conversations[clientId] + const conv = state.conversations[clientId] - for ( - let messageIndex = conv.messages.length - 1; - messageIndex >= 0; - messageIndex-- - ) { - const message = conv.messages[messageIndex] - if (!message?.toolCalls) continue + for ( + let messageIndex = conv.messages.length - 1; + messageIndex >= 0; + messageIndex-- + ) { + const message = conv.messages[messageIndex] + if (!message?.toolCalls) continue - const toolCallIndex = message.toolCalls.findIndex( - (t: ToolCall) => t.id === toolCallId, - ) - if (toolCallIndex >= 0) { - updateToolCall(clientId, messageIndex, toolCallIndex, { - state: approved ? 'approved' : 'denied', - approvalRequired: false, - }) - return - } + const toolCallIndex = message.toolCalls.findIndex( + (t: ToolCall) => t.id === toolCallId, + ) + if (toolCallIndex >= 0) { + updateToolCall(clientId, messageIndex, toolCallIndex, { + state: approved ? 'approved' : 'denied', + approvalRequired: false, + }) + return } - }, - { withEventTarget: false }, - ), + } + }), ) // ============= Stream Events ============= cleanupFns.push( - aiEventClient.on( - 'stream:chunk:content', - (e) => { - const streamId = e.payload.streamId - const conversationId = streamToConversation.get(streamId) - if (!conversationId) return - - const chunk: Chunk = { - id: `chunk-${Date.now()}-${Math.random()}`, - type: 'content', - messageId: e.payload.messageId, - content: e.payload.content, - delta: e.payload.delta, - timestamp: e.payload.timestamp, - chunkCount: 1, - } + aiEventClient.on('stream:chunk:content', (e) => { + const streamId = e.payload.streamId + const conversationId = streamToConversation.get(streamId) + if (!conversationId) return + + const chunk: Chunk = { + id: `chunk-${Date.now()}-${Math.random()}`, + type: 'content', + messageId: e.payload.messageId, + content: e.payload.content, + delta: e.payload.delta, + timestamp: e.payload.timestamp, + chunkCount: 1, + } - const conv = state.conversations[conversationId] - if (conv?.type === 'client') { - addChunkToMessage(conversationId, chunk) - } else { - ensureMessageForChunk( - conversationId, - e.payload.messageId, - e.payload.timestamp, - ) - addChunk(conversationId, chunk) - } - }, - { withEventTarget: false }, - ), + const conv = state.conversations[conversationId] + if (conv?.type === 'client') { + addChunkToMessage(conversationId, chunk) + } else { + ensureMessageForChunk( + conversationId, + e.payload.messageId, + e.payload.timestamp, + ) + addChunk(conversationId, chunk) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'stream:chunk:tool-call', - (e) => { - const streamId = e.payload.streamId - const conversationId = streamToConversation.get(streamId) - if (!conversationId) return - - const chunk: Chunk = { - id: `chunk-${Date.now()}-${Math.random()}`, - type: 'tool_call', - messageId: e.payload.messageId, - toolCallId: e.payload.toolCallId, - toolName: e.payload.toolName, - arguments: e.payload.arguments, - timestamp: e.payload.timestamp, - chunkCount: 1, - } + aiEventClient.on('stream:chunk:tool-call', (e) => { + const streamId = e.payload.streamId + const conversationId = streamToConversation.get(streamId) + if (!conversationId) return + + const chunk: Chunk = { + id: `chunk-${Date.now()}-${Math.random()}`, + type: 'tool_call', + messageId: e.payload.messageId, + toolCallId: e.payload.toolCallId, + toolName: e.payload.toolName, + arguments: e.payload.arguments, + timestamp: e.payload.timestamp, + chunkCount: 1, + } - const conv = state.conversations[conversationId] - if (conv?.type === 'client') { - addChunkToMessage(conversationId, chunk) - } else { - ensureMessageForChunk( - conversationId, - e.payload.messageId, - e.payload.timestamp, - ) - addChunk(conversationId, chunk) - } - }, - { withEventTarget: false }, - ), + const conv = state.conversations[conversationId] + if (conv?.type === 'client') { + addChunkToMessage(conversationId, chunk) + } else { + ensureMessageForChunk( + conversationId, + e.payload.messageId, + e.payload.timestamp, + ) + addChunk(conversationId, chunk) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'stream:chunk:tool-result', - (e) => { - const streamId = e.payload.streamId - const conversationId = streamToConversation.get(streamId) - if (!conversationId) return - - const chunk: Chunk = { - id: `chunk-${Date.now()}-${Math.random()}`, - type: 'tool_result', - messageId: e.payload.messageId, - toolCallId: e.payload.toolCallId, - content: e.payload.result, - timestamp: e.payload.timestamp, - chunkCount: 1, - } + aiEventClient.on('stream:chunk:tool-result', (e) => { + const streamId = e.payload.streamId + const conversationId = streamToConversation.get(streamId) + if (!conversationId) return + + const chunk: Chunk = { + id: `chunk-${Date.now()}-${Math.random()}`, + type: 'tool_result', + messageId: e.payload.messageId, + toolCallId: e.payload.toolCallId, + content: e.payload.result, + timestamp: e.payload.timestamp, + chunkCount: 1, + } - const conv = state.conversations[conversationId] - if (conv?.type === 'client') { - addChunkToMessage(conversationId, chunk) - } else { - ensureMessageForChunk( - conversationId, - e.payload.messageId, - e.payload.timestamp, - ) - addChunk(conversationId, chunk) - } + const conv = state.conversations[conversationId] + if (conv?.type === 'client') { + addChunkToMessage(conversationId, chunk) + } else { + ensureMessageForChunk( + conversationId, + e.payload.messageId, + e.payload.timestamp, + ) + addChunk(conversationId, chunk) + } - // Also update the toolCalls array with the result - if (conv && e.payload.toolCallId) { - for (let i = conv.messages.length - 1; i >= 0; i--) { - const message = conv.messages[i] - if (!message?.toolCalls) continue - - const toolCallIndex = message.toolCalls.findIndex( - (t) => t.id === e.payload.toolCallId, - ) - if (toolCallIndex >= 0) { - updateToolCall(conversationId, i, toolCallIndex, { - result: e.payload.result, - state: 'complete', - }) - break - } + // Also update the toolCalls array with the result + if (conv && e.payload.toolCallId) { + for (let i = conv.messages.length - 1; i >= 0; i--) { + const message = conv.messages[i] + if (!message?.toolCalls) continue + + const toolCallIndex = message.toolCalls.findIndex( + (t) => t.id === e.payload.toolCallId, + ) + if (toolCallIndex >= 0) { + updateToolCall(conversationId, i, toolCallIndex, { + result: e.payload.result, + state: 'complete', + }) + break } } - }, - { withEventTarget: false }, - ), + } + }), ) cleanupFns.push( - aiEventClient.on( - 'stream:chunk:thinking', - (e) => { - const streamId = e.payload.streamId - const conversationId = streamToConversation.get(streamId) - if (!conversationId) return - - const chunk: Chunk = { - id: `chunk-${Date.now()}-${Math.random()}`, - type: 'thinking', - messageId: e.payload.messageId, - content: e.payload.content, - delta: e.payload.delta, - timestamp: e.payload.timestamp, - chunkCount: 1, - } + aiEventClient.on('stream:chunk:thinking', (e) => { + const streamId = e.payload.streamId + const conversationId = streamToConversation.get(streamId) + if (!conversationId) return + + const chunk: Chunk = { + id: `chunk-${Date.now()}-${Math.random()}`, + type: 'thinking', + messageId: e.payload.messageId, + content: e.payload.content, + delta: e.payload.delta, + timestamp: e.payload.timestamp, + chunkCount: 1, + } - const conv = state.conversations[conversationId] - if (conv?.type === 'client') { - addChunkToMessage(conversationId, chunk) - - if (e.payload.messageId) { - const messageIndex = conv.messages.findIndex( - (msg) => msg.id === e.payload.messageId, - ) - if (messageIndex !== -1) { - updateMessage(conversationId, messageIndex, { - thinkingContent: e.payload.content, - }) - } - } - } else { - ensureMessageForChunk( - conversationId, - e.payload.messageId, - e.payload.timestamp, + const conv = state.conversations[conversationId] + if (conv?.type === 'client') { + addChunkToMessage(conversationId, chunk) + + if (e.payload.messageId) { + const messageIndex = conv.messages.findIndex( + (msg) => msg.id === e.payload.messageId, ) - addChunk(conversationId, chunk) + if (messageIndex !== -1) { + updateMessage(conversationId, messageIndex, { + thinkingContent: e.payload.content, + }) + } } - }, - { withEventTarget: false }, - ), + } else { + ensureMessageForChunk( + conversationId, + e.payload.messageId, + e.payload.timestamp, + ) + addChunk(conversationId, chunk) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'stream:chunk:done', - (e) => { - const streamId = e.payload.streamId - const conversationId = streamToConversation.get(streamId) - if (!conversationId) return - - const chunk: Chunk = { - id: `chunk-${Date.now()}-${Math.random()}`, - type: 'done', - messageId: e.payload.messageId, - finishReason: e.payload.finishReason || undefined, - timestamp: e.payload.timestamp, - chunkCount: 1, - } + aiEventClient.on('stream:chunk:done', (e) => { + const streamId = e.payload.streamId + const conversationId = streamToConversation.get(streamId) + if (!conversationId) return + + const chunk: Chunk = { + id: `chunk-${Date.now()}-${Math.random()}`, + type: 'done', + messageId: e.payload.messageId, + finishReason: e.payload.finishReason || undefined, + timestamp: e.payload.timestamp, + chunkCount: 1, + } - if (e.payload.usage) { - updateConversation(conversationId, { usage: e.payload.usage }) - updateMessageUsage( - conversationId, - e.payload.messageId, - e.payload.usage, - ) - } + if (e.payload.usage) { + updateConversation(conversationId, { usage: e.payload.usage }) + updateMessageUsage( + conversationId, + e.payload.messageId, + e.payload.usage, + ) + } - const conv = state.conversations[conversationId] - if (conv?.type === 'client') { - addChunkToMessage(conversationId, chunk) - } else { - ensureMessageForChunk( - conversationId, - e.payload.messageId, - e.payload.timestamp, - ) - addChunk(conversationId, chunk) - } + const conv = state.conversations[conversationId] + if (conv?.type === 'client') { + addChunkToMessage(conversationId, chunk) + } else { + ensureMessageForChunk( + conversationId, + e.payload.messageId, + e.payload.timestamp, + ) + addChunk(conversationId, chunk) + } - updateConversation(conversationId, { - status: 'completed', - completedAt: e.payload.timestamp, - }) - }, - { withEventTarget: false }, - ), + updateConversation(conversationId, { + status: 'completed', + completedAt: e.payload.timestamp, + }) + }), ) cleanupFns.push( - aiEventClient.on( - 'stream:chunk:error', - (e) => { - const streamId = e.payload.streamId - const conversationId = streamToConversation.get(streamId) - if (!conversationId) return - - const chunk: Chunk = { - id: `chunk-${Date.now()}-${Math.random()}`, - type: 'error', - messageId: e.payload.messageId, - error: e.payload.error, - timestamp: e.payload.timestamp, - chunkCount: 1, - } + aiEventClient.on('stream:chunk:error', (e) => { + const streamId = e.payload.streamId + const conversationId = streamToConversation.get(streamId) + if (!conversationId) return + + const chunk: Chunk = { + id: `chunk-${Date.now()}-${Math.random()}`, + type: 'error', + messageId: e.payload.messageId, + error: e.payload.error, + timestamp: e.payload.timestamp, + chunkCount: 1, + } - const conv = state.conversations[conversationId] - if (conv?.type === 'client') { - addChunkToMessage(conversationId, chunk) - } else { - ensureMessageForChunk( - conversationId, - e.payload.messageId, - e.payload.timestamp, - ) - addChunk(conversationId, chunk) - } + const conv = state.conversations[conversationId] + if (conv?.type === 'client') { + addChunkToMessage(conversationId, chunk) + } else { + ensureMessageForChunk( + conversationId, + e.payload.messageId, + e.payload.timestamp, + ) + addChunk(conversationId, chunk) + } - updateConversation(conversationId, { - status: 'error', - completedAt: e.payload.timestamp, - }) - }, - { withEventTarget: false }, - ), + updateConversation(conversationId, { + status: 'error', + completedAt: e.payload.timestamp, + }) + }), ) cleanupFns.push( - aiEventClient.on( - 'stream:ended', - (e) => { - const streamId = e.payload.streamId - const conversationId = streamToConversation.get(streamId) - if (!conversationId) return - - updateConversation(conversationId, { - status: 'completed', - completedAt: e.payload.timestamp, - }) - }, - { withEventTarget: false }, - ), + aiEventClient.on('stream:ended', (e) => { + const streamId = e.payload.streamId + const conversationId = streamToConversation.get(streamId) + if (!conversationId) return + + updateConversation(conversationId, { + status: 'completed', + completedAt: e.payload.timestamp, + }) + }), ) cleanupFns.push( - aiEventClient.on( - 'stream:approval-requested', - (e) => { - const { - streamId, - messageId, - toolCallId, - toolName, - input, - approvalId, - timestamp, - } = e.payload - - const conversationId = streamToConversation.get(streamId) - if (!conversationId) return - - const conv = state.conversations[conversationId] - if (!conv) return - - const chunk: Chunk = { - id: `chunk-${Date.now()}-${Math.random()}`, - type: 'approval', - messageId: messageId, - toolCallId, - toolName, - approvalId, - input, - timestamp, - chunkCount: 1, - } + aiEventClient.on('stream:approval-requested', (e) => { + const { + streamId, + messageId, + toolCallId, + toolName, + input, + approvalId, + timestamp, + } = e.payload + + const conversationId = streamToConversation.get(streamId) + if (!conversationId) return - if (conv.type === 'client') { - addChunkToMessage(conversationId, chunk) - } else { - addChunk(conversationId, chunk) - } + const conv = state.conversations[conversationId] + if (!conv) return + + const chunk: Chunk = { + id: `chunk-${Date.now()}-${Math.random()}`, + type: 'approval', + messageId: messageId, + toolCallId, + toolName, + approvalId, + input, + timestamp, + chunkCount: 1, + } - for (let i = conv.messages.length - 1; i >= 0; i--) { - const message = conv.messages[i] - if (!message) continue - - if (message.role === 'assistant' && message.toolCalls) { - const toolCallIndex = message.toolCalls.findIndex( - (t: ToolCall) => t.id === toolCallId, - ) - if (toolCallIndex >= 0) { - updateToolCall(conversationId, i, toolCallIndex, { - approvalRequired: true, - approvalId, - state: 'approval-requested', - }) - return - } + if (conv.type === 'client') { + addChunkToMessage(conversationId, chunk) + } else { + addChunk(conversationId, chunk) + } + + for (let i = conv.messages.length - 1; i >= 0; i--) { + const message = conv.messages[i] + if (!message) continue + + if (message.role === 'assistant' && message.toolCalls) { + const toolCallIndex = message.toolCalls.findIndex( + (t: ToolCall) => t.id === toolCallId, + ) + if (toolCallIndex >= 0) { + updateToolCall(conversationId, i, toolCallIndex, { + approvalRequired: true, + approvalId, + state: 'approval-requested', + }) + return } } - }, - { withEventTarget: false }, - ), + } + }), ) // ============= Processor Events ============= cleanupFns.push( - aiEventClient.on( - 'processor:text-updated', - (e) => { - const streamId = e.payload.streamId + aiEventClient.on('processor:text-updated', (e) => { + const streamId = e.payload.streamId - let conversationId = streamToConversation.get(streamId) + let conversationId = streamToConversation.get(streamId) - if (!conversationId) { - const activeClients = Object.values(state.conversations) - .filter((c) => c.type === 'client' && c.status === 'active') - .sort((a, b) => b.startedAt - a.startedAt) + if (!conversationId) { + const activeClients = Object.values(state.conversations) + .filter((c) => c.type === 'client' && c.status === 'active') + .sort((a, b) => b.startedAt - a.startedAt) - if (activeClients.length > 0 && activeClients[0]) { - conversationId = activeClients[0].id - streamToConversation.set(streamId, conversationId) - } + if (activeClients.length > 0 && activeClients[0]) { + conversationId = activeClients[0].id + streamToConversation.set(streamId, conversationId) } + } - if (!conversationId) return + if (!conversationId) return - const conv = state.conversations[conversationId] - if (!conv) return + const conv = state.conversations[conversationId] + if (!conv) return - // Only update existing assistant messages, don't create new ones - // (client:message-appended is responsible for creating messages) - const lastMessage = conv.messages[conv.messages.length - 1] - if (lastMessage && lastMessage.role === 'assistant') { - updateMessage(conversationId, conv.messages.length - 1, { - content: e.payload.content, - }) - } - }, - { withEventTarget: false }, - ), + // Only update existing assistant messages, don't create new ones + // (client:message-appended is responsible for creating messages) + const lastMessage = conv.messages[conv.messages.length - 1] + if (lastMessage && lastMessage.role === 'assistant') { + updateMessage(conversationId, conv.messages.length - 1, { + content: e.payload.content, + }) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'processor:tool-call-state-changed', - (e) => { - const streamId = e.payload.streamId - const conversationId = streamToConversation.get(streamId) + aiEventClient.on('processor:tool-call-state-changed', (e) => { + const streamId = e.payload.streamId + const conversationId = streamToConversation.get(streamId) - if (!conversationId || !state.conversations[conversationId]) return + if (!conversationId || !state.conversations[conversationId]) return - const conv = state.conversations[conversationId] - const lastMessage = conv.messages[conv.messages.length - 1] + const conv = state.conversations[conversationId] + const lastMessage = conv.messages[conv.messages.length - 1] - if (lastMessage && lastMessage.role === 'assistant') { - const toolCalls = lastMessage.toolCalls || [] - const existingToolIndex = toolCalls.findIndex( - (t) => t.id === e.payload.toolCallId, - ) + if (lastMessage && lastMessage.role === 'assistant') { + const toolCalls = lastMessage.toolCalls || [] + const existingToolIndex = toolCalls.findIndex( + (t) => t.id === e.payload.toolCallId, + ) - const toolCall: ToolCall = { - id: e.payload.toolCallId, - name: e.payload.toolName, - arguments: JSON.stringify(e.payload.arguments, null, 2), - state: e.payload.state, - } + const toolCall: ToolCall = { + id: e.payload.toolCallId, + name: e.payload.toolName, + arguments: JSON.stringify(e.payload.arguments, null, 2), + state: e.payload.state, + } - if (existingToolIndex >= 0) { - updateToolCall( - conversationId, - conv.messages.length - 1, - existingToolIndex, - toolCall, - ) - } else { - setToolCalls(conversationId, conv.messages.length - 1, [ - ...toolCalls, - toolCall, - ]) - } + if (existingToolIndex >= 0) { + updateToolCall( + conversationId, + conv.messages.length - 1, + existingToolIndex, + toolCall, + ) + } else { + setToolCalls(conversationId, conv.messages.length - 1, [ + ...toolCalls, + toolCall, + ]) } - }, - { withEventTarget: false }, - ), + } + }), ) cleanupFns.push( - aiEventClient.on( - 'processor:tool-result-state-changed', - (e) => { - const streamId = e.payload.streamId - const conversationId = streamToConversation.get(streamId) + aiEventClient.on('processor:tool-result-state-changed', (e) => { + const streamId = e.payload.streamId + const conversationId = streamToConversation.get(streamId) - if (!conversationId || !state.conversations[conversationId]) return + if (!conversationId || !state.conversations[conversationId]) return - const conv = state.conversations[conversationId] + const conv = state.conversations[conversationId] - for (let i = conv.messages.length - 1; i >= 0; i--) { - const message = conv.messages[i] - if (!message?.toolCalls) continue + for (let i = conv.messages.length - 1; i >= 0; i--) { + const message = conv.messages[i] + if (!message?.toolCalls) continue - const toolCallIndex = message.toolCalls.findIndex( - (t) => t.id === e.payload.toolCallId, - ) - if (toolCallIndex >= 0) { - updateToolCall(conversationId, i, toolCallIndex, { - result: e.payload.content, - state: e.payload.error ? 'error' : e.payload.state, - }) - return - } + const toolCallIndex = message.toolCalls.findIndex( + (t) => t.id === e.payload.toolCallId, + ) + if (toolCallIndex >= 0) { + updateToolCall(conversationId, i, toolCallIndex, { + result: e.payload.content, + state: e.payload.error ? 'error' : e.payload.state, + }) + return } - }, - { withEventTarget: false }, - ), + } + }), ) // ============= Chat Events (for usage tracking) ============= cleanupFns.push( - aiEventClient.on( - 'chat:started', - (e) => { - const streamId = e.payload.streamId - const model = e.payload.model - const provider = e.payload.provider - const clientId = e.payload.clientId - - if (clientId && state.conversations[clientId]) { - streamToConversation.set(streamId, clientId) - updateConversation(clientId, { status: 'active', ...e.payload }) - return - } + aiEventClient.on('chat:started', (e) => { + const streamId = e.payload.streamId + const model = e.payload.model + const provider = e.payload.provider + const clientId = e.payload.clientId + + if (clientId && state.conversations[clientId]) { + streamToConversation.set(streamId, clientId) + updateConversation(clientId, { status: 'active', ...e.payload }) + return + } - const activeClient = Object.values(state.conversations).find( - (c) => c.type === 'client' && c.status === 'active' && !c.model, + const activeClient = Object.values(state.conversations).find( + (c) => c.type === 'client' && c.status === 'active' && !c.model, + ) + + if (activeClient) { + streamToConversation.set(streamId, activeClient.id) + updateConversation(activeClient.id, { ...e.payload }) + } else { + const existingServerConv = Object.values(state.conversations).find( + (c) => c.type === 'server' && c.model === model, ) - if (activeClient) { - streamToConversation.set(streamId, activeClient.id) - updateConversation(activeClient.id, { ...e.payload }) + if (existingServerConv) { + streamToConversation.set(streamId, existingServerConv.id) + updateConversation(existingServerConv.id, { + status: 'active', + ...e.payload, + }) } else { - const existingServerConv = Object.values(state.conversations).find( - (c) => c.type === 'server' && c.model === model, - ) - - if (existingServerConv) { - streamToConversation.set(streamId, existingServerConv.id) - updateConversation(existingServerConv.id, { - status: 'active', - ...e.payload, - }) - } else { - const serverId = `server-${model}` - getOrCreateConversation(serverId, 'server', `${model} Server`) - streamToConversation.set(streamId, serverId) - updateConversation(serverId, { ...e.payload }) - } + const serverId = `server-${model}` + getOrCreateConversation(serverId, 'server', `${model} Server`) + streamToConversation.set(streamId, serverId) + updateConversation(serverId, { ...e.payload }) } - }, - { withEventTarget: false }, - ), + } + }), ) cleanupFns.push( - aiEventClient.on( - 'chat:completed', - (e) => { - const { requestId, usage } = e.payload - - const conversationId = requestToConversation.get(requestId) - if (conversationId && state.conversations[conversationId]) { - const updates: Partial = { - status: 'completed', - completedAt: e.payload.timestamp, - } - if (usage) { - updates.usage = usage - } - updateConversation(conversationId, updates) - if (usage) { - updateMessageUsage(conversationId, e.payload.messageId, usage) - } + aiEventClient.on('chat:completed', (e) => { + const { requestId, usage } = e.payload + + const conversationId = requestToConversation.get(requestId) + if (conversationId && state.conversations[conversationId]) { + const updates: Partial = { + status: 'completed', + completedAt: e.payload.timestamp, } - }, - { withEventTarget: false }, - ), + if (usage) { + updates.usage = usage + } + updateConversation(conversationId, updates) + if (usage) { + updateMessageUsage(conversationId, e.payload.messageId, usage) + } + } + }), ) cleanupFns.push( - aiEventClient.on( - 'chat:iteration', - (e) => { - const { requestId, iterationNumber } = e.payload - - const conversationId = requestToConversation.get(requestId) - if (conversationId && state.conversations[conversationId]) { - updateConversation(conversationId, { - iterationCount: iterationNumber, - }) - } - }, - { withEventTarget: false }, - ), + aiEventClient.on('chat:iteration', (e) => { + const { requestId, iterationNumber } = e.payload + + const conversationId = requestToConversation.get(requestId) + if (conversationId && state.conversations[conversationId]) { + updateConversation(conversationId, { + iterationCount: iterationNumber, + }) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'usage:tokens', - (e) => { - const { requestId, usage, messageId } = e.payload - - const conversationId = requestToConversation.get(requestId) - if (conversationId && state.conversations[conversationId]) { - updateConversation(conversationId, { usage }) - updateMessageUsage(conversationId, messageId, usage) - } - }, - { withEventTarget: false }, - ), + aiEventClient.on('usage:tokens', (e) => { + const { requestId, usage, messageId } = e.payload + + const conversationId = requestToConversation.get(requestId) + if (conversationId && state.conversations[conversationId]) { + updateConversation(conversationId, { usage }) + updateMessageUsage(conversationId, messageId, usage) + } + }), ) // ============= Tool Call Completed (with duration) ============= cleanupFns.push( - aiEventClient.on( - 'tool:call-completed', - (e) => { - const { - streamId, - toolCallId, - toolName, - result, - duration, - messageId, - timestamp, - } = e.payload - - const conversationId = streamToConversation.get(streamId) - if (!conversationId || !state.conversations[conversationId]) return - - const conv = state.conversations[conversationId] - - // Add a tool_result chunk to show the result in the chunks view - const chunk: Chunk = { - id: `chunk-tool-result-${toolCallId}-${Date.now()}`, - type: 'tool_result', - messageId: messageId, - toolCallId, - toolName, - result, - duration, - timestamp, - chunkCount: 1, - } + aiEventClient.on('tool:call-completed', (e) => { + const { + streamId, + toolCallId, + toolName, + result, + duration, + messageId, + timestamp, + } = e.payload + + const conversationId = streamToConversation.get(streamId) + if (!conversationId || !state.conversations[conversationId]) return - // Add chunk to message if it's a client conversation, otherwise to conversation - if (conv.type === 'client' && messageId) { - const messageIndex = conv.messages.findIndex( - (m) => m.id === messageId, - ) - if (messageIndex !== -1) { - queueMessageChunk(conversationId, messageIndex, chunk) - } else { - // If message not found, add to last assistant message - for (let i = conv.messages.length - 1; i >= 0; i--) { - if (conv.messages[i]?.role === 'assistant') { - queueMessageChunk(conversationId, i, chunk) - break - } + const conv = state.conversations[conversationId] + + // Add a tool_result chunk to show the result in the chunks view + const chunk: Chunk = { + id: `chunk-tool-result-${toolCallId}-${Date.now()}`, + type: 'tool_result', + messageId: messageId, + toolCallId, + toolName, + result, + duration, + timestamp, + chunkCount: 1, + } + + // Add chunk to message if it's a client conversation, otherwise to conversation + if (conv.type === 'client' && messageId) { + const messageIndex = conv.messages.findIndex( + (m) => m.id === messageId, + ) + if (messageIndex !== -1) { + queueMessageChunk(conversationId, messageIndex, chunk) + } else { + // If message not found, add to last assistant message + for (let i = conv.messages.length - 1; i >= 0; i--) { + if (conv.messages[i]?.role === 'assistant') { + queueMessageChunk(conversationId, i, chunk) + break } } - } else { - addChunk(conversationId, chunk) } + } else { + addChunk(conversationId, chunk) + } - // Update the tool call with duration - for (let i = conv.messages.length - 1; i >= 0; i--) { - const message = conv.messages[i] - if (!message?.toolCalls) continue + // Update the tool call with duration + for (let i = conv.messages.length - 1; i >= 0; i--) { + const message = conv.messages[i] + if (!message?.toolCalls) continue - const toolCallIndex = message.toolCalls.findIndex( - (t) => t.id === toolCallId, - ) - if (toolCallIndex >= 0) { - updateToolCall(conversationId, i, toolCallIndex, { - duration, - result, - }) - return - } + const toolCallIndex = message.toolCalls.findIndex( + (t) => t.id === toolCallId, + ) + if (toolCallIndex >= 0) { + updateToolCall(conversationId, i, toolCallIndex, { + duration, + result, + }) + return } - }, - { withEventTarget: false }, - ), + } + }), ) // ============= Embedding Events ============= cleanupFns.push( - aiEventClient.on( - 'embedding:started', - (e) => { - const { requestId, model, inputCount, timestamp, clientId } = - e.payload - - // Try to find an active conversation to attach to, or create a new one - let conversationId = clientId - if (!conversationId || !state.conversations[conversationId]) { - // Find most recent active client conversation - const activeClients = Object.values(state.conversations) - .filter((c) => c.type === 'client' && c.status === 'active') - .sort((a, b) => b.startedAt - a.startedAt) - - if (activeClients.length > 0 && activeClients[0]) { - conversationId = activeClients[0].id - } else { - // Create a new conversation for embeddings - conversationId = `embedding-${requestId}` - getOrCreateConversation( - conversationId, - 'server', - `Embedding (${model})`, - ) - updateConversation(conversationId, { model }) - } + aiEventClient.on('embedding:started', (e) => { + const { requestId, model, inputCount, timestamp, clientId } = e.payload + + // Try to find an active conversation to attach to, or create a new one + let conversationId = clientId + if (!conversationId || !state.conversations[conversationId]) { + // Find most recent active client conversation + const activeClients = Object.values(state.conversations) + .filter((c) => c.type === 'client' && c.status === 'active') + .sort((a, b) => b.startedAt - a.startedAt) + + if (activeClients.length > 0 && activeClients[0]) { + conversationId = activeClients[0].id + } else { + // Create a new conversation for embeddings + conversationId = `embedding-${requestId}` + getOrCreateConversation( + conversationId, + 'server', + `Embedding (${model})`, + ) + updateConversation(conversationId, { model }) } + } - requestToConversation.set(requestId, conversationId) + requestToConversation.set(requestId, conversationId) - const embeddingOp: EmbeddingOperation = { - id: requestId, - model, - inputCount, - duration: 0, - timestamp, - status: 'started', - } + const embeddingOp: EmbeddingOperation = { + id: requestId, + model, + inputCount, + duration: 0, + timestamp, + status: 'started', + } - const conv = state.conversations[conversationId] - if (conv) { - const embeddings = conv.embeddings || [] - setState('conversations', conversationId, 'embeddings', [ - ...embeddings, - embeddingOp, - ]) - setState('conversations', conversationId, 'hasEmbedding', true) - } - }, - { withEventTarget: false }, - ), + const conv = state.conversations[conversationId] + if (conv) { + const embeddings = conv.embeddings || [] + setState('conversations', conversationId, 'embeddings', [ + ...embeddings, + embeddingOp, + ]) + setState('conversations', conversationId, 'hasEmbedding', true) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'embedding:completed', - (e) => { - const { requestId, duration } = e.payload + aiEventClient.on('embedding:completed', (e) => { + const { requestId, duration } = e.payload - const conversationId = requestToConversation.get(requestId) - if (!conversationId || !state.conversations[conversationId]) return + const conversationId = requestToConversation.get(requestId) + if (!conversationId || !state.conversations[conversationId]) return - const conv = state.conversations[conversationId] - if (!conv.embeddings) return + const conv = state.conversations[conversationId] + if (!conv.embeddings) return - const embeddingIndex = conv.embeddings.findIndex( - (op) => op.id === requestId, + const embeddingIndex = conv.embeddings.findIndex( + (op) => op.id === requestId, + ) + if (embeddingIndex >= 0) { + setState( + 'conversations', + conversationId, + 'embeddings', + embeddingIndex, + produce((op: EmbeddingOperation) => { + op.duration = duration + op.status = 'completed' + }), ) - if (embeddingIndex >= 0) { - setState( - 'conversations', - conversationId, - 'embeddings', - embeddingIndex, - produce((op: EmbeddingOperation) => { - op.duration = duration - op.status = 'completed' - }), - ) - } - }, - { withEventTarget: false }, - ), + } + }), ) // ============= Summarize Events ============= cleanupFns.push( - aiEventClient.on( - 'summarize:started', - (e) => { - const { requestId, model, inputLength, timestamp, clientId } = - e.payload - - // Try to find an active conversation to attach to, or create a new one - let conversationId = clientId - if (!conversationId || !state.conversations[conversationId]) { - // Find most recent active client conversation - const activeClients = Object.values(state.conversations) - .filter((c) => c.type === 'client' && c.status === 'active') - .sort((a, b) => b.startedAt - a.startedAt) - - if (activeClients.length > 0 && activeClients[0]) { - conversationId = activeClients[0].id - } else { - // Create a new conversation for summaries - conversationId = `summarize-${requestId}` - getOrCreateConversation( - conversationId, - 'server', - `Summarize (${model})`, - ) - updateConversation(conversationId, { model }) - } + aiEventClient.on('summarize:started', (e) => { + const { requestId, model, inputLength, timestamp, clientId } = e.payload + + // Try to find an active conversation to attach to, or create a new one + let conversationId = clientId + if (!conversationId || !state.conversations[conversationId]) { + // Find most recent active client conversation + const activeClients = Object.values(state.conversations) + .filter((c) => c.type === 'client' && c.status === 'active') + .sort((a, b) => b.startedAt - a.startedAt) + + if (activeClients.length > 0 && activeClients[0]) { + conversationId = activeClients[0].id + } else { + // Create a new conversation for summaries + conversationId = `summarize-${requestId}` + getOrCreateConversation( + conversationId, + 'server', + `Summarize (${model})`, + ) + updateConversation(conversationId, { model }) } + } - requestToConversation.set(requestId, conversationId) + requestToConversation.set(requestId, conversationId) - const summarizeOp: SummarizeOperation = { - id: requestId, - model, - inputLength, - timestamp, - status: 'started', - } + const summarizeOp: SummarizeOperation = { + id: requestId, + model, + inputLength, + timestamp, + status: 'started', + } - const conv = state.conversations[conversationId] - if (conv) { - const summaries = conv.summaries || [] - setState('conversations', conversationId, 'summaries', [ - ...summaries, - summarizeOp, - ]) - setState('conversations', conversationId, 'hasSummarize', true) - } - }, - { withEventTarget: false }, - ), + const conv = state.conversations[conversationId] + if (conv) { + const summaries = conv.summaries || [] + setState('conversations', conversationId, 'summaries', [ + ...summaries, + summarizeOp, + ]) + setState('conversations', conversationId, 'hasSummarize', true) + } + }), ) cleanupFns.push( - aiEventClient.on( - 'summarize:completed', - (e) => { - const { requestId, outputLength, duration } = e.payload + aiEventClient.on('summarize:completed', (e) => { + const { requestId, outputLength, duration } = e.payload - const conversationId = requestToConversation.get(requestId) - if (!conversationId || !state.conversations[conversationId]) return + const conversationId = requestToConversation.get(requestId) + if (!conversationId || !state.conversations[conversationId]) return - const conv = state.conversations[conversationId] - if (!conv.summaries) return + const conv = state.conversations[conversationId] + if (!conv.summaries) return - const summaryIndex = conv.summaries.findIndex( - (op) => op.id === requestId, + const summaryIndex = conv.summaries.findIndex( + (op) => op.id === requestId, + ) + if (summaryIndex >= 0) { + setState( + 'conversations', + conversationId, + 'summaries', + summaryIndex, + produce((op: SummarizeOperation) => { + op.duration = duration + op.outputLength = outputLength + op.status = 'completed' + }), ) - if (summaryIndex >= 0) { - setState( - 'conversations', - conversationId, - 'summaries', - summaryIndex, - produce((op: SummarizeOperation) => { - op.duration = duration - op.outputLength = outputLength - op.status = 'completed' - }), - ) - } - }, - { withEventTarget: false }, - ), + } + }), ) // Cleanup all listeners on unmount diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json index 1a532e5e..e8903384 100644 --- a/packages/typescript/ai/package.json +++ b/packages/typescript/ai/package.json @@ -51,7 +51,7 @@ "embeddings" ], "dependencies": { - "@tanstack/devtools-event-client": "^0.3.5", + "@tanstack/devtools-event-client": "^0.4.0", "partial-json": "^0.1.7" }, "peerDependencies": { diff --git a/packages/typescript/ai/src/event-client.ts b/packages/typescript/ai/src/event-client.ts index f69af31d..86bbdd0a 100644 --- a/packages/typescript/ai/src/event-client.ts +++ b/packages/typescript/ai/src/event-client.ts @@ -296,91 +296,11 @@ export interface AIDevtoolsEventMap { } } -// Helper type to strip the prefix at the type level -type StripPrefix = - T extends `tanstack-ai-devtools:${infer Suffix}` ? Suffix : never - -// Get all event names without the prefix -type EventSuffix = StripPrefix - class AiEventClient extends EventClient { - private eventTarget: EventTarget - constructor() { super({ pluginId: 'tanstack-ai-devtools', }) - this.eventTarget = new EventTarget() - } - - /** - * Subscribe to events using both the parent EventClient and EventTarget API - * @param eventSuffix - The event name without the prefix (e.g., "stream:started") - * @param handler - The event handler function - * @param options - Optional configuration for event subscription - * @returns A function to unsubscribe from the event - */ - override on( - eventSuffix: TSuffix, - handler: (event: { - type: `tanstack-ai-devtools:${TSuffix}` - payload: AIDevtoolsEventMap[`tanstack-ai-devtools:${TSuffix}`] - }) => void, - options?: { withEventTarget?: boolean }, - ): () => void { - const parentUnsubscribe = super.on(eventSuffix, handler) - - const withEventTarget = options?.withEventTarget ?? true - let eventListener: ((event: Event) => void) | undefined - - if (withEventTarget) { - // Create a wrapper to handle CustomEvent for EventTarget - eventListener = (event: Event) => { - if (event instanceof CustomEvent) { - handler({ - type: `${eventSuffix}` as `tanstack-ai-devtools:${TSuffix}`, - payload: event.detail, - }) - } - } - - // Add listener to EventTarget - this.eventTarget.addEventListener(eventSuffix, eventListener) - } - - // Return unsubscribe function that cleans up both subscriptions - return () => { - parentUnsubscribe() - if (withEventTarget && eventListener) { - this.eventTarget.removeEventListener(eventSuffix, eventListener) - } - } - } - - /** - * Emit an event to both the parent EventClient and the EventTarget - * @param eventSuffix - The event name without the prefix (e.g., "stream:started") - * @param data - The event data - */ - override emit( - eventSuffix: TSuffix, - data: AIDevtoolsEventMap[`tanstack-ai-devtools:${TSuffix}`], - ): void { - super.emit(eventSuffix, data) - - // Always dispatch to EventTarget (for local listeners) - const customEvent = new CustomEvent(eventSuffix, { - detail: data, - }) - this.eventTarget.dispatchEvent(customEvent) - } - - /** - * Get the underlying EventTarget for advanced use cases - * @returns The EventTarget instance - */ - getEventTarget(): EventTarget { - return this.eventTarget } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c72fc01..486008b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -405,8 +405,8 @@ importers: specifier: ^0.8.10 version: 0.8.10(solid-js@1.9.10) '@tanstack/devtools-event-client': - specifier: ^0.3.5 - version: 0.3.5 + specifier: ^0.4.0 + version: 0.4.0 '@tanstack/devtools-vite': specifier: ^0.3.11 version: 0.3.11(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) @@ -600,8 +600,8 @@ importers: specifier: ^4.0.0 version: 4.0.10(zod@4.1.13) '@tanstack/devtools-event-client': - specifier: ^0.3.5 - version: 0.3.5 + specifier: ^0.4.0 + version: 0.4.0 partial-json: specifier: ^0.1.7 version: 0.1.7 @@ -2611,6 +2611,10 @@ packages: resolution: {integrity: sha512-RL1f5ZlfZMpghrCIdzl6mLOFLTuhqmPNblZgBaeKfdtk5rfbjykurv+VfYydOFXj0vxVIoA2d/zT7xfD7Ph8fw==} engines: {node: '>=18'} + '@tanstack/devtools-event-client@0.4.0': + resolution: {integrity: sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==} + engines: {node: '>=18'} + '@tanstack/devtools-ui@0.4.4': resolution: {integrity: sha512-5xHXFyX3nom0UaNfiOM92o6ziaHjGo3mcSGe2HD5Xs8dWRZNpdZ0Smd0B9ddEhy0oB+gXyMzZgUJb9DmrZV0Mg==} engines: {node: '>=18'} @@ -8661,6 +8665,8 @@ snapshots: '@tanstack/devtools-event-client@0.3.5': {} + '@tanstack/devtools-event-client@0.4.0': {} + '@tanstack/devtools-ui@0.4.4(csstype@3.2.3)(solid-js@1.9.10)': dependencies: clsx: 2.1.1