Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/ai-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
},
"dependencies": {
"@tanstack/ai": "workspace:*",
"@tanstack/ai-event-client": "workspace:*"
"@tanstack/ai-event-client": "workspace:*",
"@tanstack/store": "^0.8.0"
},
"devDependencies": {
"@standard-schema/spec": "^1.1.0",
Expand Down
57 changes: 57 additions & 0 deletions packages/ai-client/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
normalizeToUIMessage,
parseWithStandardSchema,
} from '@tanstack/ai/client'
import { Store } from '@tanstack/store'
import { createNoOpChatDevtoolsBridge } from './devtools-noop'
import {
fetcherToConnectionAdapter,
Expand Down Expand Up @@ -74,6 +75,24 @@ type ClientToolResult = {
errorText?: string
}

/**
* Immutable projection of the client's reactive state, held in a
* `@tanstack/store` Store. The same information the `onXChange` callbacks
* report, but as one value a framework binding can subscribe to and select
* from (`useStore(client.store, s => s.messages)`).
*/
export interface ChatClientSnapshot<
TTools extends ReadonlyArray<AnyClientTool> = any,
> {
messages: Array<UIMessage<TTools>>
isLoading: boolean
error: Error | undefined
status: ChatClientState
isSubscribed: boolean
connectionStatus: ConnectionStatus
sessionGenerating: boolean
}

function resolveTransport(transport: {
connection?: ConnectionAdapter
fetcher?: ChatFetcher
Expand Down Expand Up @@ -146,6 +165,14 @@ export class ChatClient<
private readonly activeRunIds = new Set<string>()
private devtoolsMounted = false

/**
* The client's reactive state as a `@tanstack/store` Store. Seeded in the
* constructor and kept in sync by the `setX` mutators via `syncStore()`.
* Consume from React with `useStore(client.store)` (or a selector);
* Solid/Vue/Svelte have equivalent `@tanstack/*-store` bindings.
*/
readonly store: Store<ChatClientSnapshot<TTools>>

private readonly callbacksRef: {
current: {
onResponse: (response?: Response) => void | Promise<void>
Expand Down Expand Up @@ -236,6 +263,7 @@ export class ChatClient<
onMessagesChange: (messages: Array<UIMessage>) => {
this.persistor?.notifyMessagesChanged(messages)
this.callbacksRef.current.onMessagesChange(messages)
this.syncStore()
},
onStreamStart: () => {
this.setStatus('streaming')
Expand Down Expand Up @@ -432,6 +460,10 @@ export class ChatClient<
},
})

// Seed the Store now that all backing state exists, so `client.store` is
// valid before the first subscriber attaches.
this.store = new Store(this.readStoreSnapshot())

this.persistor?.hydrateAsync(persistedMessages)
}

Expand Down Expand Up @@ -497,31 +529,36 @@ export class ChatClient<
this.isLoading = isLoading
this.callbacksRef.current.onLoadingChange(isLoading)
this.events.loadingChanged(isLoading)
this.syncStore()
}

private setStatus(status: ChatClientState): void {
this.status = status
this.callbacksRef.current.onStatusChange(status)
this.devtoolsBridge.emitSnapshot()
this.syncStore()
}

private setIsSubscribed(isSubscribed: boolean): void {
this.isSubscribed = isSubscribed
this.callbacksRef.current.onSubscriptionChange(isSubscribed)
this.devtoolsBridge.emitSnapshot()
this.syncStore()
}

private setConnectionStatus(status: ConnectionStatus): void {
this.connectionStatus = status
this.callbacksRef.current.onConnectionStatusChange(status)
this.devtoolsBridge.emitSnapshot()
this.syncStore()
}

private setSessionGenerating(isGenerating: boolean): void {
if (this.sessionGenerating === isGenerating) return
this.sessionGenerating = isGenerating
this.callbacksRef.current.onSessionGeneratingChange(isGenerating)
this.devtoolsBridge.emitSnapshot()
this.syncStore()
}

private resetSessionGenerating(): void {
Expand All @@ -534,6 +571,26 @@ export class ChatClient<
this.error = error
this.callbacksRef.current.onErrorChange(error)
this.events.errorChanged(error?.message || null)
this.syncStore()
}

private readStoreSnapshot(): ChatClientSnapshot<TTools> {
return {
messages: this.getMessages(),
isLoading: this.isLoading,
error: this.error,
status: this.status,
isSubscribed: this.isSubscribed,
connectionStatus: this.connectionStatus,
sessionGenerating: this.sessionGenerating,
}
}

// Project current state into the Store. `setState` shallow-compares nothing —
// it always notifies — but each field above is read fresh, so subscribers see
// a consistent snapshot. Only reached after the constructor seeds `store`.
private syncStore(): void {
this.store.setState(() => this.readStoreSnapshot())
}

private buildDevtoolsBridgeOptions(
Expand Down
1 change: 1 addition & 0 deletions packages/ai-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { ChatClient } from './chat-client'
export type { ChatClientSnapshot } from './chat-client'
export { RealtimeClient } from './realtime-client'
export { GenerationClient } from './generation-client'
export { VideoGenerationClient } from './video-generation-client'
Expand Down
3 changes: 2 additions & 1 deletion packages/ai-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"media-generation"
],
"dependencies": {
"@tanstack/ai-client": "workspace:*"
"@tanstack/ai-client": "workspace:*",
"@tanstack/react-store": "^0.8.0"
},
"peerDependencies": {
"@tanstack/ai": "workspace:^",
Expand Down
Loading
Loading