@tanstack/ai-react 0.9.0 → 0.11.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.
- package/README.md +0 -4
- package/dist/esm/types.d.ts +8 -6
- package/dist/esm/use-chat.js +36 -25
- package/dist/esm/use-chat.js.map +1 -1
- package/package.json +5 -5
- package/src/types.ts +14 -6
- package/src/use-chat.ts +55 -65
package/README.md
CHANGED
|
@@ -49,10 +49,6 @@ A powerful, type-safe AI SDK for building AI-powered applications.
|
|
|
49
49
|
|
|
50
50
|
### <a href="https://tanstack.com/ai">Read the docs →</a>
|
|
51
51
|
|
|
52
|
-
## Requirements
|
|
53
|
-
|
|
54
|
-
- **Node.js v24+** is required to avoid compatibility issues with `isolated-vm`.
|
|
55
|
-
|
|
56
52
|
## Tree-Shakeable Adapters
|
|
57
53
|
|
|
58
54
|
Import only the functionality you need for smaller bundle sizes:
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export type UseChatOptions<TTools extends ReadonlyArray<AnyClientTool> = any, TS
|
|
|
51
51
|
* typed `partial` / `final` fields; when it is omitted (default), the return
|
|
52
52
|
* is unchanged.
|
|
53
53
|
*/
|
|
54
|
-
export type UseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any, TSchema extends SchemaInput | undefined = undefined> = BaseUseChatReturn<TTools> & (TSchema extends SchemaInput ? {
|
|
54
|
+
export type UseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any, TSchema extends SchemaInput | undefined = undefined> = BaseUseChatReturn<TTools, TSchema extends SchemaInput ? InferSchemaType<TSchema> : unknown> & (TSchema extends SchemaInput ? {
|
|
55
55
|
/**
|
|
56
56
|
* Live, progressively-parsed structured output. Updated from
|
|
57
57
|
* `TEXT_MESSAGE_CONTENT` deltas via `parsePartialJSON` while the stream
|
|
@@ -66,11 +66,13 @@ export type UseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any, TSc
|
|
|
66
66
|
*/
|
|
67
67
|
final: InferSchemaType<TSchema> | null;
|
|
68
68
|
} : Record<never, never>);
|
|
69
|
-
interface BaseUseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any> {
|
|
69
|
+
interface BaseUseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any, TData = unknown> {
|
|
70
70
|
/**
|
|
71
|
-
* Current messages in the conversation
|
|
71
|
+
* Current messages in the conversation. When `outputSchema` is supplied,
|
|
72
|
+
* `messages[i].parts.find(p => p.type === 'structured-output')` is typed
|
|
73
|
+
* with the schema's inferred shape — `data: T`, `partial: DeepPartial<T>`.
|
|
72
74
|
*/
|
|
73
|
-
messages: Array<UIMessage<TTools>>;
|
|
75
|
+
messages: Array<UIMessage<TTools, TData>>;
|
|
74
76
|
/**
|
|
75
77
|
* Send a message and get a response.
|
|
76
78
|
* Can be a simple string or multimodal content with images, audio, etc.
|
|
@@ -79,7 +81,7 @@ interface BaseUseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any> {
|
|
|
79
81
|
/**
|
|
80
82
|
* Append a message to the conversation
|
|
81
83
|
*/
|
|
82
|
-
append: (message: ModelMessage | UIMessage<TTools>) => Promise<void>;
|
|
84
|
+
append: (message: ModelMessage | UIMessage<TTools, TData>) => Promise<void>;
|
|
83
85
|
/**
|
|
84
86
|
* Add the result of a client-side tool execution
|
|
85
87
|
*/
|
|
@@ -135,7 +137,7 @@ interface BaseUseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any> {
|
|
|
135
137
|
/**
|
|
136
138
|
* Set messages manually
|
|
137
139
|
*/
|
|
138
|
-
setMessages: (messages: Array<UIMessage<TTools>>) => void;
|
|
140
|
+
setMessages: (messages: Array<UIMessage<TTools, TData>>) => void;
|
|
139
141
|
/**
|
|
140
142
|
* Clear all messages
|
|
141
143
|
*/
|
package/dist/esm/use-chat.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { ChatClient } from "@tanstack/ai-client";
|
|
2
|
-
import { parsePartialJSON } from "@tanstack/ai";
|
|
3
2
|
import { useId, useState, useRef, useMemo, useEffect, useCallback } from "react";
|
|
4
3
|
function useChat(options) {
|
|
5
4
|
const hookId = useId();
|
|
@@ -13,9 +12,6 @@ function useChat(options) {
|
|
|
13
12
|
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
14
13
|
const [connectionStatus, setConnectionStatus] = useState("disconnected");
|
|
15
14
|
const [sessionGenerating, setSessionGenerating] = useState(false);
|
|
16
|
-
const [partial, setPartial] = useState({});
|
|
17
|
-
const [final, setFinal] = useState(null);
|
|
18
|
-
const rawJsonRef = useRef("");
|
|
19
15
|
const messagesRef = useRef(
|
|
20
16
|
options.initialMessages || []
|
|
21
17
|
);
|
|
@@ -31,27 +27,9 @@ function useChat(options) {
|
|
|
31
27
|
id: clientId,
|
|
32
28
|
initialMessages: messagesToUse,
|
|
33
29
|
body: optionsRef.current.body,
|
|
34
|
-
|
|
35
|
-
// Capturing the function reference directly would freeze it to whatever
|
|
36
|
-
// the parent passed on the first render.
|
|
30
|
+
forwardedProps: optionsRef.current.forwardedProps,
|
|
37
31
|
onResponse: (response) => optionsRef.current.onResponse?.(response),
|
|
38
32
|
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
33
|
optionsRef.current.onChunk?.(chunk);
|
|
56
34
|
},
|
|
57
35
|
onFinish: (message) => {
|
|
@@ -87,8 +65,11 @@ function useChat(options) {
|
|
|
87
65
|
});
|
|
88
66
|
}, [clientId]);
|
|
89
67
|
useEffect(() => {
|
|
90
|
-
client.updateOptions({
|
|
91
|
-
|
|
68
|
+
client.updateOptions({
|
|
69
|
+
body: options.body,
|
|
70
|
+
forwardedProps: options.forwardedProps
|
|
71
|
+
});
|
|
72
|
+
}, [client, options.body, options.forwardedProps]);
|
|
92
73
|
useEffect(() => {
|
|
93
74
|
if (options.initialMessages && options.initialMessages.length > 0) {
|
|
94
75
|
if (messages.length === 0) {
|
|
@@ -151,6 +132,36 @@ function useChat(options) {
|
|
|
151
132
|
},
|
|
152
133
|
[client]
|
|
153
134
|
);
|
|
135
|
+
const activeStructuredPart = useMemo(() => {
|
|
136
|
+
let lastUserIndex = -1;
|
|
137
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
138
|
+
if (messages[i].role === "user") {
|
|
139
|
+
lastUserIndex = i;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (lastUserIndex === -1) return null;
|
|
144
|
+
for (let i = messages.length - 1; i > lastUserIndex; i--) {
|
|
145
|
+
const m = messages[i];
|
|
146
|
+
if (m.role !== "assistant") continue;
|
|
147
|
+
const part = m.parts.find(
|
|
148
|
+
(p) => p.type === "structured-output"
|
|
149
|
+
);
|
|
150
|
+
if (part) return part;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}, [messages]);
|
|
154
|
+
const partial = useMemo(() => {
|
|
155
|
+
if (!activeStructuredPart) return {};
|
|
156
|
+
const v = activeStructuredPart.partial ?? activeStructuredPart.data;
|
|
157
|
+
return v ?? {};
|
|
158
|
+
}, [activeStructuredPart]);
|
|
159
|
+
const final = useMemo(() => {
|
|
160
|
+
if (!activeStructuredPart || activeStructuredPart.status !== "complete") {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return activeStructuredPart.data;
|
|
164
|
+
}, [activeStructuredPart]);
|
|
154
165
|
return {
|
|
155
166
|
messages,
|
|
156
167
|
sendMessage,
|
package/dist/esm/use-chat.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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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"}
|
|
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 {\n AnyClientTool,\n InferSchemaType,\n ModelMessage,\n SchemaInput,\n StreamChunk,\n} from '@tanstack/ai'\nimport type {\n ChatClientState,\n ConnectionStatus,\n StructuredOutputPart,\n} 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 type Partial = DeepPartial<InferSchemaType<NonNullable<TSchema>>>\n type Final = InferSchemaType<NonNullable<TSchema>>\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 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 const client = useMemo(() => {\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 forwardedProps: optionsRef.current.forwardedProps,\n onResponse: (response) => optionsRef.current.onResponse?.(response),\n onChunk: (chunk: StreamChunk) => {\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 useEffect(() => {\n client.updateOptions({\n body: options.body,\n forwardedProps: options.forwardedProps,\n })\n }, [client, options.body, options.forwardedProps])\n\n useEffect(() => {\n if (options.initialMessages && options.initialMessages.length > 0) {\n if (messages.length === 0) {\n client.setMessagesManually(options.initialMessages)\n }\n }\n }, [])\n\n useEffect(() => {\n if (options.live) {\n client.subscribe()\n } else {\n client.unsubscribe()\n }\n }, [client, options.live])\n\n useEffect(() => {\n return () => {\n if (options.live) {\n client.unsubscribe()\n } else {\n client.stop()\n }\n }\n }, [client, options.live])\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 // The \"active\" structured-output part is the one on the assistant message\n // that follows the latest user message. No such message exists between\n // sendMessage() and the first chunk, so partial/final naturally read as\n // cleared. Historical parts on earlier assistant messages remain available\n // via `messages` directly.\n //\n // When there is NO user message yet (e.g. `initialMessages` contains only\n // a stale assistant turn or a system prompt) we deliberately return null\n // rather than scanning historical assistants — otherwise a `final` from a\n // previous session would leak into the hook value on first render.\n const activeStructuredPart = useMemo<StructuredOutputPart | null>(() => {\n let lastUserIndex = -1\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i]!.role === 'user') {\n lastUserIndex = i\n break\n }\n }\n if (lastUserIndex === -1) return null\n for (let i = messages.length - 1; i > lastUserIndex; i--) {\n const m = messages[i]!\n if (m.role !== 'assistant') continue\n const part = m.parts.find(\n (p): p is StructuredOutputPart => p.type === 'structured-output',\n )\n if (part) return part\n }\n return null\n }, [messages])\n\n const partial = useMemo<Partial>(() => {\n if (!activeStructuredPart) return {} as Partial\n const v = activeStructuredPart.partial ?? activeStructuredPart.data\n return (v ?? {}) as Partial\n }, [activeStructuredPart])\n\n const final = useMemo<Final | null>(() => {\n if (!activeStructuredPart || activeStructuredPart.status !== 'complete') {\n return null\n }\n return activeStructuredPart.data as Final\n }, [activeStructuredPart])\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 partial,\n final,\n } as unknown as UseChatReturn<TTools, TSchema>\n}\n"],"names":["error","status"],"mappings":";;AAuBO,SAAS,QAGd,SAA0E;AAC1E,QAAM,SAAS,MAAA;AACf,QAAM,WAAW,QAAQ,MAAM;AAE/B,QAAM,CAAC,UAAU,WAAW,IAAI;AAAA,IAC9B,QAAQ,mBAAmB,CAAA;AAAA,EAAC;AAE9B,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,KAAK;AAChD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAA4B,MAAS;AAC/D,QAAM,CAAC,QAAQ,SAAS,IAAI,SAA0B,OAAO;AAC7D,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,KAAK;AACtD,QAAM,CAAC,kBAAkB,mBAAmB,IAC1C,SAA2B,cAAc;AAC3C,QAAM,CAAC,mBAAmB,oBAAoB,IAAI,SAAS,KAAK;AAMhE,QAAM,cAAc;AAAA,IAClB,QAAQ,mBAAmB,CAAA;AAAA,EAAC;AAE9B,QAAM,kBAAkB,OAAO,IAAI;AAGnC,cAAY,UAAU;AAGtB,QAAM,aAAa,OAAwC,OAAO;AAClE,aAAW,UAAU;AAGrB,QAAM,SAAS,QAAQ,MAAM;AAC3B,UAAM,gBAAgB,gBAAgB,UAClC,QAAQ,mBAAmB,CAAA,IAC3B,YAAY;AAEhB,oBAAgB,UAAU;AAE1B,WAAO,IAAI,WAAW;AAAA,MACpB,YAAY,WAAW,QAAQ;AAAA,MAC/B,IAAI;AAAA,MACJ,iBAAiB;AAAA,MACjB,MAAM,WAAW,QAAQ;AAAA,MACzB,gBAAgB,WAAW,QAAQ;AAAA,MACnC,YAAY,CAAC,aAAa,WAAW,QAAQ,aAAa,QAAQ;AAAA,MAClE,SAAS,CAAC,UAAuB;AAC/B,mBAAW,QAAQ,UAAU,KAAK;AAAA,MACpC;AAAA,MACA,UAAU,CAAC,YAA+B;AACxC,mBAAW,QAAQ,WAAW,OAAO;AAAA,MACvC;AAAA,MACA,SAAS,CAACA,WAAiB;AACzB,mBAAW,QAAQ,UAAUA,MAAK;AAAA,MACpC;AAAA,MACA,OAAO,WAAW,QAAQ;AAAA,MAC1B,eAAe,CAAC,WAAW,MAAM,YAC/B,WAAW,QAAQ,gBAAgB,WAAW,MAAM,OAAO;AAAA,MAC7D,iBAAiB,QAAQ;AAAA,MACzB,kBAAkB,CAAC,gBAA0C;AAC3D,oBAAY,WAAW;AAAA,MACzB;AAAA,MACA,iBAAiB,CAAC,iBAA0B;AAC1C,qBAAa,YAAY;AAAA,MAC3B;AAAA,MACA,eAAe,CAAC,aAAgC;AAC9C,iBAAS,QAAQ;AAAA,MACnB;AAAA,MACA,gBAAgB,CAACC,YAA4B;AAC3C,kBAAUA,OAAM;AAAA,MAClB;AAAA,MACA,sBAAsB,CAAC,qBAA8B;AACnD,wBAAgB,gBAAgB;AAAA,MAClC;AAAA,MACA,0BAA0B,CAAC,eAAiC;AAC1D,4BAAoB,UAAU;AAAA,MAChC;AAAA,MACA,2BAA2B,CAAC,iBAA0B;AACpD,6BAAqB,YAAY;AAAA,MACnC;AAAA,IAAA,CACD;AAAA,EACH,GAAG,CAAC,QAAQ,CAAC;AAEb,YAAU,MAAM;AACd,WAAO,cAAc;AAAA,MACnB,MAAM,QAAQ;AAAA,MACd,gBAAgB,QAAQ;AAAA,IAAA,CACzB;AAAA,EACH,GAAG,CAAC,QAAQ,QAAQ,MAAM,QAAQ,cAAc,CAAC;AAEjD,YAAU,MAAM;AACd,QAAI,QAAQ,mBAAmB,QAAQ,gBAAgB,SAAS,GAAG;AACjE,UAAI,SAAS,WAAW,GAAG;AACzB,eAAO,oBAAoB,QAAQ,eAAe;AAAA,MACpD;AAAA,IACF;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,YAAU,MAAM;AACd,QAAI,QAAQ,MAAM;AAChB,aAAO,UAAA;AAAA,IACT,OAAO;AACL,aAAO,YAAA;AAAA,IACT;AAAA,EACF,GAAG,CAAC,QAAQ,QAAQ,IAAI,CAAC;AAEzB,YAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,QAAQ,MAAM;AAChB,eAAO,YAAA;AAAA,MACT,OAAO;AACL,eAAO,KAAA;AAAA,MACT;AAAA,IACF;AAAA,EACF,GAAG,CAAC,QAAQ,QAAQ,IAAI,CAAC;AAEzB,QAAM,cAAc;AAAA,IAClB,OAAO,YAAwC;AAC7C,YAAM,OAAO,YAAY,OAAO;AAAA,IAClC;AAAA,IACA,CAAC,MAAM;AAAA,EAAA;AAGT,QAAM,SAAS;AAAA,IACb,OAAO,YAAsC;AAC3C,YAAM,OAAO,OAAO,OAAO;AAAA,IAC7B;AAAA,IACA,CAAC,MAAM;AAAA,EAAA;AAGT,QAAM,SAAS,YAAY,YAAY;AACrC,UAAM,OAAO,OAAA;AAAA,EACf,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,OAAO,YAAY,MAAM;AAC7B,WAAO,KAAA;AAAA,EACT,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,QAAQ,YAAY,MAAM;AAC9B,WAAO,MAAA;AAAA,EACT,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,sBAAsB;AAAA,IAC1B,CAAC,gBAA0C;AACzC,aAAO,oBAAoB,WAAW;AAAA,IACxC;AAAA,IACA,CAAC,MAAM;AAAA,EAAA;AAGT,QAAM,gBAAgB;AAAA,IACpB,OAAO,WAMD;AACJ,YAAM,OAAO,cAAc,MAAM;AAAA,IACnC;AAAA,IACA,CAAC,MAAM;AAAA,EAAA;AAGT,QAAM,0BAA0B;AAAA,IAC9B,OAAO,aAAgD;AACrD,YAAM,OAAO,wBAAwB,QAAQ;AAAA,IAC/C;AAAA,IACA,CAAC,MAAM;AAAA,EAAA;AAaT,QAAM,uBAAuB,QAAqC,MAAM;AACtE,QAAI,gBAAgB;AACpB,aAAS,IAAI,SAAS,SAAS,GAAG,KAAK,GAAG,KAAK;AAC7C,UAAI,SAAS,CAAC,EAAG,SAAS,QAAQ;AAChC,wBAAgB;AAChB;AAAA,MACF;AAAA,IACF;AACA,QAAI,kBAAkB,GAAI,QAAO;AACjC,aAAS,IAAI,SAAS,SAAS,GAAG,IAAI,eAAe,KAAK;AACxD,YAAM,IAAI,SAAS,CAAC;AACpB,UAAI,EAAE,SAAS,YAAa;AAC5B,YAAM,OAAO,EAAE,MAAM;AAAA,QACnB,CAAC,MAAiC,EAAE,SAAS;AAAA,MAAA;AAE/C,UAAI,KAAM,QAAO;AAAA,IACnB;AACA,WAAO;AAAA,EACT,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,UAAU,QAAiB,MAAM;AACrC,QAAI,CAAC,qBAAsB,QAAO,CAAA;AAClC,UAAM,IAAI,qBAAqB,WAAW,qBAAqB;AAC/D,WAAQ,KAAK,CAAA;AAAA,EACf,GAAG,CAAC,oBAAoB,CAAC;AAEzB,QAAM,QAAQ,QAAsB,MAAM;AACxC,QAAI,CAAC,wBAAwB,qBAAqB,WAAW,YAAY;AACvE,aAAO;AAAA,IACT;AACA,WAAO,qBAAqB;AAAA,EAC9B,GAAG,CAAC,oBAAoB,CAAC;AAEzB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,aAAa;AAAA,IACb;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EAAA;AAEJ;"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "React hooks for TanStack AI",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
"streaming"
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@tanstack/ai-client": "0.
|
|
34
|
+
"@tanstack/ai-client": "0.11.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@types/react": ">=18.0.0",
|
|
38
38
|
"react": ">=18.0.0",
|
|
39
|
-
"@tanstack/ai": "^0.
|
|
39
|
+
"@tanstack/ai": "^0.19.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@standard-schema/spec": "^1.1.0",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"@vitest/coverage-v8": "4.0.14",
|
|
46
46
|
"jsdom": "^27.2.0",
|
|
47
47
|
"react": "^19.2.3",
|
|
48
|
-
"vite": "^7.
|
|
49
|
-
"@tanstack/ai": "0.
|
|
48
|
+
"vite": "^7.3.3",
|
|
49
|
+
"@tanstack/ai": "0.19.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"clean": "premove ./build ./dist",
|
package/src/types.ts
CHANGED
|
@@ -86,7 +86,10 @@ export type UseChatOptions<
|
|
|
86
86
|
export type UseChatReturn<
|
|
87
87
|
TTools extends ReadonlyArray<AnyClientTool> = any,
|
|
88
88
|
TSchema extends SchemaInput | undefined = undefined,
|
|
89
|
-
> = BaseUseChatReturn<
|
|
89
|
+
> = BaseUseChatReturn<
|
|
90
|
+
TTools,
|
|
91
|
+
TSchema extends SchemaInput ? InferSchemaType<TSchema> : unknown
|
|
92
|
+
> &
|
|
90
93
|
(TSchema extends SchemaInput
|
|
91
94
|
? {
|
|
92
95
|
/**
|
|
@@ -105,11 +108,16 @@ export type UseChatReturn<
|
|
|
105
108
|
}
|
|
106
109
|
: Record<never, never>)
|
|
107
110
|
|
|
108
|
-
interface BaseUseChatReturn<
|
|
111
|
+
interface BaseUseChatReturn<
|
|
112
|
+
TTools extends ReadonlyArray<AnyClientTool> = any,
|
|
113
|
+
TData = unknown,
|
|
114
|
+
> {
|
|
109
115
|
/**
|
|
110
|
-
* Current messages in the conversation
|
|
116
|
+
* Current messages in the conversation. When `outputSchema` is supplied,
|
|
117
|
+
* `messages[i].parts.find(p => p.type === 'structured-output')` is typed
|
|
118
|
+
* with the schema's inferred shape — `data: T`, `partial: DeepPartial<T>`.
|
|
111
119
|
*/
|
|
112
|
-
messages: Array<UIMessage<TTools>>
|
|
120
|
+
messages: Array<UIMessage<TTools, TData>>
|
|
113
121
|
|
|
114
122
|
/**
|
|
115
123
|
* Send a message and get a response.
|
|
@@ -120,7 +128,7 @@ interface BaseUseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any> {
|
|
|
120
128
|
/**
|
|
121
129
|
* Append a message to the conversation
|
|
122
130
|
*/
|
|
123
|
-
append: (message: ModelMessage | UIMessage<TTools>) => Promise<void>
|
|
131
|
+
append: (message: ModelMessage | UIMessage<TTools, TData>) => Promise<void>
|
|
124
132
|
|
|
125
133
|
/**
|
|
126
134
|
* Add the result of a client-side tool execution
|
|
@@ -187,7 +195,7 @@ interface BaseUseChatReturn<TTools extends ReadonlyArray<AnyClientTool> = any> {
|
|
|
187
195
|
/**
|
|
188
196
|
* Set messages manually
|
|
189
197
|
*/
|
|
190
|
-
setMessages: (messages: Array<UIMessage<TTools>>) => void
|
|
198
|
+
setMessages: (messages: Array<UIMessage<TTools, TData>>) => void
|
|
191
199
|
|
|
192
200
|
/**
|
|
193
201
|
* Clear all messages
|
package/src/use-chat.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { ChatClient } from '@tanstack/ai-client'
|
|
2
|
-
import { parsePartialJSON } from '@tanstack/ai'
|
|
3
2
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
4
3
|
import type {
|
|
5
4
|
AnyClientTool,
|
|
@@ -8,7 +7,11 @@ import type {
|
|
|
8
7
|
SchemaInput,
|
|
9
8
|
StreamChunk,
|
|
10
9
|
} from '@tanstack/ai'
|
|
11
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
ChatClientState,
|
|
12
|
+
ConnectionStatus,
|
|
13
|
+
StructuredOutputPart,
|
|
14
|
+
} from '@tanstack/ai-client'
|
|
12
15
|
|
|
13
16
|
import type {
|
|
14
17
|
DeepPartial,
|
|
@@ -36,18 +39,8 @@ export function useChat<
|
|
|
36
39
|
useState<ConnectionStatus>('disconnected')
|
|
37
40
|
const [sessionGenerating, setSessionGenerating] = useState(false)
|
|
38
41
|
|
|
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
42
|
type Partial = DeepPartial<InferSchemaType<NonNullable<TSchema>>>
|
|
44
43
|
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
44
|
|
|
52
45
|
// Track current messages in a ref to preserve them when client is recreated
|
|
53
46
|
const messagesRef = useRef<Array<UIMessage<TTools>>>(
|
|
@@ -56,7 +49,6 @@ export function useChat<
|
|
|
56
49
|
const isFirstMountRef = useRef(true)
|
|
57
50
|
|
|
58
51
|
// Update ref synchronously during render so it's always current when useMemo runs.
|
|
59
|
-
// A useEffect here would be async and messagesRef could be stale on client recreation.
|
|
60
52
|
messagesRef.current = messages
|
|
61
53
|
|
|
62
54
|
// Track current options in a ref to avoid recreating client when options change
|
|
@@ -64,11 +56,7 @@ export function useChat<
|
|
|
64
56
|
optionsRef.current = options
|
|
65
57
|
|
|
66
58
|
// Create ChatClient instance with callbacks to sync state
|
|
67
|
-
// Note: Options are captured at client creation time.
|
|
68
|
-
// The connection adapter can use functions for dynamic values (url, headers, etc.)
|
|
69
|
-
// which are evaluated lazily on each request.
|
|
70
59
|
const client = useMemo(() => {
|
|
71
|
-
// On first mount, use initialMessages. On subsequent recreations, preserve existing messages.
|
|
72
60
|
const messagesToUse = isFirstMountRef.current
|
|
73
61
|
? options.initialMessages || []
|
|
74
62
|
: messagesRef.current
|
|
@@ -80,34 +68,9 @@ export function useChat<
|
|
|
80
68
|
id: clientId,
|
|
81
69
|
initialMessages: messagesToUse,
|
|
82
70
|
body: optionsRef.current.body,
|
|
83
|
-
|
|
84
|
-
// Capturing the function reference directly would freeze it to whatever
|
|
85
|
-
// the parent passed on the first render.
|
|
71
|
+
forwardedProps: optionsRef.current.forwardedProps,
|
|
86
72
|
onResponse: (response) => optionsRef.current.onResponse?.(response),
|
|
87
73
|
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
74
|
optionsRef.current.onChunk?.(chunk)
|
|
112
75
|
},
|
|
113
76
|
onFinish: (message: UIMessage<TTools>) => {
|
|
@@ -144,25 +107,21 @@ export function useChat<
|
|
|
144
107
|
})
|
|
145
108
|
}, [clientId])
|
|
146
109
|
|
|
147
|
-
// Sync body changes to the client
|
|
148
|
-
// This allows dynamic body values (like model selection) to be updated without recreating the client
|
|
149
110
|
useEffect(() => {
|
|
150
|
-
client.updateOptions({
|
|
151
|
-
|
|
111
|
+
client.updateOptions({
|
|
112
|
+
body: options.body,
|
|
113
|
+
forwardedProps: options.forwardedProps,
|
|
114
|
+
})
|
|
115
|
+
}, [client, options.body, options.forwardedProps])
|
|
152
116
|
|
|
153
|
-
// Sync initial messages on mount only
|
|
154
|
-
// Note: initialMessages are passed to ChatClient constructor, but we also
|
|
155
|
-
// set them here to ensure React state is in sync
|
|
156
117
|
useEffect(() => {
|
|
157
118
|
if (options.initialMessages && options.initialMessages.length > 0) {
|
|
158
|
-
// Only set if current messages are empty (initial state)
|
|
159
119
|
if (messages.length === 0) {
|
|
160
120
|
client.setMessagesManually(options.initialMessages)
|
|
161
121
|
}
|
|
162
122
|
}
|
|
163
|
-
}, [])
|
|
123
|
+
}, [])
|
|
164
124
|
|
|
165
|
-
// Keep connection lifecycle opt-in and explicit.
|
|
166
125
|
useEffect(() => {
|
|
167
126
|
if (options.live) {
|
|
168
127
|
client.subscribe()
|
|
@@ -171,13 +130,8 @@ export function useChat<
|
|
|
171
130
|
}
|
|
172
131
|
}, [client, options.live])
|
|
173
132
|
|
|
174
|
-
// Cleanup on unmount: stop any in-flight requests
|
|
175
|
-
// Note: We only cleanup when client changes or component unmounts.
|
|
176
|
-
// DO NOT include isLoading in dependencies - that would cause the cleanup
|
|
177
|
-
// to run when isLoading changes, aborting continuation requests.
|
|
178
133
|
useEffect(() => {
|
|
179
134
|
return () => {
|
|
180
|
-
// live mode owns the connection lifecycle; non-live keeps request-only stop.
|
|
181
135
|
if (options.live) {
|
|
182
136
|
client.unsubscribe()
|
|
183
137
|
} else {
|
|
@@ -186,9 +140,6 @@ export function useChat<
|
|
|
186
140
|
}
|
|
187
141
|
}, [client, options.live])
|
|
188
142
|
|
|
189
|
-
// All callback options are read through optionsRef at call time, so fresh
|
|
190
|
-
// closures from each render are picked up without recreating the client.
|
|
191
|
-
|
|
192
143
|
const sendMessage = useCallback(
|
|
193
144
|
async (content: string | MultimodalContent) => {
|
|
194
145
|
await client.sendMessage(content)
|
|
@@ -242,10 +193,49 @@ export function useChat<
|
|
|
242
193
|
[client],
|
|
243
194
|
)
|
|
244
195
|
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
196
|
+
// The "active" structured-output part is the one on the assistant message
|
|
197
|
+
// that follows the latest user message. No such message exists between
|
|
198
|
+
// sendMessage() and the first chunk, so partial/final naturally read as
|
|
199
|
+
// cleared. Historical parts on earlier assistant messages remain available
|
|
200
|
+
// via `messages` directly.
|
|
201
|
+
//
|
|
202
|
+
// When there is NO user message yet (e.g. `initialMessages` contains only
|
|
203
|
+
// a stale assistant turn or a system prompt) we deliberately return null
|
|
204
|
+
// rather than scanning historical assistants — otherwise a `final` from a
|
|
205
|
+
// previous session would leak into the hook value on first render.
|
|
206
|
+
const activeStructuredPart = useMemo<StructuredOutputPart | null>(() => {
|
|
207
|
+
let lastUserIndex = -1
|
|
208
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
209
|
+
if (messages[i]!.role === 'user') {
|
|
210
|
+
lastUserIndex = i
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (lastUserIndex === -1) return null
|
|
215
|
+
for (let i = messages.length - 1; i > lastUserIndex; i--) {
|
|
216
|
+
const m = messages[i]!
|
|
217
|
+
if (m.role !== 'assistant') continue
|
|
218
|
+
const part = m.parts.find(
|
|
219
|
+
(p): p is StructuredOutputPart => p.type === 'structured-output',
|
|
220
|
+
)
|
|
221
|
+
if (part) return part
|
|
222
|
+
}
|
|
223
|
+
return null
|
|
224
|
+
}, [messages])
|
|
225
|
+
|
|
226
|
+
const partial = useMemo<Partial>(() => {
|
|
227
|
+
if (!activeStructuredPart) return {} as Partial
|
|
228
|
+
const v = activeStructuredPart.partial ?? activeStructuredPart.data
|
|
229
|
+
return (v ?? {}) as Partial
|
|
230
|
+
}, [activeStructuredPart])
|
|
231
|
+
|
|
232
|
+
const final = useMemo<Final | null>(() => {
|
|
233
|
+
if (!activeStructuredPart || activeStructuredPart.status !== 'complete') {
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
return activeStructuredPart.data as Final
|
|
237
|
+
}, [activeStructuredPart])
|
|
238
|
+
|
|
249
239
|
return {
|
|
250
240
|
messages,
|
|
251
241
|
sendMessage,
|