@tanstack/ai-client 0.15.2 → 0.16.2
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.
- package/dist/esm/chat-client.d.ts +9 -0
- package/dist/esm/chat-client.js +92 -17
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/client-persistor.d.ts +86 -0
- package/dist/esm/client-persistor.js +243 -0
- package/dist/esm/client-persistor.js.map +1 -0
- package/dist/esm/connection-adapters.d.ts +6 -0
- package/dist/esm/connection-adapters.js +10 -2
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/types.d.ts +9 -0
- package/dist/esm/types.js.map +1 -1
- package/package.json +3 -3
- package/src/chat-client.ts +112 -28
- package/src/client-persistor.ts +337 -0
- package/src/connection-adapters.ts +25 -2
- package/src/index.ts +1 -0
- package/src/types.ts +22 -0
package/dist/esm/types.d.ts
CHANGED
|
@@ -190,6 +190,11 @@ export interface UIMessage<TTools extends ReadonlyArray<AnyClientTool> = any, TD
|
|
|
190
190
|
parts: Array<MessagePart<TTools, TData>>;
|
|
191
191
|
createdAt?: Date;
|
|
192
192
|
}
|
|
193
|
+
export interface ChatClientPersistence<TTools extends ReadonlyArray<AnyClientTool> = any> {
|
|
194
|
+
getItem: (id: string) => Array<UIMessage<TTools>> | null | undefined | Promise<Array<UIMessage<TTools>> | null | undefined>;
|
|
195
|
+
setItem: (id: string, messages: Array<UIMessage<TTools>>) => void | Promise<void>;
|
|
196
|
+
removeItem: (id: string) => void | Promise<void>;
|
|
197
|
+
}
|
|
193
198
|
type IsUnknown<T> = unknown extends T ? [T] extends [unknown] ? true : false : false;
|
|
194
199
|
type KnownContext<T> = IsUnknown<T> extends true ? never : T;
|
|
195
200
|
type MergeContext<TLeft, TRight> = [TLeft] extends [never] ? TRight : [TRight] extends [never] ? TLeft : TLeft & TRight;
|
|
@@ -228,6 +233,10 @@ export interface ChatClientBaseOptions<TTools extends ReadonlyArray<AnyClientToo
|
|
|
228
233
|
* Initial messages to populate the chat
|
|
229
234
|
*/
|
|
230
235
|
initialMessages?: Array<UIMessage<TTools>>;
|
|
236
|
+
/**
|
|
237
|
+
* Optional persistence adapter for chat messages.
|
|
238
|
+
*/
|
|
239
|
+
persistence?: ChatClientPersistence<TTools>;
|
|
231
240
|
/**
|
|
232
241
|
* Unique identifier for this chat instance
|
|
233
242
|
* Used for managing multiple chats
|
package/dist/esm/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sources":["../../src/types.ts"],"sourcesContent":["import type {\n AnyClientTool,\n AudioPart,\n ChunkStrategy,\n ContentPart,\n DocumentPart,\n ImagePart,\n InferToolInput,\n InferToolOutput,\n ModelMessage,\n StreamChunk,\n StructuredOutputPart,\n VideoPart,\n} from '@tanstack/ai/client'\nimport type { ConnectionAdapter } from './connection-adapters'\nimport type { AIDevtoolsClientMetadata } from './devtools'\nimport type { ChatDevtoolsBridgeFactory } from './devtools-noop'\n\nexport type { StructuredOutputPart } from '@tanstack/ai/client'\n\n/**\n * `messages` is the full UIMessage history (not a delta). `data` is the\n * merged body — `ChatClientOptions.body` plus any per-call data passed to\n * `sendMessage(...)`. `threadId` / `runId` are the AG-UI correlation ids\n * the chat client uses to track this turn — forward them to your server\n * if it needs to correlate requests.\n */\nexport interface ChatFetcherInput {\n messages: Array<UIMessage>\n data?: Record<string, unknown>\n threadId: string\n runId: string\n}\n\nexport interface ChatFetcherOptions {\n /** Fires when `stop()` is called or the request is superseded. */\n signal: AbortSignal\n}\n\n/**\n * Direct function that performs a chat request. Mirrors\n * `GenerationFetcher`. Returns either a `Response` (SSE body parsed by the\n * chat client) or an `AsyncIterable<StreamChunk>` (yielded directly). May\n * return the value synchronously, as a `Promise`, or as an async generator\n * (`async function*`) — the chat client awaits whichever shape is returned.\n *\n * @example\n * ```ts\n * useChat({\n * fetcher: ({ messages }, { signal }) =>\n * chatFn({ data: { messages }, signal }),\n * })\n * ```\n */\nexport type ChatFetcher = (\n input: ChatFetcherInput,\n options: ChatFetcherOptions,\n) =>\n | Response\n | AsyncIterable<StreamChunk>\n | Promise<Response | AsyncIterable<StreamChunk>>\n\n/**\n * Distributive `Omit` — applies `Omit<O, K>` per branch of a union so\n * discriminated unions survive omission. Plain `Omit` collapses unions\n * into a single object shape, which would erase the `ChatTransport` XOR\n * when framework hooks omit React-managed callbacks from\n * `ChatClientOptions`.\n */\nexport type DistributedOmit<\n TObject,\n TKeys extends keyof any,\n> = TObject extends unknown ? Omit<TObject, TKeys> : never\n\n/**\n * Discriminated union enforcing that exactly one of `connection` or\n * `fetcher` is provided. Mirrors `GenerationTransport`.\n */\nexport type ChatTransport =\n | { connection: ConnectionAdapter; fetcher?: never }\n | { fetcher: ChatFetcher; connection?: never }\n\n/**\n * Tool call states - track the lifecycle of a tool call\n */\nexport type ToolCallState =\n | 'awaiting-input' // Received start but no arguments yet\n | 'input-streaming' // Partial arguments received\n | 'input-complete' // All arguments received\n | 'approval-requested' // Waiting for user approval\n | 'approval-responded' // User has approved/denied\n | 'complete' // Result is complete\n\n/**\n * Tool result states - track the lifecycle of a tool result\n */\nexport type ToolResultState =\n | 'streaming' // Placeholder for future streamed output\n | 'complete' // Result is complete\n | 'error' // Error occurred\n\n/**\n * ChatClient state - track the lifecycle of a chat\n */\nexport type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error'\n\n/**\n * Connection lifecycle state for the subscription loop.\n */\nexport type ConnectionStatus =\n | 'disconnected'\n | 'connecting'\n | 'connected'\n | 'error'\n\n/**\n * Multimodal content input for sending messages with rich media.\n * Allows sending text, images, audio, video, and documents to the LLM.\n *\n * @example\n * ```ts\n * // Send an image with a question\n * client.sendMessage({\n * content: [\n * { type: 'text', content: 'What is in this image?' },\n * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }\n * ],\n * id: 'custom-message-id' // optional\n * })\n * ```\n */\nexport interface MultimodalContent {\n /**\n * The content of the message.\n * Can be a simple string or an array of content parts for multimodal messages.\n */\n content: string | Array<ContentPart>\n /**\n * Optional custom ID for the message.\n * If not provided, a unique ID will be generated.\n */\n id?: string\n}\n\n/**\n * Message parts - building blocks of UIMessage\n */\nexport interface TextPart {\n type: 'text'\n content: string\n}\n\n/**\n * Helper type that creates a tool-call part for a specific tool.\n * This is a conditional type to enable proper distribution over union types,\n * creating a discriminated union where `name` is the discriminant.\n */\ntype ToolCallPartForTool<T> = T extends AnyClientTool\n ? {\n type: 'tool-call'\n id: string\n name: T['name']\n arguments: string // JSON string (may be incomplete)\n /** Parsed tool input (typed from inputSchema) */\n input?: InferToolInput<T>\n state: ToolCallState\n /** Approval metadata if tool requires user approval */\n approval?: {\n id: string // Unique approval ID\n needsApproval: boolean // Always true if present\n approved?: boolean // User's decision (undefined until responded)\n }\n /** Tool execution output (for client tools or after approval) */\n output?: InferToolOutput<T>\n }\n : never\n\n/**\n * Fallback tool-call part type when tools are not typed\n */\ntype UntypedToolCallPart = {\n type: 'tool-call'\n id: string\n name: string\n arguments: string\n input?: any\n state: ToolCallState\n approval?: {\n id: string\n needsApproval: boolean\n approved?: boolean\n }\n output?: any\n}\n\n/**\n * Tool call part that creates a proper discriminated union.\n * When TTools is typed, checking `part.name === 'toolName'` will narrow\n * `part.output` to the correct type for that tool.\n *\n * The discriminant is `name`, so code like:\n * ```ts\n * if (part.name === 'recommendGuitar') {\n * // part.output is now typed to the recommendGuitar tool's output\n * }\n * ```\n */\nexport type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =\n // Check if we have a concrete tools array (not 'any' or 'never')\n [TTools] extends [never]\n ? UntypedToolCallPart\n : unknown extends TTools\n ? UntypedToolCallPart\n : TTools extends ReadonlyArray<infer Tool>\n ? Tool extends AnyClientTool\n ? ToolCallPartForTool<Tool>\n : UntypedToolCallPart\n : UntypedToolCallPart\n\nexport interface ToolResultPart {\n type: 'tool-result'\n toolCallId: string\n content: string | Array<ContentPart>\n state: ToolResultState\n error?: string // Error message if state is \"error\"\n}\n\nexport interface ThinkingPart {\n type: 'thinking'\n content: string\n}\n\nexport type MessagePart<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> =\n | TextPart\n | ImagePart\n | AudioPart\n | VideoPart\n | DocumentPart\n | ToolCallPart<TTools>\n | ToolResultPart\n | ThinkingPart\n | StructuredOutputPart<TData>\n\n/**\n * UIMessage - Domain-specific message format optimized for building chat UIs\n * Contains parts that can be text, tool calls, or tool results.\n *\n * `TTools` narrows the tool-call/result part types based on the registered\n * tools. `TData` is the schema-inferred type for any `structured-output` part\n * on the message — defaulted to `unknown` so untyped consumers (the core\n * stream processor, the wire converter) don't need to thread a schema generic\n * everywhere; the hook layer (`useChat({ outputSchema })`) substitutes it on\n * the public return so `m.parts.find(p => p.type === 'structured-output').data`\n * is typed without manual casts.\n */\nexport interface UIMessage<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> {\n id: string\n role: 'system' | 'user' | 'assistant'\n parts: Array<MessagePart<TTools, TData>>\n createdAt?: Date\n}\n\ntype IsUnknown<T> = unknown extends T\n ? [T] extends [unknown]\n ? true\n : false\n : false\n\ntype KnownContext<T> = IsUnknown<T> extends true ? never : T\n\ntype MergeContext<TLeft, TRight> = [TLeft] extends [never]\n ? TRight\n : [TRight] extends [never]\n ? TLeft\n : TLeft & TRight\n\ntype UnionToIntersection<T> = [T] extends [never]\n ? never\n : (T extends unknown ? (value: T) => void : never) extends (\n value: infer TIntersection,\n ) => void\n ? TIntersection\n : never\n\ntype DefinedContext<T> = Exclude<T, undefined>\n\ntype ContextFromExecute<T> = T extends (...args: any) => any\n ? NonNullable<Parameters<T>[1]> extends { context: infer TContext }\n ? KnownContext<TContext>\n : never\n : never\n\ntype ContextFromClientTool<T> = T extends AnyClientTool\n ? T extends { execute?: infer TExecute }\n ? ContextFromExecute<TExecute>\n : never\n : never\n\ntype RequiredContextFromClientToolUnion<T> = T extends unknown\n ? undefined extends ContextFromClientTool<T>\n ? never\n : ContextFromClientTool<T>\n : never\n\ntype ContextFromClientToolUnion<T> = [\n UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>,\n] extends [never]\n ? never\n : [RequiredContextFromClientToolUnion<T>] extends [never]\n ? UnionToIntersection<DefinedContext<ContextFromClientTool<T>>> | undefined\n : UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>\n\ntype ContextFromClientTools<TTools> =\n IsUnknown<TTools> extends true\n ? never\n : TTools extends readonly [infer THead, ...infer TTail]\n ? MergeContext<\n ContextFromClientTool<THead>,\n ContextFromClientTools<TTail>\n >\n : TTools extends ReadonlyArray<infer TItem>\n ? ContextFromClientToolUnion<TItem>\n : never\n\nexport type InferredClientContext<TTools> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? unknown\n : ContextFromClientTools<TTools>\n\nexport type ClientContextOptionFromTools<TTools, TContext> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? { context?: TContext }\n : undefined extends ContextFromClientTools<TTools>\n ? { context?: TContext & ContextFromClientTools<TTools> }\n : { context: TContext & ContextFromClientTools<TTools> }\n\n/**\n * Base options for `ChatClient`, excluding the transport (`connection` or\n * `fetcher`) which is supplied separately via `ChatTransport` so the XOR\n * is preserved when composing the final `ChatClientOptions` type.\n */\nexport interface ChatClientBaseOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = unknown,\n> {\n /**\n * Initial messages to populate the chat\n */\n initialMessages?: Array<UIMessage<TTools>>\n\n /**\n * Unique identifier for this chat instance\n * Used for managing multiple chats\n */\n id?: string\n\n /**\n * Thread ID to use for this chat session. Persists across sends within\n * the session. If omitted, a unique thread ID is generated.\n */\n threadId?: string\n\n /**\n * Arbitrary client-controlled JSON forwarded to the server in the\n * AG-UI `RunAgentInput.forwardedProps` field. Use this for per-session\n * options like provider/model selection or feature flags that the\n * server endpoint should read.\n *\n * Replaces the legacy `body` option. If both are provided,\n * `forwardedProps` wins on key collision.\n */\n forwardedProps?: Record<string, any>\n\n /**\n * @deprecated Use `forwardedProps` instead. `body` continues to work\n * unchanged — its values are merged into the AG-UI\n * `RunAgentInput.forwardedProps` field on the wire and are also\n * mirrored under the legacy `data` field for servers that have not\n * migrated yet. Will be removed in a future major release.\n */\n body?: Record<string, any>\n\n /**\n * Client-local runtime context passed to client tool implementations.\n *\n * This value is not serialized to the server. Use `forwardedProps` for\n * explicit client-to-server handoff of serializable values.\n */\n context?: TContext\n\n /**\n * Callback when a response is received\n */\n onResponse?: (response?: Response) => void | Promise<void>\n\n /**\n * Callback when a stream chunk is received\n */\n onChunk?: (chunk: StreamChunk) => void\n\n /**\n * Callback when the response is finished\n */\n onFinish?: (message: UIMessage<TTools>) => void\n\n /**\n * Callback when an error occurs\n */\n onError?: (error: Error) => void\n\n /**\n * Callback when messages change\n */\n onMessagesChange?: (messages: Array<UIMessage<TTools>>) => void\n\n /**\n * Callback when loading state changes\n */\n onLoadingChange?: (isLoading: boolean) => void\n\n /**\n * Callback when error state changes\n */\n onErrorChange?: (error: Error | undefined) => void\n\n /**\n * Callback when chat status changes\n */\n onStatusChange?: (status: ChatClientState) => void\n\n /**\n * Callback when subscription lifecycle changes.\n * This is independent from request lifecycle (`isLoading`, `status`).\n */\n onSubscriptionChange?: (isSubscribed: boolean) => void\n\n /**\n * Callback when connection lifecycle changes.\n */\n onConnectionStatusChange?: (status: ConnectionStatus) => void\n\n /**\n * Callback when session generation activity changes.\n * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).\n * Unlike `onLoadingChange` (request-local), this reflects shared generation\n * activity visible to all subscribers (e.g. across tabs/devices).\n */\n onSessionGeneratingChange?: (isGenerating: boolean) => void\n\n /**\n * Callback when a custom event is received from a server-side tool.\n * Custom events are emitted by tools using `context.emitCustomEvent()` during execution.\n *\n * @param eventType - The name of the custom event\n * @param data - The event payload data\n * @param context - Additional context including the toolCallId that emitted the event\n */\n onCustomEvent?: (\n eventType: string,\n data: unknown,\n context: { toolCallId?: string },\n ) => void\n\n /**\n * Client-side tools with execution logic\n * When provided, tools with execute functions will be called automatically\n */\n tools?: TTools\n\n /**\n * Devtools hook metadata for this client instance.\n */\n devtools?: Partial<AIDevtoolsClientMetadata>\n\n /**\n * Factory that constructs the devtools bridge. Default is a no-op\n * factory, which keeps `@tanstack/ai-client/devtools` (the heavy\n * bridge implementation) out of the main entry's bundle. Frameworks\n * that need live devtools should pass the real factory from\n * `@tanstack/ai-client/devtools`.\n */\n devtoolsBridgeFactory?: ChatDevtoolsBridgeFactory\n\n /**\n * Stream processing options (optional)\n * Configure chunking strategy\n */\n streamProcessor?: {\n /**\n * Strategy for when to emit text updates\n * Defaults to ImmediateStrategy (every chunk)\n */\n chunkStrategy?: ChunkStrategy\n }\n}\n\n/**\n * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be\n * provided — the type-level XOR is enforced via `ChatTransport`.\n */\nexport type ChatClientOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = InferredClientContext<TTools>,\n> = DistributedOmit<ChatClientBaseOptions<TTools, TContext>, 'context'> &\n ClientContextOptionFromTools<TTools, TContext> &\n ChatTransport\n\nexport interface ChatRequestBody {\n messages: Array<ModelMessage>\n data?: Record<string, any>\n}\n\n/**\n * Create a typed array of client tools with proper type inference.\n * This eliminates the need for `as const` when defining tool arrays.\n *\n * @example\n * ```ts\n * const tools = clientTools(\n * myTool1.client(() => result1),\n * myTool2.client(() => result2),\n * )\n *\n * // tools is now properly typed as a tuple with literal tool names\n * // This enables type narrowing when checking part.name === 'toolName'\n * ```\n */\nexport function clientTools<const T extends Array<AnyClientTool>>(\n ...tools: T\n): T {\n return tools\n}\n\n/**\n * Helper to create typed chat client options\n * Use this to get proper type inference for messages\n *\n * @example\n * ```ts\n * const tools = clientTools(myTool1, myTool2)\n *\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools,\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * ```\n */\nexport function createChatClientOptions<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(\n options: ChatClientOptions<TTools, TContext>,\n): ChatClientOptions<TTools, TContext> {\n return options\n}\n\n/**\n * Extract the message type from chat options\n *\n * @example\n * ```ts\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools: [myTool1, myTool2],\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * // MyMessages is now Array<UIMessage<[typeof myTool1, typeof myTool2]>>\n * ```\n */\nexport type InferChatMessages<T> =\n T extends ChatClientOptions<infer TTools, any>\n ? Array<UIMessage<TTools>>\n : never\n"],"names":[],"mappings":"AAuhBO,SAAS,eACX,OACA;AACH,SAAO;AACT;AAkBO,SAAS,wBAId,SACqC;AACrC,SAAO;AACT;"}
|
|
1
|
+
{"version":3,"file":"types.js","sources":["../../src/types.ts"],"sourcesContent":["import type {\n AnyClientTool,\n AudioPart,\n ChunkStrategy,\n ContentPart,\n DocumentPart,\n ImagePart,\n InferToolInput,\n InferToolOutput,\n ModelMessage,\n StreamChunk,\n StructuredOutputPart,\n VideoPart,\n} from '@tanstack/ai/client'\nimport type { ConnectionAdapter } from './connection-adapters'\nimport type { AIDevtoolsClientMetadata } from './devtools'\nimport type { ChatDevtoolsBridgeFactory } from './devtools-noop'\n\nexport type { StructuredOutputPart } from '@tanstack/ai/client'\n\n/**\n * `messages` is the full UIMessage history (not a delta). `data` is the\n * merged body — `ChatClientOptions.body` plus any per-call data passed to\n * `sendMessage(...)`. `threadId` / `runId` are the AG-UI correlation ids\n * the chat client uses to track this turn — forward them to your server\n * if it needs to correlate requests.\n */\nexport interface ChatFetcherInput {\n messages: Array<UIMessage>\n data?: Record<string, unknown>\n threadId: string\n runId: string\n}\n\nexport interface ChatFetcherOptions {\n /** Fires when `stop()` is called or the request is superseded. */\n signal: AbortSignal\n}\n\n/**\n * Direct function that performs a chat request. Mirrors\n * `GenerationFetcher`. Returns either a `Response` (SSE body parsed by the\n * chat client) or an `AsyncIterable<StreamChunk>` (yielded directly). May\n * return the value synchronously, as a `Promise`, or as an async generator\n * (`async function*`) — the chat client awaits whichever shape is returned.\n *\n * @example\n * ```ts\n * useChat({\n * fetcher: ({ messages }, { signal }) =>\n * chatFn({ data: { messages }, signal }),\n * })\n * ```\n */\nexport type ChatFetcher = (\n input: ChatFetcherInput,\n options: ChatFetcherOptions,\n) =>\n | Response\n | AsyncIterable<StreamChunk>\n | Promise<Response | AsyncIterable<StreamChunk>>\n\n/**\n * Distributive `Omit` — applies `Omit<O, K>` per branch of a union so\n * discriminated unions survive omission. Plain `Omit` collapses unions\n * into a single object shape, which would erase the `ChatTransport` XOR\n * when framework hooks omit React-managed callbacks from\n * `ChatClientOptions`.\n */\nexport type DistributedOmit<\n TObject,\n TKeys extends keyof any,\n> = TObject extends unknown ? Omit<TObject, TKeys> : never\n\n/**\n * Discriminated union enforcing that exactly one of `connection` or\n * `fetcher` is provided. Mirrors `GenerationTransport`.\n */\nexport type ChatTransport =\n | { connection: ConnectionAdapter; fetcher?: never }\n | { fetcher: ChatFetcher; connection?: never }\n\n/**\n * Tool call states - track the lifecycle of a tool call\n */\nexport type ToolCallState =\n | 'awaiting-input' // Received start but no arguments yet\n | 'input-streaming' // Partial arguments received\n | 'input-complete' // All arguments received\n | 'approval-requested' // Waiting for user approval\n | 'approval-responded' // User has approved/denied\n | 'complete' // Result is complete\n\n/**\n * Tool result states - track the lifecycle of a tool result\n */\nexport type ToolResultState =\n | 'streaming' // Placeholder for future streamed output\n | 'complete' // Result is complete\n | 'error' // Error occurred\n\n/**\n * ChatClient state - track the lifecycle of a chat\n */\nexport type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error'\n\n/**\n * Connection lifecycle state for the subscription loop.\n */\nexport type ConnectionStatus =\n | 'disconnected'\n | 'connecting'\n | 'connected'\n | 'error'\n\n/**\n * Multimodal content input for sending messages with rich media.\n * Allows sending text, images, audio, video, and documents to the LLM.\n *\n * @example\n * ```ts\n * // Send an image with a question\n * client.sendMessage({\n * content: [\n * { type: 'text', content: 'What is in this image?' },\n * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }\n * ],\n * id: 'custom-message-id' // optional\n * })\n * ```\n */\nexport interface MultimodalContent {\n /**\n * The content of the message.\n * Can be a simple string or an array of content parts for multimodal messages.\n */\n content: string | Array<ContentPart>\n /**\n * Optional custom ID for the message.\n * If not provided, a unique ID will be generated.\n */\n id?: string\n}\n\n/**\n * Message parts - building blocks of UIMessage\n */\nexport interface TextPart {\n type: 'text'\n content: string\n}\n\n/**\n * Helper type that creates a tool-call part for a specific tool.\n * This is a conditional type to enable proper distribution over union types,\n * creating a discriminated union where `name` is the discriminant.\n */\ntype ToolCallPartForTool<T> = T extends AnyClientTool\n ? {\n type: 'tool-call'\n id: string\n name: T['name']\n arguments: string // JSON string (may be incomplete)\n /** Parsed tool input (typed from inputSchema) */\n input?: InferToolInput<T>\n state: ToolCallState\n /** Approval metadata if tool requires user approval */\n approval?: {\n id: string // Unique approval ID\n needsApproval: boolean // Always true if present\n approved?: boolean // User's decision (undefined until responded)\n }\n /** Tool execution output (for client tools or after approval) */\n output?: InferToolOutput<T>\n }\n : never\n\n/**\n * Fallback tool-call part type when tools are not typed\n */\ntype UntypedToolCallPart = {\n type: 'tool-call'\n id: string\n name: string\n arguments: string\n input?: any\n state: ToolCallState\n approval?: {\n id: string\n needsApproval: boolean\n approved?: boolean\n }\n output?: any\n}\n\n/**\n * Tool call part that creates a proper discriminated union.\n * When TTools is typed, checking `part.name === 'toolName'` will narrow\n * `part.output` to the correct type for that tool.\n *\n * The discriminant is `name`, so code like:\n * ```ts\n * if (part.name === 'recommendGuitar') {\n * // part.output is now typed to the recommendGuitar tool's output\n * }\n * ```\n */\nexport type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =\n // Check if we have a concrete tools array (not 'any' or 'never')\n [TTools] extends [never]\n ? UntypedToolCallPart\n : unknown extends TTools\n ? UntypedToolCallPart\n : TTools extends ReadonlyArray<infer Tool>\n ? Tool extends AnyClientTool\n ? ToolCallPartForTool<Tool>\n : UntypedToolCallPart\n : UntypedToolCallPart\n\nexport interface ToolResultPart {\n type: 'tool-result'\n toolCallId: string\n content: string | Array<ContentPart>\n state: ToolResultState\n error?: string // Error message if state is \"error\"\n}\n\nexport interface ThinkingPart {\n type: 'thinking'\n content: string\n}\n\nexport type MessagePart<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> =\n | TextPart\n | ImagePart\n | AudioPart\n | VideoPart\n | DocumentPart\n | ToolCallPart<TTools>\n | ToolResultPart\n | ThinkingPart\n | StructuredOutputPart<TData>\n\n/**\n * UIMessage - Domain-specific message format optimized for building chat UIs\n * Contains parts that can be text, tool calls, or tool results.\n *\n * `TTools` narrows the tool-call/result part types based on the registered\n * tools. `TData` is the schema-inferred type for any `structured-output` part\n * on the message — defaulted to `unknown` so untyped consumers (the core\n * stream processor, the wire converter) don't need to thread a schema generic\n * everywhere; the hook layer (`useChat({ outputSchema })`) substitutes it on\n * the public return so `m.parts.find(p => p.type === 'structured-output').data`\n * is typed without manual casts.\n */\nexport interface UIMessage<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> {\n id: string\n role: 'system' | 'user' | 'assistant'\n parts: Array<MessagePart<TTools, TData>>\n createdAt?: Date\n}\n\nexport interface ChatClientPersistence<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> {\n getItem: (\n id: string,\n ) =>\n | Array<UIMessage<TTools>>\n | null\n | undefined\n | Promise<Array<UIMessage<TTools>> | null | undefined>\n setItem: (\n id: string,\n messages: Array<UIMessage<TTools>>,\n ) => void | Promise<void>\n removeItem: (id: string) => void | Promise<void>\n}\n\ntype IsUnknown<T> = unknown extends T\n ? [T] extends [unknown]\n ? true\n : false\n : false\n\ntype KnownContext<T> = IsUnknown<T> extends true ? never : T\n\ntype MergeContext<TLeft, TRight> = [TLeft] extends [never]\n ? TRight\n : [TRight] extends [never]\n ? TLeft\n : TLeft & TRight\n\ntype UnionToIntersection<T> = [T] extends [never]\n ? never\n : (T extends unknown ? (value: T) => void : never) extends (\n value: infer TIntersection,\n ) => void\n ? TIntersection\n : never\n\ntype DefinedContext<T> = Exclude<T, undefined>\n\ntype ContextFromExecute<T> = T extends (...args: any) => any\n ? NonNullable<Parameters<T>[1]> extends { context: infer TContext }\n ? KnownContext<TContext>\n : never\n : never\n\ntype ContextFromClientTool<T> = T extends AnyClientTool\n ? T extends { execute?: infer TExecute }\n ? ContextFromExecute<TExecute>\n : never\n : never\n\ntype RequiredContextFromClientToolUnion<T> = T extends unknown\n ? undefined extends ContextFromClientTool<T>\n ? never\n : ContextFromClientTool<T>\n : never\n\ntype ContextFromClientToolUnion<T> = [\n UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>,\n] extends [never]\n ? never\n : [RequiredContextFromClientToolUnion<T>] extends [never]\n ? UnionToIntersection<DefinedContext<ContextFromClientTool<T>>> | undefined\n : UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>\n\ntype ContextFromClientTools<TTools> =\n IsUnknown<TTools> extends true\n ? never\n : TTools extends readonly [infer THead, ...infer TTail]\n ? MergeContext<\n ContextFromClientTool<THead>,\n ContextFromClientTools<TTail>\n >\n : TTools extends ReadonlyArray<infer TItem>\n ? ContextFromClientToolUnion<TItem>\n : never\n\nexport type InferredClientContext<TTools> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? unknown\n : ContextFromClientTools<TTools>\n\nexport type ClientContextOptionFromTools<TTools, TContext> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? { context?: TContext }\n : undefined extends ContextFromClientTools<TTools>\n ? { context?: TContext & ContextFromClientTools<TTools> }\n : { context: TContext & ContextFromClientTools<TTools> }\n\n/**\n * Base options for `ChatClient`, excluding the transport (`connection` or\n * `fetcher`) which is supplied separately via `ChatTransport` so the XOR\n * is preserved when composing the final `ChatClientOptions` type.\n */\nexport interface ChatClientBaseOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = unknown,\n> {\n /**\n * Initial messages to populate the chat\n */\n initialMessages?: Array<UIMessage<TTools>>\n\n /**\n * Optional persistence adapter for chat messages.\n */\n persistence?: ChatClientPersistence<TTools>\n\n /**\n * Unique identifier for this chat instance\n * Used for managing multiple chats\n */\n id?: string\n\n /**\n * Thread ID to use for this chat session. Persists across sends within\n * the session. If omitted, a unique thread ID is generated.\n */\n threadId?: string\n\n /**\n * Arbitrary client-controlled JSON forwarded to the server in the\n * AG-UI `RunAgentInput.forwardedProps` field. Use this for per-session\n * options like provider/model selection or feature flags that the\n * server endpoint should read.\n *\n * Replaces the legacy `body` option. If both are provided,\n * `forwardedProps` wins on key collision.\n */\n forwardedProps?: Record<string, any>\n\n /**\n * @deprecated Use `forwardedProps` instead. `body` continues to work\n * unchanged — its values are merged into the AG-UI\n * `RunAgentInput.forwardedProps` field on the wire and are also\n * mirrored under the legacy `data` field for servers that have not\n * migrated yet. Will be removed in a future major release.\n */\n body?: Record<string, any>\n\n /**\n * Client-local runtime context passed to client tool implementations.\n *\n * This value is not serialized to the server. Use `forwardedProps` for\n * explicit client-to-server handoff of serializable values.\n */\n context?: TContext\n\n /**\n * Callback when a response is received\n */\n onResponse?: (response?: Response) => void | Promise<void>\n\n /**\n * Callback when a stream chunk is received\n */\n onChunk?: (chunk: StreamChunk) => void\n\n /**\n * Callback when the response is finished\n */\n onFinish?: (message: UIMessage<TTools>) => void\n\n /**\n * Callback when an error occurs\n */\n onError?: (error: Error) => void\n\n /**\n * Callback when messages change\n */\n onMessagesChange?: (messages: Array<UIMessage<TTools>>) => void\n\n /**\n * Callback when loading state changes\n */\n onLoadingChange?: (isLoading: boolean) => void\n\n /**\n * Callback when error state changes\n */\n onErrorChange?: (error: Error | undefined) => void\n\n /**\n * Callback when chat status changes\n */\n onStatusChange?: (status: ChatClientState) => void\n\n /**\n * Callback when subscription lifecycle changes.\n * This is independent from request lifecycle (`isLoading`, `status`).\n */\n onSubscriptionChange?: (isSubscribed: boolean) => void\n\n /**\n * Callback when connection lifecycle changes.\n */\n onConnectionStatusChange?: (status: ConnectionStatus) => void\n\n /**\n * Callback when session generation activity changes.\n * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).\n * Unlike `onLoadingChange` (request-local), this reflects shared generation\n * activity visible to all subscribers (e.g. across tabs/devices).\n */\n onSessionGeneratingChange?: (isGenerating: boolean) => void\n\n /**\n * Callback when a custom event is received from a server-side tool.\n * Custom events are emitted by tools using `context.emitCustomEvent()` during execution.\n *\n * @param eventType - The name of the custom event\n * @param data - The event payload data\n * @param context - Additional context including the toolCallId that emitted the event\n */\n onCustomEvent?: (\n eventType: string,\n data: unknown,\n context: { toolCallId?: string },\n ) => void\n\n /**\n * Client-side tools with execution logic\n * When provided, tools with execute functions will be called automatically\n */\n tools?: TTools\n\n /**\n * Devtools hook metadata for this client instance.\n */\n devtools?: Partial<AIDevtoolsClientMetadata>\n\n /**\n * Factory that constructs the devtools bridge. Default is a no-op\n * factory, which keeps `@tanstack/ai-client/devtools` (the heavy\n * bridge implementation) out of the main entry's bundle. Frameworks\n * that need live devtools should pass the real factory from\n * `@tanstack/ai-client/devtools`.\n */\n devtoolsBridgeFactory?: ChatDevtoolsBridgeFactory\n\n /**\n * Stream processing options (optional)\n * Configure chunking strategy\n */\n streamProcessor?: {\n /**\n * Strategy for when to emit text updates\n * Defaults to ImmediateStrategy (every chunk)\n */\n chunkStrategy?: ChunkStrategy\n }\n}\n\n/**\n * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be\n * provided — the type-level XOR is enforced via `ChatTransport`.\n */\nexport type ChatClientOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = InferredClientContext<TTools>,\n> = DistributedOmit<ChatClientBaseOptions<TTools, TContext>, 'context'> &\n ClientContextOptionFromTools<TTools, TContext> &\n ChatTransport\n\nexport interface ChatRequestBody {\n messages: Array<ModelMessage>\n data?: Record<string, any>\n}\n\n/**\n * Create a typed array of client tools with proper type inference.\n * This eliminates the need for `as const` when defining tool arrays.\n *\n * @example\n * ```ts\n * const tools = clientTools(\n * myTool1.client(() => result1),\n * myTool2.client(() => result2),\n * )\n *\n * // tools is now properly typed as a tuple with literal tool names\n * // This enables type narrowing when checking part.name === 'toolName'\n * ```\n */\nexport function clientTools<const T extends Array<AnyClientTool>>(\n ...tools: T\n): T {\n return tools\n}\n\n/**\n * Helper to create typed chat client options\n * Use this to get proper type inference for messages\n *\n * @example\n * ```ts\n * const tools = clientTools(myTool1, myTool2)\n *\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools,\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * ```\n */\nexport function createChatClientOptions<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(\n options: ChatClientOptions<TTools, TContext>,\n): ChatClientOptions<TTools, TContext> {\n return options\n}\n\n/**\n * Extract the message type from chat options\n *\n * @example\n * ```ts\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools: [myTool1, myTool2],\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * // MyMessages is now Array<UIMessage<[typeof myTool1, typeof myTool2]>>\n * ```\n */\nexport type InferChatMessages<T> =\n T extends ChatClientOptions<infer TTools, any>\n ? Array<UIMessage<TTools>>\n : never\n"],"names":[],"mappings":"AA6iBO,SAAS,eACX,OACA;AACH,SAAO;AACT;AAkBO,SAAS,wBAId,SACqC;AACrC,SAAO;AACT;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.2",
|
|
4
4
|
"description": "Framework-agnostic headless client for TanStack AI chat, realtime sessions, streaming transports, and media generations.",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"src"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@tanstack/ai": "0.
|
|
45
|
-
"@tanstack/ai-event-client": "0.5.
|
|
44
|
+
"@tanstack/ai": "0.27.0",
|
|
45
|
+
"@tanstack/ai-event-client": "0.5.3"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@standard-schema/spec": "^1.1.0",
|
package/src/chat-client.ts
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
import { createNoOpChatDevtoolsBridge } from './devtools-noop'
|
|
10
10
|
import {
|
|
11
11
|
fetcherToConnectionAdapter,
|
|
12
|
+
getChunkRunId,
|
|
12
13
|
normalizeConnectionAdapter,
|
|
13
14
|
} from './connection-adapters'
|
|
15
|
+
import { ChatPersistor } from './client-persistor'
|
|
14
16
|
import type {
|
|
15
17
|
AnyClientTool,
|
|
16
18
|
ContentPart,
|
|
@@ -95,6 +97,11 @@ export class ChatClient<
|
|
|
95
97
|
private connection: SubscribeConnectionAdapter
|
|
96
98
|
private readonly uniqueId: string
|
|
97
99
|
private readonly threadId: string
|
|
100
|
+
// All persistence concerns (hydrate / save / clear, plus suppression of late
|
|
101
|
+
// chunks after a mid-stream clear) live in ChatPersistor so this class stays
|
|
102
|
+
// focused on streaming. Undefined when no `persistence` adapter is configured.
|
|
103
|
+
private readonly persistor?: ChatPersistor
|
|
104
|
+
private currentRunId: string | null = null
|
|
98
105
|
// Track the legacy `body` option and the canonical `forwardedProps`
|
|
99
106
|
// option as separate slots so that `updateOptions({ forwardedProps })`
|
|
100
107
|
// doesn't wipe a previously-set `body` (and vice versa). They are
|
|
@@ -163,6 +170,13 @@ export class ChatClient<
|
|
|
163
170
|
constructor(options: ChatClientOptions<TTools, TContext>) {
|
|
164
171
|
this.uniqueId = options.id || this.generateUniqueId('chat')
|
|
165
172
|
this.threadId = options.threadId || this.generateUniqueId('thread')
|
|
173
|
+
if (options.persistence) {
|
|
174
|
+
this.persistor = new ChatPersistor(
|
|
175
|
+
options.persistence,
|
|
176
|
+
this.uniqueId,
|
|
177
|
+
(messages) => this.processor.setMessages(messages),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
166
180
|
// Both `body` (deprecated) and `forwardedProps` populate the AG-UI
|
|
167
181
|
// `RunAgentInput.forwardedProps` wire field. They are stored
|
|
168
182
|
// separately so `updateOptions` can replace one without touching the
|
|
@@ -208,15 +222,19 @@ export class ChatClient<
|
|
|
208
222
|
// Create StreamProcessor with event handlers.
|
|
209
223
|
// Use conditional spreads so we don't pass `undefined` into
|
|
210
224
|
// `StreamProcessorOptions` fields under `exactOptionalPropertyTypes`.
|
|
225
|
+
const persistedMessages = this.persistor?.readInitial()
|
|
226
|
+
const initialMessages = Array.isArray(persistedMessages)
|
|
227
|
+
? persistedMessages
|
|
228
|
+
: options.initialMessages
|
|
229
|
+
|
|
211
230
|
this.processor = new StreamProcessor({
|
|
212
231
|
...(options.streamProcessor?.chunkStrategy
|
|
213
232
|
? { chunkStrategy: options.streamProcessor.chunkStrategy }
|
|
214
233
|
: {}),
|
|
215
|
-
...(
|
|
216
|
-
? { initialMessages: options.initialMessages }
|
|
217
|
-
: {}),
|
|
234
|
+
...(initialMessages ? { initialMessages } : {}),
|
|
218
235
|
events: {
|
|
219
236
|
onMessagesChange: (messages: Array<UIMessage>) => {
|
|
237
|
+
this.persistor?.notifyMessagesChanged(messages)
|
|
220
238
|
this.callbacksRef.current.onMessagesChange(messages)
|
|
221
239
|
},
|
|
222
240
|
onStreamStart: () => {
|
|
@@ -413,6 +431,8 @@ export class ChatClient<
|
|
|
413
431
|
},
|
|
414
432
|
},
|
|
415
433
|
})
|
|
434
|
+
|
|
435
|
+
this.persistor?.hydrateAsync(persistedMessages)
|
|
416
436
|
}
|
|
417
437
|
|
|
418
438
|
mountDevtools(): void {
|
|
@@ -424,6 +444,51 @@ export class ChatClient<
|
|
|
424
444
|
this.devtoolsBridge.mountWithTools(this.processor.getMessages().length)
|
|
425
445
|
}
|
|
426
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Drain a runId-less RUN_ERROR that belongs to a cleared run the client is
|
|
449
|
+
* still tracking. The persistor owns the cleared-run bookkeeping; the client
|
|
450
|
+
* owns the active-run / session / processing state.
|
|
451
|
+
*/
|
|
452
|
+
private drainIgnoredRunlessChunk(chunk: StreamChunk): void {
|
|
453
|
+
if (chunk.type !== 'RUN_ERROR') return
|
|
454
|
+
const runId = this.persistor?.takeRunlessRunId()
|
|
455
|
+
if (!runId) return
|
|
456
|
+
this.activeRunIds.delete(runId)
|
|
457
|
+
this.setSessionGenerating(this.activeRunIds.size > 0)
|
|
458
|
+
this.resolveProcessing()
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private updateRunLifecycle(
|
|
462
|
+
chunk: StreamChunk,
|
|
463
|
+
options?: { resolveProcessing?: boolean },
|
|
464
|
+
): void {
|
|
465
|
+
if (chunk.type === 'RUN_STARTED') {
|
|
466
|
+
const chunkRunId = getChunkRunId(chunk) ?? chunk.runId
|
|
467
|
+
this.activeRunIds.add(chunkRunId)
|
|
468
|
+
this.persistor?.onRunStarted(chunkRunId)
|
|
469
|
+
this.setSessionGenerating(true)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') {
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const runId = getChunkRunId(chunk)
|
|
478
|
+
if (runId) {
|
|
479
|
+
this.activeRunIds.delete(runId)
|
|
480
|
+
this.persistor?.onRunSettled(runId)
|
|
481
|
+
} else if (chunk.type === 'RUN_ERROR') {
|
|
482
|
+
// RUN_ERROR without runId is a session-level error; clear all runs.
|
|
483
|
+
this.activeRunIds.clear()
|
|
484
|
+
this.persistor?.onSessionRunError()
|
|
485
|
+
}
|
|
486
|
+
this.setSessionGenerating(this.activeRunIds.size > 0)
|
|
487
|
+
if (options?.resolveProcessing !== false) {
|
|
488
|
+
this.resolveProcessing()
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
427
492
|
private generateUniqueId(prefix: string): string {
|
|
428
493
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
|
|
429
494
|
}
|
|
@@ -461,6 +526,7 @@ export class ChatClient<
|
|
|
461
526
|
|
|
462
527
|
private resetSessionGenerating(): void {
|
|
463
528
|
this.activeRunIds.clear()
|
|
529
|
+
this.persistor?.resetIgnored()
|
|
464
530
|
this.setSessionGenerating(false)
|
|
465
531
|
}
|
|
466
532
|
|
|
@@ -609,33 +675,27 @@ export class ChatClient<
|
|
|
609
675
|
if (this.connectionStatus === 'connecting') {
|
|
610
676
|
this.setConnectionStatus('connected')
|
|
611
677
|
}
|
|
612
|
-
this.
|
|
613
|
-
if (
|
|
614
|
-
|
|
615
|
-
|
|
678
|
+
const shouldIgnore = this.persistor?.shouldIgnoreChunk(chunk) ?? false
|
|
679
|
+
if (shouldIgnore) {
|
|
680
|
+
if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
|
|
681
|
+
if (getChunkRunId(chunk)) {
|
|
682
|
+
this.updateRunLifecycle(chunk, { resolveProcessing: false })
|
|
683
|
+
} else {
|
|
684
|
+
this.drainIgnoredRunlessChunk(chunk)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
continue
|
|
616
688
|
}
|
|
689
|
+
this.callbacksRef.current.onChunk(chunk)
|
|
617
690
|
this.devtoolsBridge.observeChunk(chunk)
|
|
618
691
|
this.processor.processChunk(chunk)
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const runId =
|
|
627
|
-
'runId' in chunk && typeof chunk.runId === 'string'
|
|
628
|
-
? chunk.runId
|
|
629
|
-
: undefined
|
|
630
|
-
if (runId) {
|
|
631
|
-
this.activeRunIds.delete(runId)
|
|
632
|
-
} else if (chunk.type === 'RUN_ERROR') {
|
|
633
|
-
// RUN_ERROR without runId is a session-level error; clear all runs
|
|
634
|
-
this.activeRunIds.clear()
|
|
635
|
-
}
|
|
636
|
-
this.setSessionGenerating(this.activeRunIds.size > 0)
|
|
637
|
-
this.resolveProcessing()
|
|
638
|
-
}
|
|
692
|
+
// Run lifecycle (active-run tracking, session-generating state, and
|
|
693
|
+
// processing resolution for RUN_FINISHED / RUN_ERROR) is handled in a
|
|
694
|
+
// single place so the ignored-chunk path above and this path can't
|
|
695
|
+
// diverge. RUN_ERROR carries its runId via the AG-UI passthrough so a
|
|
696
|
+
// per-run error only clears that run, while a runId-less RUN_ERROR is
|
|
697
|
+
// treated as a session-level error that clears every active run.
|
|
698
|
+
this.updateRunLifecycle(chunk)
|
|
639
699
|
// Yield control back to event loop for UI updates
|
|
640
700
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
641
701
|
}
|
|
@@ -794,6 +854,8 @@ export class ChatClient<
|
|
|
794
854
|
|
|
795
855
|
// Track generation so a superseded stream's cleanup doesn't clobber the new one
|
|
796
856
|
const generation = ++this.streamGeneration
|
|
857
|
+
const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
858
|
+
this.currentRunId = runId
|
|
797
859
|
|
|
798
860
|
this.setIsLoading(true)
|
|
799
861
|
this.setStatus('submitted')
|
|
@@ -874,7 +936,7 @@ export class ChatClient<
|
|
|
874
936
|
// serialize to an unusable shape.
|
|
875
937
|
const runContext = {
|
|
876
938
|
threadId: this.threadId,
|
|
877
|
-
runId
|
|
939
|
+
runId,
|
|
878
940
|
clientTools: Array.from(clientTools.values()).map((t) => ({
|
|
879
941
|
name: t.name,
|
|
880
942
|
description: t.description,
|
|
@@ -967,6 +1029,7 @@ export class ChatClient<
|
|
|
967
1029
|
this.currentStreamId = null
|
|
968
1030
|
this.devtoolsBridge.setCurrentStreamId(null)
|
|
969
1031
|
this.currentMessageId = null
|
|
1032
|
+
this.currentRunId = null
|
|
970
1033
|
this.activeClientTools = null
|
|
971
1034
|
this.activeContext = undefined
|
|
972
1035
|
this.abortController = null
|
|
@@ -1084,7 +1147,11 @@ export class ChatClient<
|
|
|
1084
1147
|
* Stop the current stream
|
|
1085
1148
|
*/
|
|
1086
1149
|
stop(): void {
|
|
1150
|
+
const hadLocalStream = this.abortController !== null
|
|
1087
1151
|
this.cancelInFlightStream({ setReadyStatus: true })
|
|
1152
|
+
if (hadLocalStream) {
|
|
1153
|
+
this.resetSessionGenerating()
|
|
1154
|
+
}
|
|
1088
1155
|
this.events.stopped()
|
|
1089
1156
|
}
|
|
1090
1157
|
|
|
@@ -1092,7 +1159,24 @@ export class ChatClient<
|
|
|
1092
1159
|
* Clear all messages
|
|
1093
1160
|
*/
|
|
1094
1161
|
clear(): void {
|
|
1162
|
+
if (this.persistor) {
|
|
1163
|
+
this.persistor.snapshotClear({
|
|
1164
|
+
messages: this.processor.getMessages(),
|
|
1165
|
+
activeRunIds: this.activeRunIds,
|
|
1166
|
+
currentRunId: this.currentRunId,
|
|
1167
|
+
})
|
|
1168
|
+
if (this.isLoading) {
|
|
1169
|
+
this.cancelInFlightStream({ setReadyStatus: true })
|
|
1170
|
+
this.resetSessionGenerating()
|
|
1171
|
+
} else if (this.activeRunIds.size > 0) {
|
|
1172
|
+
this.resetSessionGenerating()
|
|
1173
|
+
}
|
|
1174
|
+
// Suppress persisting the empty snapshot that clearMessages emits, then
|
|
1175
|
+
// remove the stored conversation outright.
|
|
1176
|
+
this.persistor.beginClear()
|
|
1177
|
+
}
|
|
1095
1178
|
this.processor.clearMessages()
|
|
1179
|
+
this.persistor?.remove()
|
|
1096
1180
|
this.setError(undefined)
|
|
1097
1181
|
this.events.messagesCleared()
|
|
1098
1182
|
}
|