@tanstack/ai-react 0.8.2 → 0.9.0

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.
@@ -1,6 +1,6 @@
1
1
  export { useChat } from './use-chat.js';
2
2
  export { useRealtimeChat } from './use-realtime-chat.js';
3
- export type { UseChatOptions, UseChatReturn, UIMessage, ChatRequestBody, } from './types.js';
3
+ export type { DeepPartial, UseChatOptions, UseChatReturn, UIMessage, ChatRequestBody, } from './types.js';
4
4
  export type { UseRealtimeChatOptions, UseRealtimeChatReturn, } from './realtime-types.js';
5
5
  export { useGeneration } from './use-generation.js';
6
6
  export type { UseGenerationOptions, UseGenerationReturn, } from './use-generation.js';
@@ -1,6 +1,14 @@
1
- import { AnyClientTool, ModelMessage } from '@tanstack/ai';
1
+ import { AnyClientTool, InferSchemaType, ModelMessage, SchemaInput } from '@tanstack/ai';
2
2
  import { ChatClientOptions, ChatClientState, ChatRequestBody, ConnectionStatus, MultimodalContent, UIMessage } from '@tanstack/ai-client';
3
3
  export type { ChatRequestBody, MultimodalContent, UIMessage };
4
+ /**
5
+ * Recursive partial — every property and every nested array element is optional.
6
+ * Used to type the in-flight `partial` value the hook exposes while a structured
7
+ * output stream is still arriving (the JSON has shape but is incomplete).
8
+ */
9
+ export type DeepPartial<T> = T extends ReadonlyArray<infer U> ? Array<DeepPartial<U>> : T extends object ? {
10
+ [K in keyof T]?: DeepPartial<T[K]>;
11
+ } : T;
4
12
  /**
5
13
  * Options for the useChat hook.
6
14
  *
@@ -14,17 +22,51 @@ export type { ChatRequestBody, MultimodalContent, UIMessage };
14
22
  * All other callbacks (onResponse, onChunk, onFinish, onError) are
15
23
  * passed through to the underlying ChatClient and can be used for side effects.
16
24
  *
25
+ * When `outputSchema` is supplied, the hook returns a typed `partial` (live
26
+ * progressive object, updated from `TEXT_MESSAGE_CONTENT` deltas via
27
+ * `parsePartialJSON`) and `final` (validated terminal payload from the
28
+ * `structured-output.complete` event). The schema is used purely for type
29
+ * inference on the client — server-side validation still runs against the
30
+ * schema you pass to `chat({ outputSchema })` on the server route.
31
+ *
17
32
  * Note: Connection and body changes will recreate the ChatClient instance.
18
33
  * To update these options, remount the component or use a key prop.
19
34
  */
20
- export type UseChatOptions<TTools extends ReadonlyArray<AnyClientTool> = any> = Omit<ChatClientOptions<TTools>, 'onMessagesChange' | 'onLoadingChange' | 'onErrorChange' | 'onStatusChange' | 'onSubscriptionChange' | 'onConnectionStatusChange' | 'onSessionGeneratingChange'> & {
35
+ export type UseChatOptions<TTools extends ReadonlyArray<AnyClientTool> = any, TSchema extends SchemaInput | undefined = undefined> = Omit<ChatClientOptions<TTools>, 'onMessagesChange' | 'onLoadingChange' | 'onErrorChange' | 'onStatusChange' | 'onSubscriptionChange' | 'onConnectionStatusChange' | 'onSessionGeneratingChange'> & {
21
36
  /**
22
37
  * Opt into mount-time live subscription behavior.
23
38
  * When enabled, the hook subscribes on mount and unsubscribes on unmount.
24
39
  */
25
40
  live?: boolean;
41
+ /**
42
+ * Standard-schema-compatible schema (Zod, Valibot, ArkType, or a plain JSON
43
+ * Schema). Used to infer the shape of `partial` and `final` in the return.
44
+ * The schema is **not** sent to the server — server-side validation runs
45
+ * against the schema passed to `chat({ outputSchema })` on the server route.
46
+ */
47
+ outputSchema?: TSchema;
26
48
  };
27
- export interface UseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any> {
49
+ /**
50
+ * Discriminated return shape: when `outputSchema` is supplied, the hook adds
51
+ * typed `partial` / `final` fields; when it is omitted (default), the return
52
+ * is unchanged.
53
+ */
54
+ export type UseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any, TSchema extends SchemaInput | undefined = undefined> = BaseUseChatReturn<TTools> & (TSchema extends SchemaInput ? {
55
+ /**
56
+ * Live, progressively-parsed structured output. Updated from
57
+ * `TEXT_MESSAGE_CONTENT` deltas via `parsePartialJSON` while the stream
58
+ * is still arriving, and snapped to the validated payload when
59
+ * `structured-output.complete` fires. Resets on every new run
60
+ * (`sendMessage` / `reload`).
61
+ */
62
+ partial: DeepPartial<InferSchemaType<TSchema>>;
63
+ /**
64
+ * Final, schema-validated structured output. `null` until the terminal
65
+ * `structured-output.complete` event arrives. Resets on every new run.
66
+ */
67
+ final: InferSchemaType<TSchema> | null;
68
+ } : Record<never, never>);
69
+ interface BaseUseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any> {
28
70
  /**
29
71
  * Current messages in the conversation
30
72
  */
@@ -1,3 +1,3 @@
1
- import { AnyClientTool } from '@tanstack/ai';
1
+ import { AnyClientTool, SchemaInput } from '@tanstack/ai';
2
2
  import { UseChatOptions, UseChatReturn } from './types.js';
3
- export declare function useChat<TTools extends ReadonlyArray<AnyClientTool> = any>(options: UseChatOptions<TTools>): UseChatReturn<TTools>;
3
+ export declare function useChat<TTools extends ReadonlyArray<AnyClientTool> = any, TSchema extends SchemaInput | undefined = undefined>(options: UseChatOptions<TTools, TSchema>): UseChatReturn<TTools, TSchema>;
@@ -1,4 +1,5 @@
1
1
  import { ChatClient } from "@tanstack/ai-client";
2
+ import { parsePartialJSON } from "@tanstack/ai";
2
3
  import { useId, useState, useRef, useMemo, useEffect, useCallback } from "react";
3
4
  function useChat(options) {
4
5
  const hookId = useId();
@@ -12,6 +13,9 @@ function useChat(options) {
12
13
  const [isSubscribed, setIsSubscribed] = useState(false);
13
14
  const [connectionStatus, setConnectionStatus] = useState("disconnected");
14
15
  const [sessionGenerating, setSessionGenerating] = useState(false);
16
+ const [partial, setPartial] = useState({});
17
+ const [final, setFinal] = useState(null);
18
+ const rawJsonRef = useRef("");
15
19
  const messagesRef = useRef(
16
20
  options.initialMessages || []
17
21
  );
@@ -31,7 +35,25 @@ function useChat(options) {
31
35
  // Capturing the function reference directly would freeze it to whatever
32
36
  // the parent passed on the first render.
33
37
  onResponse: (response) => optionsRef.current.onResponse?.(response),
34
- onChunk: (chunk) => optionsRef.current.onChunk?.(chunk),
38
+ onChunk: (chunk) => {
39
+ if (optionsRef.current.outputSchema !== void 0) {
40
+ if (chunk.type === "RUN_STARTED") {
41
+ rawJsonRef.current = "";
42
+ setPartial({});
43
+ setFinal(null);
44
+ } else if (chunk.type === "TEXT_MESSAGE_CONTENT" && chunk.delta) {
45
+ rawJsonRef.current += chunk.delta;
46
+ const progressive = parsePartialJSON(rawJsonRef.current);
47
+ if (progressive && typeof progressive === "object") {
48
+ setPartial(progressive);
49
+ }
50
+ } else if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
51
+ const value = chunk.value;
52
+ setFinal(value.object);
53
+ }
54
+ }
55
+ optionsRef.current.onChunk?.(chunk);
56
+ },
35
57
  onFinish: (message) => {
36
58
  optionsRef.current.onFinish?.(message);
37
59
  },
@@ -144,7 +166,9 @@ function useChat(options) {
144
166
  setMessages: setMessagesManually,
145
167
  clear,
146
168
  addToolResult,
147
- addToolApprovalResponse
169
+ addToolApprovalResponse,
170
+ partial,
171
+ final
148
172
  };
149
173
  }
150
174
  export {
@@ -1 +1 @@
1
- {"version":3,"file":"use-chat.js","sources":["../../src/use-chat.ts"],"sourcesContent":["import { ChatClient } from '@tanstack/ai-client'\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'\nimport type { AnyClientTool, ModelMessage } from '@tanstack/ai'\nimport type { ChatClientState, ConnectionStatus } from '@tanstack/ai-client'\n\nimport type {\n MultimodalContent,\n UIMessage,\n UseChatOptions,\n UseChatReturn,\n} from './types'\n\nexport function useChat<TTools extends ReadonlyArray<AnyClientTool> = any>(\n options: UseChatOptions<TTools>,\n): UseChatReturn<TTools> {\n const hookId = useId()\n const clientId = options.id || hookId\n\n const [messages, setMessages] = useState<Array<UIMessage<TTools>>>(\n options.initialMessages || [],\n )\n const [isLoading, setIsLoading] = useState(false)\n const [error, setError] = useState<Error | undefined>(undefined)\n const [status, setStatus] = useState<ChatClientState>('ready')\n const [isSubscribed, setIsSubscribed] = useState(false)\n const [connectionStatus, setConnectionStatus] =\n useState<ConnectionStatus>('disconnected')\n const [sessionGenerating, setSessionGenerating] = useState(false)\n\n // Track current messages in a ref to preserve them when client is recreated\n const messagesRef = useRef<Array<UIMessage<TTools>>>(\n options.initialMessages || [],\n )\n const isFirstMountRef = useRef(true)\n\n // Update ref synchronously during render so it's always current when useMemo runs.\n // A useEffect here would be async and messagesRef could be stale on client recreation.\n messagesRef.current = messages\n\n // Track current options in a ref to avoid recreating client when options change\n const optionsRef = useRef<UseChatOptions<TTools>>(options)\n optionsRef.current = options\n\n // Create ChatClient instance with callbacks to sync state\n // Note: Options are captured at client creation time.\n // The connection adapter can use functions for dynamic values (url, headers, etc.)\n // which are evaluated lazily on each request.\n const client = useMemo(() => {\n // On first mount, use initialMessages. On subsequent recreations, preserve existing messages.\n const messagesToUse = isFirstMountRef.current\n ? options.initialMessages || []\n : messagesRef.current\n\n isFirstMountRef.current = false\n\n return new ChatClient({\n connection: optionsRef.current.connection,\n id: clientId,\n initialMessages: messagesToUse,\n body: optionsRef.current.body,\n // Wrap every callback so the latest options are read at call time.\n // Capturing the function reference directly would freeze it to whatever\n // the parent passed on the first render.\n onResponse: (response) => optionsRef.current.onResponse?.(response),\n onChunk: (chunk) => optionsRef.current.onChunk?.(chunk),\n onFinish: (message: UIMessage<TTools>) => {\n optionsRef.current.onFinish?.(message)\n },\n onError: (error: Error) => {\n optionsRef.current.onError?.(error)\n },\n tools: optionsRef.current.tools,\n onCustomEvent: (eventType, data, context) =>\n optionsRef.current.onCustomEvent?.(eventType, data, context),\n streamProcessor: options.streamProcessor,\n onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {\n setMessages(newMessages)\n },\n onLoadingChange: (newIsLoading: boolean) => {\n setIsLoading(newIsLoading)\n },\n onErrorChange: (newError: Error | undefined) => {\n setError(newError)\n },\n onStatusChange: (status: ChatClientState) => {\n setStatus(status)\n },\n onSubscriptionChange: (nextIsSubscribed: boolean) => {\n setIsSubscribed(nextIsSubscribed)\n },\n onConnectionStatusChange: (nextStatus: ConnectionStatus) => {\n setConnectionStatus(nextStatus)\n },\n onSessionGeneratingChange: (isGenerating: boolean) => {\n setSessionGenerating(isGenerating)\n },\n })\n }, [clientId])\n\n // Sync body changes to the client\n // This allows dynamic body values (like model selection) to be updated without recreating the client\n useEffect(() => {\n client.updateOptions({ body: options.body })\n }, [client, options.body])\n\n // Sync initial messages on mount only\n // Note: initialMessages are passed to ChatClient constructor, but we also\n // set them here to ensure React state is in sync\n useEffect(() => {\n if (options.initialMessages && options.initialMessages.length > 0) {\n // Only set if current messages are empty (initial state)\n if (messages.length === 0) {\n client.setMessagesManually(options.initialMessages)\n }\n }\n }, []) // Only run on mount - initialMessages are handled by ChatClient constructor\n\n // Keep connection lifecycle opt-in and explicit.\n useEffect(() => {\n if (options.live) {\n client.subscribe()\n } else {\n client.unsubscribe()\n }\n }, [client, options.live])\n\n // Cleanup on unmount: stop any in-flight requests\n // Note: We only cleanup when client changes or component unmounts.\n // DO NOT include isLoading in dependencies - that would cause the cleanup\n // to run when isLoading changes, aborting continuation requests.\n useEffect(() => {\n return () => {\n // live mode owns the connection lifecycle; non-live keeps request-only stop.\n if (options.live) {\n client.unsubscribe()\n } else {\n client.stop()\n }\n }\n }, [client, options.live])\n\n // All callback options are read through optionsRef at call time, so fresh\n // closures from each render are picked up without recreating the client.\n\n const sendMessage = useCallback(\n async (content: string | MultimodalContent) => {\n await client.sendMessage(content)\n },\n [client],\n )\n\n const append = useCallback(\n async (message: ModelMessage | UIMessage) => {\n await client.append(message)\n },\n [client],\n )\n\n const reload = useCallback(async () => {\n await client.reload()\n }, [client])\n\n const stop = useCallback(() => {\n client.stop()\n }, [client])\n\n const clear = useCallback(() => {\n client.clear()\n }, [client])\n\n const setMessagesManually = useCallback(\n (newMessages: Array<UIMessage<TTools>>) => {\n client.setMessagesManually(newMessages)\n },\n [client],\n )\n\n const addToolResult = useCallback(\n async (result: {\n toolCallId: string\n tool: string\n output: any\n state?: 'output-available' | 'output-error'\n errorText?: string\n }) => {\n await client.addToolResult(result)\n },\n [client],\n )\n\n const addToolApprovalResponse = useCallback(\n async (response: { id: string; approved: boolean }) => {\n await client.addToolApprovalResponse(response)\n },\n [client],\n )\n\n return {\n messages,\n sendMessage,\n append,\n reload,\n stop,\n isLoading,\n error,\n status,\n isSubscribed,\n connectionStatus,\n sessionGenerating,\n setMessages: setMessagesManually,\n clear,\n addToolResult,\n addToolApprovalResponse,\n }\n}\n"],"names":["error","status"],"mappings":"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"}
1
+ {"version":3,"file":"use-chat.js","sources":["../../src/use-chat.ts"],"sourcesContent":["import { ChatClient } from '@tanstack/ai-client'\nimport { parsePartialJSON } from '@tanstack/ai'\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'\nimport type {\n AnyClientTool,\n InferSchemaType,\n ModelMessage,\n SchemaInput,\n StreamChunk,\n} from '@tanstack/ai'\nimport type { ChatClientState, ConnectionStatus } from '@tanstack/ai-client'\n\nimport type {\n DeepPartial,\n MultimodalContent,\n UIMessage,\n UseChatOptions,\n UseChatReturn,\n} from './types'\n\nexport function useChat<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TSchema extends SchemaInput | undefined = undefined,\n>(options: UseChatOptions<TTools, TSchema>): UseChatReturn<TTools, TSchema> {\n const hookId = useId()\n const clientId = options.id || hookId\n\n const [messages, setMessages] = useState<Array<UIMessage<TTools>>>(\n options.initialMessages || [],\n )\n const [isLoading, setIsLoading] = useState(false)\n const [error, setError] = useState<Error | undefined>(undefined)\n const [status, setStatus] = useState<ChatClientState>('ready')\n const [isSubscribed, setIsSubscribed] = useState(false)\n const [connectionStatus, setConnectionStatus] =\n useState<ConnectionStatus>('disconnected')\n const [sessionGenerating, setSessionGenerating] = useState(false)\n\n // Structured-output state. Only meaningful when `outputSchema` is supplied;\n // when it isn't, these stay at their initial values and are hidden from the\n // return type by the conditional in UseChatReturn. Runtime always tracks\n // them — the type system gates visibility, not the runtime.\n type Partial = DeepPartial<InferSchemaType<NonNullable<TSchema>>>\n type Final = InferSchemaType<NonNullable<TSchema>>\n const [partial, setPartial] = useState<Partial>({} as Partial)\n const [final, setFinal] = useState<Final | null>(null)\n // Raw JSON accumulator for parsePartialJSON. Ref instead of state — partial\n // JSON parsing happens synchronously inside the chunk handler; we don't want\n // a re-render per delta solely to track the buffer.\n const rawJsonRef = useRef('')\n\n // Track current messages in a ref to preserve them when client is recreated\n const messagesRef = useRef<Array<UIMessage<TTools>>>(\n options.initialMessages || [],\n )\n const isFirstMountRef = useRef(true)\n\n // Update ref synchronously during render so it's always current when useMemo runs.\n // A useEffect here would be async and messagesRef could be stale on client recreation.\n messagesRef.current = messages\n\n // Track current options in a ref to avoid recreating client when options change\n const optionsRef = useRef<UseChatOptions<TTools, TSchema>>(options)\n optionsRef.current = options\n\n // Create ChatClient instance with callbacks to sync state\n // Note: Options are captured at client creation time.\n // The connection adapter can use functions for dynamic values (url, headers, etc.)\n // which are evaluated lazily on each request.\n const client = useMemo(() => {\n // On first mount, use initialMessages. On subsequent recreations, preserve existing messages.\n const messagesToUse = isFirstMountRef.current\n ? options.initialMessages || []\n : messagesRef.current\n\n isFirstMountRef.current = false\n\n return new ChatClient({\n connection: optionsRef.current.connection,\n id: clientId,\n initialMessages: messagesToUse,\n body: optionsRef.current.body,\n // Wrap every callback so the latest options are read at call time.\n // Capturing the function reference directly would freeze it to whatever\n // the parent passed on the first render.\n onResponse: (response) => optionsRef.current.onResponse?.(response),\n onChunk: (chunk: StreamChunk) => {\n // Internal structured-output tracking — runs before the user callback\n // so user code observes the same state the hook does. Only active when\n // a schema is supplied; otherwise the branches are no-ops.\n if (optionsRef.current.outputSchema !== undefined) {\n if (chunk.type === 'RUN_STARTED') {\n // New run — reset both views.\n rawJsonRef.current = ''\n setPartial({} as Partial)\n setFinal(null)\n } else if (chunk.type === 'TEXT_MESSAGE_CONTENT' && chunk.delta) {\n rawJsonRef.current += chunk.delta\n const progressive = parsePartialJSON(rawJsonRef.current)\n if (progressive && typeof progressive === 'object') {\n setPartial(progressive as Partial)\n }\n } else if (\n chunk.type === 'CUSTOM' &&\n chunk.name === 'structured-output.complete'\n ) {\n const value = chunk.value as { object: unknown }\n setFinal(value.object as Final)\n }\n }\n optionsRef.current.onChunk?.(chunk)\n },\n onFinish: (message: UIMessage<TTools>) => {\n optionsRef.current.onFinish?.(message)\n },\n onError: (error: Error) => {\n optionsRef.current.onError?.(error)\n },\n tools: optionsRef.current.tools,\n onCustomEvent: (eventType, data, context) =>\n optionsRef.current.onCustomEvent?.(eventType, data, context),\n streamProcessor: options.streamProcessor,\n onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {\n setMessages(newMessages)\n },\n onLoadingChange: (newIsLoading: boolean) => {\n setIsLoading(newIsLoading)\n },\n onErrorChange: (newError: Error | undefined) => {\n setError(newError)\n },\n onStatusChange: (status: ChatClientState) => {\n setStatus(status)\n },\n onSubscriptionChange: (nextIsSubscribed: boolean) => {\n setIsSubscribed(nextIsSubscribed)\n },\n onConnectionStatusChange: (nextStatus: ConnectionStatus) => {\n setConnectionStatus(nextStatus)\n },\n onSessionGeneratingChange: (isGenerating: boolean) => {\n setSessionGenerating(isGenerating)\n },\n })\n }, [clientId])\n\n // Sync body changes to the client\n // This allows dynamic body values (like model selection) to be updated without recreating the client\n useEffect(() => {\n client.updateOptions({ body: options.body })\n }, [client, options.body])\n\n // Sync initial messages on mount only\n // Note: initialMessages are passed to ChatClient constructor, but we also\n // set them here to ensure React state is in sync\n useEffect(() => {\n if (options.initialMessages && options.initialMessages.length > 0) {\n // Only set if current messages are empty (initial state)\n if (messages.length === 0) {\n client.setMessagesManually(options.initialMessages)\n }\n }\n }, []) // Only run on mount - initialMessages are handled by ChatClient constructor\n\n // Keep connection lifecycle opt-in and explicit.\n useEffect(() => {\n if (options.live) {\n client.subscribe()\n } else {\n client.unsubscribe()\n }\n }, [client, options.live])\n\n // Cleanup on unmount: stop any in-flight requests\n // Note: We only cleanup when client changes or component unmounts.\n // DO NOT include isLoading in dependencies - that would cause the cleanup\n // to run when isLoading changes, aborting continuation requests.\n useEffect(() => {\n return () => {\n // live mode owns the connection lifecycle; non-live keeps request-only stop.\n if (options.live) {\n client.unsubscribe()\n } else {\n client.stop()\n }\n }\n }, [client, options.live])\n\n // All callback options are read through optionsRef at call time, so fresh\n // closures from each render are picked up without recreating the client.\n\n const sendMessage = useCallback(\n async (content: string | MultimodalContent) => {\n await client.sendMessage(content)\n },\n [client],\n )\n\n const append = useCallback(\n async (message: ModelMessage | UIMessage) => {\n await client.append(message)\n },\n [client],\n )\n\n const reload = useCallback(async () => {\n await client.reload()\n }, [client])\n\n const stop = useCallback(() => {\n client.stop()\n }, [client])\n\n const clear = useCallback(() => {\n client.clear()\n }, [client])\n\n const setMessagesManually = useCallback(\n (newMessages: Array<UIMessage<TTools>>) => {\n client.setMessagesManually(newMessages)\n },\n [client],\n )\n\n const addToolResult = useCallback(\n async (result: {\n toolCallId: string\n tool: string\n output: any\n state?: 'output-available' | 'output-error'\n errorText?: string\n }) => {\n await client.addToolResult(result)\n },\n [client],\n )\n\n const addToolApprovalResponse = useCallback(\n async (response: { id: string; approved: boolean }) => {\n await client.addToolApprovalResponse(response)\n },\n [client],\n )\n\n // partial / final are runtime-tracked unconditionally; the conditional\n // return type (UseChatReturn<TTools, TSchema>) hides them from callers that\n // didn't supply `outputSchema`. The `as` cast is the seam between the\n // unconditional runtime shape and the schema-discriminated public shape.\n return {\n messages,\n sendMessage,\n append,\n reload,\n stop,\n isLoading,\n error,\n status,\n isSubscribed,\n connectionStatus,\n sessionGenerating,\n setMessages: setMessagesManually,\n clear,\n addToolResult,\n addToolApprovalResponse,\n partial,\n final,\n } as unknown as UseChatReturn<TTools, TSchema>\n}\n"],"names":["error","status"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-react",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "React hooks for TanStack AI",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -31,21 +31,22 @@
31
31
  "streaming"
32
32
  ],
33
33
  "dependencies": {
34
- "@tanstack/ai-client": "0.9.1"
34
+ "@tanstack/ai-client": "0.9.2"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@types/react": ">=18.0.0",
38
38
  "react": ">=18.0.0",
39
- "@tanstack/ai": "^0.16.0"
39
+ "@tanstack/ai": "^0.17.0"
40
40
  },
41
41
  "devDependencies": {
42
+ "@standard-schema/spec": "^1.1.0",
42
43
  "@testing-library/react": "^16.3.0",
43
44
  "@types/react": "^19.2.7",
44
45
  "@vitest/coverage-v8": "4.0.14",
45
46
  "jsdom": "^27.2.0",
46
47
  "react": "^19.2.3",
47
48
  "vite": "^7.2.7",
48
- "@tanstack/ai": "0.16.0"
49
+ "@tanstack/ai": "0.17.0"
49
50
  },
50
51
  "scripts": {
51
52
  "clean": "premove ./build ./dist",
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { useChat } from './use-chat'
2
2
  export { useRealtimeChat } from './use-realtime-chat'
3
3
  export type {
4
+ DeepPartial,
4
5
  UseChatOptions,
5
6
  UseChatReturn,
6
7
  UIMessage,
package/src/types.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { AnyClientTool, ModelMessage } from '@tanstack/ai'
1
+ import type {
2
+ AnyClientTool,
3
+ InferSchemaType,
4
+ ModelMessage,
5
+ SchemaInput,
6
+ } from '@tanstack/ai'
2
7
  import type {
3
8
  ChatClientOptions,
4
9
  ChatClientState,
@@ -11,6 +16,18 @@ import type {
11
16
  // Re-export types from ai-client
12
17
  export type { ChatRequestBody, MultimodalContent, UIMessage }
13
18
 
19
+ /**
20
+ * Recursive partial — every property and every nested array element is optional.
21
+ * Used to type the in-flight `partial` value the hook exposes while a structured
22
+ * output stream is still arriving (the JSON has shape but is incomplete).
23
+ */
24
+ export type DeepPartial<T> =
25
+ T extends ReadonlyArray<infer U>
26
+ ? Array<DeepPartial<U>>
27
+ : T extends object
28
+ ? { [K in keyof T]?: DeepPartial<T[K]> }
29
+ : T
30
+
14
31
  /**
15
32
  * Options for the useChat hook.
16
33
  *
@@ -24,30 +41,71 @@ export type { ChatRequestBody, MultimodalContent, UIMessage }
24
41
  * All other callbacks (onResponse, onChunk, onFinish, onError) are
25
42
  * passed through to the underlying ChatClient and can be used for side effects.
26
43
  *
44
+ * When `outputSchema` is supplied, the hook returns a typed `partial` (live
45
+ * progressive object, updated from `TEXT_MESSAGE_CONTENT` deltas via
46
+ * `parsePartialJSON`) and `final` (validated terminal payload from the
47
+ * `structured-output.complete` event). The schema is used purely for type
48
+ * inference on the client — server-side validation still runs against the
49
+ * schema you pass to `chat({ outputSchema })` on the server route.
50
+ *
27
51
  * Note: Connection and body changes will recreate the ChatClient instance.
28
52
  * To update these options, remount the component or use a key prop.
29
53
  */
30
- export type UseChatOptions<TTools extends ReadonlyArray<AnyClientTool> = any> =
31
- Omit<
32
- ChatClientOptions<TTools>,
33
- | 'onMessagesChange'
34
- | 'onLoadingChange'
35
- | 'onErrorChange'
36
- | 'onStatusChange'
37
- | 'onSubscriptionChange'
38
- | 'onConnectionStatusChange'
39
- | 'onSessionGeneratingChange'
40
- > & {
41
- /**
42
- * Opt into mount-time live subscription behavior.
43
- * When enabled, the hook subscribes on mount and unsubscribes on unmount.
44
- */
45
- live?: boolean
46
- }
47
-
48
- export interface UseChatReturn<
54
+ export type UseChatOptions<
55
+ TTools extends ReadonlyArray<AnyClientTool> = any,
56
+ TSchema extends SchemaInput | undefined = undefined,
57
+ > = Omit<
58
+ ChatClientOptions<TTools>,
59
+ | 'onMessagesChange'
60
+ | 'onLoadingChange'
61
+ | 'onErrorChange'
62
+ | 'onStatusChange'
63
+ | 'onSubscriptionChange'
64
+ | 'onConnectionStatusChange'
65
+ | 'onSessionGeneratingChange'
66
+ > & {
67
+ /**
68
+ * Opt into mount-time live subscription behavior.
69
+ * When enabled, the hook subscribes on mount and unsubscribes on unmount.
70
+ */
71
+ live?: boolean
72
+ /**
73
+ * Standard-schema-compatible schema (Zod, Valibot, ArkType, or a plain JSON
74
+ * Schema). Used to infer the shape of `partial` and `final` in the return.
75
+ * The schema is **not** sent to the server — server-side validation runs
76
+ * against the schema passed to `chat({ outputSchema })` on the server route.
77
+ */
78
+ outputSchema?: TSchema
79
+ }
80
+
81
+ /**
82
+ * Discriminated return shape: when `outputSchema` is supplied, the hook adds
83
+ * typed `partial` / `final` fields; when it is omitted (default), the return
84
+ * is unchanged.
85
+ */
86
+ export type UseChatReturn<
49
87
  TTools extends ReadonlyArray<AnyClientTool> = any,
50
- > {
88
+ TSchema extends SchemaInput | undefined = undefined,
89
+ > = BaseUseChatReturn<TTools> &
90
+ (TSchema extends SchemaInput
91
+ ? {
92
+ /**
93
+ * Live, progressively-parsed structured output. Updated from
94
+ * `TEXT_MESSAGE_CONTENT` deltas via `parsePartialJSON` while the stream
95
+ * is still arriving, and snapped to the validated payload when
96
+ * `structured-output.complete` fires. Resets on every new run
97
+ * (`sendMessage` / `reload`).
98
+ */
99
+ partial: DeepPartial<InferSchemaType<TSchema>>
100
+ /**
101
+ * Final, schema-validated structured output. `null` until the terminal
102
+ * `structured-output.complete` event arrives. Resets on every new run.
103
+ */
104
+ final: InferSchemaType<TSchema> | null
105
+ }
106
+ : Record<never, never>)
107
+
108
+ interface BaseUseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any> {
51
109
  /**
52
110
  * Current messages in the conversation
53
111
  */
package/src/use-chat.ts CHANGED
@@ -1,18 +1,27 @@
1
1
  import { ChatClient } from '@tanstack/ai-client'
2
+ import { parsePartialJSON } from '@tanstack/ai'
2
3
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
3
- import type { AnyClientTool, ModelMessage } from '@tanstack/ai'
4
+ import type {
5
+ AnyClientTool,
6
+ InferSchemaType,
7
+ ModelMessage,
8
+ SchemaInput,
9
+ StreamChunk,
10
+ } from '@tanstack/ai'
4
11
  import type { ChatClientState, ConnectionStatus } from '@tanstack/ai-client'
5
12
 
6
13
  import type {
14
+ DeepPartial,
7
15
  MultimodalContent,
8
16
  UIMessage,
9
17
  UseChatOptions,
10
18
  UseChatReturn,
11
19
  } from './types'
12
20
 
13
- export function useChat<TTools extends ReadonlyArray<AnyClientTool> = any>(
14
- options: UseChatOptions<TTools>,
15
- ): UseChatReturn<TTools> {
21
+ export function useChat<
22
+ TTools extends ReadonlyArray<AnyClientTool> = any,
23
+ TSchema extends SchemaInput | undefined = undefined,
24
+ >(options: UseChatOptions<TTools, TSchema>): UseChatReturn<TTools, TSchema> {
16
25
  const hookId = useId()
17
26
  const clientId = options.id || hookId
18
27
 
@@ -27,6 +36,19 @@ export function useChat<TTools extends ReadonlyArray<AnyClientTool> = any>(
27
36
  useState<ConnectionStatus>('disconnected')
28
37
  const [sessionGenerating, setSessionGenerating] = useState(false)
29
38
 
39
+ // Structured-output state. Only meaningful when `outputSchema` is supplied;
40
+ // when it isn't, these stay at their initial values and are hidden from the
41
+ // return type by the conditional in UseChatReturn. Runtime always tracks
42
+ // them — the type system gates visibility, not the runtime.
43
+ type Partial = DeepPartial<InferSchemaType<NonNullable<TSchema>>>
44
+ type Final = InferSchemaType<NonNullable<TSchema>>
45
+ const [partial, setPartial] = useState<Partial>({} as Partial)
46
+ const [final, setFinal] = useState<Final | null>(null)
47
+ // Raw JSON accumulator for parsePartialJSON. Ref instead of state — partial
48
+ // JSON parsing happens synchronously inside the chunk handler; we don't want
49
+ // a re-render per delta solely to track the buffer.
50
+ const rawJsonRef = useRef('')
51
+
30
52
  // Track current messages in a ref to preserve them when client is recreated
31
53
  const messagesRef = useRef<Array<UIMessage<TTools>>>(
32
54
  options.initialMessages || [],
@@ -38,7 +60,7 @@ export function useChat<TTools extends ReadonlyArray<AnyClientTool> = any>(
38
60
  messagesRef.current = messages
39
61
 
40
62
  // Track current options in a ref to avoid recreating client when options change
41
- const optionsRef = useRef<UseChatOptions<TTools>>(options)
63
+ const optionsRef = useRef<UseChatOptions<TTools, TSchema>>(options)
42
64
  optionsRef.current = options
43
65
 
44
66
  // Create ChatClient instance with callbacks to sync state
@@ -62,7 +84,32 @@ export function useChat<TTools extends ReadonlyArray<AnyClientTool> = any>(
62
84
  // Capturing the function reference directly would freeze it to whatever
63
85
  // the parent passed on the first render.
64
86
  onResponse: (response) => optionsRef.current.onResponse?.(response),
65
- onChunk: (chunk) => optionsRef.current.onChunk?.(chunk),
87
+ onChunk: (chunk: StreamChunk) => {
88
+ // Internal structured-output tracking — runs before the user callback
89
+ // so user code observes the same state the hook does. Only active when
90
+ // a schema is supplied; otherwise the branches are no-ops.
91
+ if (optionsRef.current.outputSchema !== undefined) {
92
+ if (chunk.type === 'RUN_STARTED') {
93
+ // New run — reset both views.
94
+ rawJsonRef.current = ''
95
+ setPartial({} as Partial)
96
+ setFinal(null)
97
+ } else if (chunk.type === 'TEXT_MESSAGE_CONTENT' && chunk.delta) {
98
+ rawJsonRef.current += chunk.delta
99
+ const progressive = parsePartialJSON(rawJsonRef.current)
100
+ if (progressive && typeof progressive === 'object') {
101
+ setPartial(progressive as Partial)
102
+ }
103
+ } else if (
104
+ chunk.type === 'CUSTOM' &&
105
+ chunk.name === 'structured-output.complete'
106
+ ) {
107
+ const value = chunk.value as { object: unknown }
108
+ setFinal(value.object as Final)
109
+ }
110
+ }
111
+ optionsRef.current.onChunk?.(chunk)
112
+ },
66
113
  onFinish: (message: UIMessage<TTools>) => {
67
114
  optionsRef.current.onFinish?.(message)
68
115
  },
@@ -195,6 +242,10 @@ export function useChat<TTools extends ReadonlyArray<AnyClientTool> = any>(
195
242
  [client],
196
243
  )
197
244
 
245
+ // partial / final are runtime-tracked unconditionally; the conditional
246
+ // return type (UseChatReturn<TTools, TSchema>) hides them from callers that
247
+ // didn't supply `outputSchema`. The `as` cast is the seam between the
248
+ // unconditional runtime shape and the schema-discriminated public shape.
198
249
  return {
199
250
  messages,
200
251
  sendMessage,
@@ -211,5 +262,7 @@ export function useChat<TTools extends ReadonlyArray<AnyClientTool> = any>(
211
262
  clear,
212
263
  addToolResult,
213
264
  addToolApprovalResponse,
214
- }
265
+ partial,
266
+ final,
267
+ } as unknown as UseChatReturn<TTools, TSchema>
215
268
  }