@tanstack/ai-preact 0.11.1 → 0.12.1
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 +15 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +2 -14
- package/dist/esm/mcp-apps.js +66 -45
- package/dist/esm/mcp-apps.js.map +1 -1
- package/dist/esm/types.d.ts +29 -3
- package/dist/esm/use-chat.js +299 -243
- package/dist/esm/use-chat.js.map +1 -1
- package/dist/esm/use-mcp-app-bridge.js +46 -23
- package/dist/esm/use-mcp-app-bridge.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +10 -0
- package/src/types.ts +49 -1
- package/src/use-chat.ts +195 -27
- package/dist/esm/index.js.map +0 -1
|
@@ -1,26 +1,49 @@
|
|
|
1
|
-
import { useRef, useMemo } from "preact/hooks";
|
|
2
1
|
import { createMcpAppBridge } from "@tanstack/ai-client";
|
|
2
|
+
import { useMemo, useRef } from "preact/hooks";
|
|
3
|
+
//#region src/use-mcp-app-bridge.ts
|
|
4
|
+
/**
|
|
5
|
+
* Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for
|
|
6
|
+
* the given `threadId`/`callEndpoint`, while always invoking the latest
|
|
7
|
+
* `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating
|
|
8
|
+
* the bridge on every render and the stale-closure / `exhaustive-deps` dance
|
|
9
|
+
* you'd otherwise write by hand:
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const { sendMessage } = useChat({ threadId, connection })
|
|
13
|
+
* const bridge = useMcpAppBridge({
|
|
14
|
+
* threadId,
|
|
15
|
+
* callEndpoint: '/api/mcp-apps-call',
|
|
16
|
+
* chat: { sendMessage: async (content) => void sendMessage(content) },
|
|
17
|
+
* onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
|
18
|
+
* })
|
|
19
|
+
* // pass `bridge` to <MCPAppResource bridge={bridge} … />
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or
|
|
23
|
+
* the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`
|
|
24
|
+
* each render does not churn it.
|
|
25
|
+
*/
|
|
3
26
|
function useMcpAppBridge(options) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
const { threadId, callEndpoint, chat, fetchImpl, onLink } = options;
|
|
28
|
+
const chatRef = useRef(chat);
|
|
29
|
+
chatRef.current = chat;
|
|
30
|
+
const onLinkRef = useRef(onLink);
|
|
31
|
+
onLinkRef.current = onLink;
|
|
32
|
+
const hasOnLink = onLink != null;
|
|
33
|
+
return useMemo(() => createMcpAppBridge({
|
|
34
|
+
threadId,
|
|
35
|
+
callEndpoint,
|
|
36
|
+
fetchImpl,
|
|
37
|
+
chat: { sendMessage: (content, body) => chatRef.current.sendMessage(content, body) },
|
|
38
|
+
onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : void 0
|
|
39
|
+
}), [
|
|
40
|
+
threadId,
|
|
41
|
+
callEndpoint,
|
|
42
|
+
fetchImpl,
|
|
43
|
+
hasOnLink
|
|
44
|
+
]);
|
|
22
45
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//# sourceMappingURL=use-mcp-app-bridge.js.map
|
|
46
|
+
//#endregion
|
|
47
|
+
export { useMcpAppBridge };
|
|
48
|
+
|
|
49
|
+
//# sourceMappingURL=use-mcp-app-bridge.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-mcp-app-bridge.js","sources":["../../src/use-mcp-app-bridge.ts"],"sourcesContent":["import { useMemo, useRef } from 'preact/hooks'\nimport { createMcpAppBridge } from '@tanstack/ai-client'\nimport type {\n CreateMcpAppBridgeOptions,\n McpAppBridge,\n} from '@tanstack/ai-client'\n\nexport type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions\n\n/**\n * Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for\n * the given `threadId`/`callEndpoint`, while always invoking the latest\n * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating\n * the bridge on every render and the stale-closure / `exhaustive-deps` dance\n * you'd otherwise write by hand:\n *\n * ```tsx\n * const { sendMessage } = useChat({ threadId, connection })\n * const bridge = useMcpAppBridge({\n * threadId,\n * callEndpoint: '/api/mcp-apps-call',\n * chat: { sendMessage: async (content) => void sendMessage(content) },\n * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),\n * })\n * // pass `bridge` to <MCPAppResource bridge={bridge} … />\n * ```\n *\n * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or\n * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`\n * each render does not churn it.\n */\nexport function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge {\n const { threadId, callEndpoint, chat, fetchImpl, onLink } = options\n\n // Latest-value refs so the bridge identity stays stable but its callbacks are\n // never stale (the bridge calls `.current` at invocation time, not creation).\n const chatRef = useRef(chat)\n chatRef.current = chat\n const onLinkRef = useRef(onLink)\n onLinkRef.current = onLink\n\n // Whether a link handler was supplied governs the bridge's link behavior\n // (forward vs. display-only warn), so it's part of the bridge's identity.\n const hasOnLink = onLink != null\n\n return useMemo(\n () =>\n createMcpAppBridge({\n threadId,\n callEndpoint,\n fetchImpl,\n chat: {\n sendMessage: (content, body) =>\n chatRef.current.sendMessage(content, body),\n },\n onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : undefined,\n }),\n [threadId, callEndpoint, fetchImpl, hasOnLink],\n )\n}\n"],"
|
|
1
|
+
{"version":3,"file":"use-mcp-app-bridge.js","names":[],"sources":["../../src/use-mcp-app-bridge.ts"],"sourcesContent":["import { useMemo, useRef } from 'preact/hooks'\nimport { createMcpAppBridge } from '@tanstack/ai-client'\nimport type {\n CreateMcpAppBridgeOptions,\n McpAppBridge,\n} from '@tanstack/ai-client'\n\nexport type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions\n\n/**\n * Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for\n * the given `threadId`/`callEndpoint`, while always invoking the latest\n * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating\n * the bridge on every render and the stale-closure / `exhaustive-deps` dance\n * you'd otherwise write by hand:\n *\n * ```tsx\n * const { sendMessage } = useChat({ threadId, connection })\n * const bridge = useMcpAppBridge({\n * threadId,\n * callEndpoint: '/api/mcp-apps-call',\n * chat: { sendMessage: async (content) => void sendMessage(content) },\n * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),\n * })\n * // pass `bridge` to <MCPAppResource bridge={bridge} … />\n * ```\n *\n * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or\n * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`\n * each render does not churn it.\n */\nexport function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge {\n const { threadId, callEndpoint, chat, fetchImpl, onLink } = options\n\n // Latest-value refs so the bridge identity stays stable but its callbacks are\n // never stale (the bridge calls `.current` at invocation time, not creation).\n const chatRef = useRef(chat)\n chatRef.current = chat\n const onLinkRef = useRef(onLink)\n onLinkRef.current = onLink\n\n // Whether a link handler was supplied governs the bridge's link behavior\n // (forward vs. display-only warn), so it's part of the bridge's identity.\n const hasOnLink = onLink != null\n\n return useMemo(\n () =>\n createMcpAppBridge({\n threadId,\n callEndpoint,\n fetchImpl,\n chat: {\n sendMessage: (content, body) =>\n chatRef.current.sendMessage(content, body),\n },\n onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : undefined,\n }),\n [threadId, callEndpoint, fetchImpl, hasOnLink],\n )\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,SAAgB,gBAAgB,SAA+C;CAC7E,MAAM,EAAE,UAAU,cAAc,MAAM,WAAW,WAAW;CAI5D,MAAM,UAAU,OAAO,IAAI;CAC3B,QAAQ,UAAU;CAClB,MAAM,YAAY,OAAO,MAAM;CAC/B,UAAU,UAAU;CAIpB,MAAM,YAAY,UAAU;CAE5B,OAAO,cAEH,mBAAmB;EACjB;EACA;EACA;EACA,MAAM,EACJ,cAAc,SAAS,SACrB,QAAQ,QAAQ,YAAY,SAAS,IAAI,EAC7C;EACA,QAAQ,aAAa,QAAQ,UAAU,UAAU,GAAG,IAAI,KAAA;CAC1D,CAAC,GACH;EAAC;EAAU;EAAc;EAAW;CAAS,CAC/C;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-preact",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Preact hooks for TanStack AI streaming chat and typed messages.",
|
|
5
5
|
"author": "Tanner Linsley",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"tool-calling"
|
|
47
47
|
],
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@tanstack/ai-client": "0.
|
|
49
|
+
"@tanstack/ai-client": "0.23.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@mcp-ui/client": "^7.1.1",
|
|
@@ -54,13 +54,13 @@
|
|
|
54
54
|
"@vitest/coverage-v8": "4.0.14",
|
|
55
55
|
"jsdom": "^27.2.0",
|
|
56
56
|
"preact": "^10.26.9",
|
|
57
|
-
"vite": "^
|
|
58
|
-
"@tanstack/ai": "0.
|
|
57
|
+
"vite": "^8.1.4",
|
|
58
|
+
"@tanstack/ai": "0.43.1"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@mcp-ui/client": "^7",
|
|
62
62
|
"preact": ">=10.11.0",
|
|
63
|
-
"@tanstack/ai": "^0.
|
|
63
|
+
"@tanstack/ai": "^0.43.1"
|
|
64
64
|
},
|
|
65
65
|
"peerDependenciesMeta": {
|
|
66
66
|
"@mcp-ui/client": {
|
|
@@ -69,8 +69,8 @@
|
|
|
69
69
|
},
|
|
70
70
|
"scripts": {
|
|
71
71
|
"clean": "premove ./build ./dist",
|
|
72
|
-
"lint:fix": "
|
|
73
|
-
"test:
|
|
72
|
+
"lint:fix": "oxlint src --type-aware --fix",
|
|
73
|
+
"test:oxlint": "oxlint src --type-aware",
|
|
74
74
|
"test:lib": "vitest run",
|
|
75
75
|
"test:lib:dev": "pnpm test:lib --watch",
|
|
76
76
|
"test:types": "tsc",
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,16 @@ export type {
|
|
|
16
16
|
|
|
17
17
|
export {
|
|
18
18
|
fetchServerSentEvents,
|
|
19
|
+
localStoragePersistence,
|
|
20
|
+
sessionStoragePersistence,
|
|
21
|
+
indexedDBPersistence,
|
|
22
|
+
StorageUnavailableError,
|
|
23
|
+
type ChatClientPersistence,
|
|
24
|
+
type ChatPersistedState,
|
|
25
|
+
type ChatPersistenceOption,
|
|
26
|
+
type ChatStorageAdapter,
|
|
27
|
+
type WebStoragePersistenceOptions,
|
|
28
|
+
type IndexedDBPersistenceOptions,
|
|
19
29
|
fetchHttpStream,
|
|
20
30
|
xhrServerSentEvents,
|
|
21
31
|
xhrHttpStream,
|
package/src/types.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AnyClientTool,
|
|
3
|
+
ModelMessage,
|
|
4
|
+
RunAgentResumeItem,
|
|
5
|
+
SchemaInput,
|
|
6
|
+
} from '@tanstack/ai'
|
|
2
7
|
import type {
|
|
3
8
|
AIDevtoolsDisplayOptions,
|
|
9
|
+
BoundInterrupts,
|
|
4
10
|
ChatClientOptions,
|
|
5
11
|
ChatClientState,
|
|
12
|
+
ChatInterrupt,
|
|
13
|
+
ChatInterruptState,
|
|
6
14
|
ChatRequestBody,
|
|
15
|
+
ChatResumeState,
|
|
7
16
|
ClientContextOptionFromTools,
|
|
8
17
|
ConnectionStatus,
|
|
9
18
|
DistributedOmit,
|
|
@@ -60,8 +69,14 @@ export type UseChatOptions<
|
|
|
60
69
|
| 'onConnectionStatusChange'
|
|
61
70
|
| 'onSessionGeneratingChange'
|
|
62
71
|
| 'onQueueChange'
|
|
72
|
+
| 'onResumeStateChange'
|
|
73
|
+
| 'onRunIdChange'
|
|
63
74
|
| 'context'
|
|
64
75
|
| 'devtools'
|
|
76
|
+
// `id` is not a hook option: the hook's identity is its `threadId`, which is
|
|
77
|
+
// also the persistence key. Persist across reloads by passing a stable
|
|
78
|
+
// `threadId`; there is no separate id to set.
|
|
79
|
+
| 'id'
|
|
65
80
|
> & {
|
|
66
81
|
live?: boolean
|
|
67
82
|
/** Display options for TanStack AI Devtools. */
|
|
@@ -126,6 +141,39 @@ export interface UseChatReturn<
|
|
|
126
141
|
approved: boolean
|
|
127
142
|
}) => Promise<void>
|
|
128
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The id of the run this client has in flight — one it started or rejoined —
|
|
146
|
+
* or `null` when there is none (including while a run sits paused on an
|
|
147
|
+
* interrupt, waiting on approval).
|
|
148
|
+
*
|
|
149
|
+
* A run is one turn of the conversation, so this changes from turn to turn. A
|
|
150
|
+
* whole tool loop stays inside one run, while resuming after an interrupt
|
|
151
|
+
* continues the turn under a new id — so one user message can produce several
|
|
152
|
+
* run ids. Use it to talk to your own server about that run (cancel it, poll
|
|
153
|
+
* it, correlate a log line).
|
|
154
|
+
*/
|
|
155
|
+
runId: string | null
|
|
156
|
+
interrupts: BoundInterrupts<TTools>
|
|
157
|
+
/** @deprecated Use `interrupts`. */
|
|
158
|
+
pendingInterrupts: BoundInterrupts<TTools>
|
|
159
|
+
interruptErrors: ChatInterruptState<TTools>['interruptErrors']
|
|
160
|
+
resuming: boolean
|
|
161
|
+
resolveInterrupts: {
|
|
162
|
+
(approved: boolean): void
|
|
163
|
+
(resolver: (interrupt: ChatInterrupt<TTools>) => undefined): void
|
|
164
|
+
}
|
|
165
|
+
cancelInterrupts: () => void
|
|
166
|
+
retryInterrupts: () => void
|
|
167
|
+
resumeInterruptsUnsafe: (
|
|
168
|
+
resume: Array<RunAgentResumeItem>,
|
|
169
|
+
state?: ChatResumeState,
|
|
170
|
+
) => Promise<boolean>
|
|
171
|
+
/** @deprecated Use bound interrupt methods or `resumeInterruptsUnsafe`. */
|
|
172
|
+
resumeInterrupts: (
|
|
173
|
+
resume: Array<RunAgentResumeItem>,
|
|
174
|
+
state?: ChatResumeState,
|
|
175
|
+
) => Promise<boolean>
|
|
176
|
+
|
|
129
177
|
/**
|
|
130
178
|
* Reload the last assistant message
|
|
131
179
|
*/
|
package/src/use-chat.ts
CHANGED
|
@@ -10,12 +10,19 @@ import {
|
|
|
10
10
|
} from 'preact/hooks'
|
|
11
11
|
import type {
|
|
12
12
|
ChatClientState,
|
|
13
|
+
ChatInterrupt,
|
|
14
|
+
ChatInterruptState,
|
|
15
|
+
ChatResumeState,
|
|
13
16
|
ConnectionStatus,
|
|
14
17
|
InferredClientContext,
|
|
15
18
|
QueuedMessage,
|
|
16
19
|
SendMessageOptions,
|
|
17
20
|
} from '@tanstack/ai-client'
|
|
18
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
AnyClientTool,
|
|
23
|
+
ModelMessage,
|
|
24
|
+
RunAgentResumeItem,
|
|
25
|
+
} from '@tanstack/ai'
|
|
19
26
|
|
|
20
27
|
import type {
|
|
21
28
|
MultimodalContent,
|
|
@@ -24,12 +31,19 @@ import type {
|
|
|
24
31
|
UseChatReturn,
|
|
25
32
|
} from './types'
|
|
26
33
|
|
|
34
|
+
const EMPTY_INTERRUPTS = Object.freeze([])
|
|
35
|
+
const EMPTY_INTERRUPT_ERRORS = Object.freeze([])
|
|
36
|
+
|
|
27
37
|
export function useChat<
|
|
28
38
|
const TTools extends ReadonlyArray<AnyClientTool> = any,
|
|
29
39
|
TContext = InferredClientContext<TTools>,
|
|
30
40
|
>(options: UseChatOptions<TTools, TContext>): UseChatReturn<TTools> {
|
|
41
|
+
// The hook's identity is its `threadId` — also the persistence key, so a
|
|
42
|
+
// reload with the same `threadId` restores the same conversation. `hookId` is
|
|
43
|
+
// only a stable fallback for client-recreation keying when no `threadId` is
|
|
44
|
+
// given (an ephemeral chat), never a persistence key.
|
|
31
45
|
const hookId = useId()
|
|
32
|
-
const clientId = options.
|
|
46
|
+
const clientId = options.threadId ?? hookId
|
|
33
47
|
|
|
34
48
|
const [messages, setMessages] = useState<Array<UIMessage<TTools>>>(
|
|
35
49
|
options.initialMessages || [],
|
|
@@ -42,6 +56,15 @@ export function useChat<
|
|
|
42
56
|
useState<ConnectionStatus>('disconnected')
|
|
43
57
|
const [sessionGenerating, setSessionGenerating] = useState(false)
|
|
44
58
|
const [queue, setQueue] = useState<Array<QueuedMessage>>([])
|
|
59
|
+
const [runId, setRunId] = useState<string | null>(null)
|
|
60
|
+
const [interruptState, setInterruptState] = useState<
|
|
61
|
+
ChatInterruptState<TTools>
|
|
62
|
+
>(() => ({
|
|
63
|
+
interrupts: EMPTY_INTERRUPTS,
|
|
64
|
+
pendingInterrupts: EMPTY_INTERRUPTS,
|
|
65
|
+
interruptErrors: EMPTY_INTERRUPT_ERRORS,
|
|
66
|
+
resuming: false,
|
|
67
|
+
}))
|
|
45
68
|
|
|
46
69
|
// Track current messages in a ref to preserve them when client is recreated
|
|
47
70
|
const messagesRef = useRef<Array<UIMessage<TTools>>>(
|
|
@@ -52,10 +75,20 @@ export function useChat<
|
|
|
52
75
|
const cleanupInvalidationRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
53
76
|
null,
|
|
54
77
|
)
|
|
78
|
+
const cleanupDisposalRef = useRef<{
|
|
79
|
+
client: ChatClient
|
|
80
|
+
timeout: ReturnType<typeof setTimeout>
|
|
81
|
+
} | null>(null)
|
|
55
82
|
const optionsRef = useRef<UseChatOptions<TTools, TContext>>(options)
|
|
56
83
|
|
|
57
84
|
optionsRef.current = options
|
|
58
85
|
|
|
86
|
+
const syncResumeState = useCallback((target: ChatClient | null) => {
|
|
87
|
+
if (!target) return
|
|
88
|
+
setRunId(target.getCurrentRunId())
|
|
89
|
+
setInterruptState(target.getInterruptState())
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
59
92
|
useEffect(() => {
|
|
60
93
|
messagesRef.current = messages
|
|
61
94
|
}, [messages])
|
|
@@ -73,10 +106,20 @@ export function useChat<
|
|
|
73
106
|
? { connection: initialOptions.connection }
|
|
74
107
|
: { fetcher: initialOptions.fetcher }
|
|
75
108
|
|
|
109
|
+
const instanceHolder: {
|
|
110
|
+
current: ChatClient<TTools, TContext> | undefined
|
|
111
|
+
} = { current: undefined }
|
|
112
|
+
const getActiveInstance = () => {
|
|
113
|
+
const currentInstance = instanceHolder.current
|
|
114
|
+
if (!currentInstance || activeClientRef.current !== currentInstance) {
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
return currentInstance
|
|
118
|
+
}
|
|
119
|
+
const pendingInitializationErrors: Array<Error> = []
|
|
76
120
|
const instance = new ChatClient<TTools, TContext>({
|
|
77
121
|
devtoolsBridgeFactory: createChatDevtoolsBridge,
|
|
78
122
|
...transport,
|
|
79
|
-
id: clientId,
|
|
80
123
|
initialMessages: messagesToUse,
|
|
81
124
|
...(initialOptions.body !== undefined && { body: initialOptions.body }),
|
|
82
125
|
...(initialOptions.threadId !== undefined && {
|
|
@@ -88,6 +131,9 @@ export function useChat<
|
|
|
88
131
|
...(initialOptions.persistence !== undefined && {
|
|
89
132
|
persistence: initialOptions.persistence,
|
|
90
133
|
}),
|
|
134
|
+
...(initialOptions.initialResumeSnapshot !== undefined && {
|
|
135
|
+
initialResumeSnapshot: initialOptions.initialResumeSnapshot,
|
|
136
|
+
}),
|
|
91
137
|
...(initialOptions.context !== undefined && {
|
|
92
138
|
context: initialOptions.context,
|
|
93
139
|
}),
|
|
@@ -101,23 +147,28 @@ export function useChat<
|
|
|
101
147
|
// Capturing the function reference directly would freeze it to whatever
|
|
102
148
|
// the parent passed on the first render.
|
|
103
149
|
onResponse: (response) => {
|
|
104
|
-
if (
|
|
150
|
+
if (!getActiveInstance()) return
|
|
105
151
|
return optionsRef.current.onResponse?.(response)
|
|
106
152
|
},
|
|
107
153
|
onChunk: (chunk) => {
|
|
108
|
-
if (
|
|
154
|
+
if (!getActiveInstance()) return
|
|
109
155
|
optionsRef.current.onChunk?.(chunk)
|
|
110
156
|
},
|
|
111
157
|
onFinish: (message) => {
|
|
112
|
-
if (
|
|
158
|
+
if (!getActiveInstance()) return
|
|
113
159
|
optionsRef.current.onFinish?.(message)
|
|
114
160
|
},
|
|
115
161
|
onError: (err) => {
|
|
116
|
-
|
|
162
|
+
const currentInstance = instanceHolder.current
|
|
163
|
+
if (!currentInstance) {
|
|
164
|
+
pendingInitializationErrors.push(err)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if (activeClientRef.current !== currentInstance) return
|
|
117
168
|
optionsRef.current.onError?.(err)
|
|
118
169
|
},
|
|
119
170
|
onCustomEvent: (eventType, data, context) => {
|
|
120
|
-
if (
|
|
171
|
+
if (!getActiveInstance()) return
|
|
121
172
|
optionsRef.current.onCustomEvent?.(eventType, data, context)
|
|
122
173
|
},
|
|
123
174
|
...(initialOptions.tools !== undefined && {
|
|
@@ -127,44 +178,68 @@ export function useChat<
|
|
|
127
178
|
streamProcessor: options.streamProcessor,
|
|
128
179
|
}),
|
|
129
180
|
onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {
|
|
130
|
-
if (
|
|
181
|
+
if (!getActiveInstance()) return
|
|
131
182
|
setMessages(newMessages)
|
|
132
183
|
},
|
|
133
184
|
onLoadingChange: (newIsLoading: boolean) => {
|
|
134
|
-
|
|
185
|
+
const currentInstance = getActiveInstance()
|
|
186
|
+
if (!currentInstance) return
|
|
135
187
|
setIsLoading(newIsLoading)
|
|
188
|
+
syncResumeState(currentInstance)
|
|
136
189
|
},
|
|
137
190
|
onStatusChange: (newStatus: ChatClientState) => {
|
|
138
|
-
if (
|
|
191
|
+
if (!getActiveInstance()) return
|
|
139
192
|
setStatus(newStatus)
|
|
140
193
|
},
|
|
141
194
|
onErrorChange: (newError: Error | undefined) => {
|
|
142
|
-
if (
|
|
195
|
+
if (!getActiveInstance()) return
|
|
143
196
|
setError(newError)
|
|
144
197
|
},
|
|
145
198
|
onSubscriptionChange: (nextIsSubscribed: boolean) => {
|
|
146
|
-
if (
|
|
199
|
+
if (!getActiveInstance()) return
|
|
147
200
|
setIsSubscribed(nextIsSubscribed)
|
|
148
201
|
},
|
|
149
202
|
onConnectionStatusChange: (nextStatus: ConnectionStatus) => {
|
|
150
|
-
if (
|
|
203
|
+
if (!getActiveInstance()) return
|
|
151
204
|
setConnectionStatus(nextStatus)
|
|
152
205
|
},
|
|
153
206
|
onSessionGeneratingChange: (isGenerating: boolean) => {
|
|
154
|
-
if (
|
|
207
|
+
if (!getActiveInstance()) return
|
|
155
208
|
setSessionGenerating(isGenerating)
|
|
156
209
|
},
|
|
157
210
|
...(optionsRef.current.queue !== undefined && {
|
|
158
211
|
queue: optionsRef.current.queue,
|
|
159
212
|
}),
|
|
160
213
|
onQueueChange: (nextQueue: Array<QueuedMessage>) => {
|
|
161
|
-
if (
|
|
214
|
+
if (!getActiveInstance()) return
|
|
162
215
|
setQueue(nextQueue)
|
|
163
216
|
},
|
|
217
|
+
onRunIdChange: (nextRunId) => {
|
|
218
|
+
if (!getActiveInstance()) return
|
|
219
|
+
setRunId(nextRunId)
|
|
220
|
+
},
|
|
221
|
+
onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => {
|
|
222
|
+
if (!getActiveInstance()) return
|
|
223
|
+
setInterruptState((current) => ({
|
|
224
|
+
...current,
|
|
225
|
+
interrupts: nextPendingInterrupts,
|
|
226
|
+
pendingInterrupts: nextPendingInterrupts,
|
|
227
|
+
}))
|
|
228
|
+
},
|
|
229
|
+
onInterruptStateChange: (nextInterruptState) => {
|
|
230
|
+
if (!getActiveInstance()) return
|
|
231
|
+
setInterruptState(nextInterruptState)
|
|
232
|
+
optionsRef.current.onInterruptStateChange?.(nextInterruptState)
|
|
233
|
+
},
|
|
164
234
|
})
|
|
235
|
+
instanceHolder.current = instance
|
|
165
236
|
activeClientRef.current = instance
|
|
237
|
+
for (const initializationError of pendingInitializationErrors) {
|
|
238
|
+
if (activeClientRef.current !== instance) break
|
|
239
|
+
optionsRef.current.onError?.(initializationError)
|
|
240
|
+
}
|
|
166
241
|
return instance
|
|
167
|
-
}, [clientId])
|
|
242
|
+
}, [clientId, syncResumeState])
|
|
168
243
|
|
|
169
244
|
useEffect(() => {
|
|
170
245
|
const clientMessages = client.getMessages()
|
|
@@ -203,17 +278,41 @@ export function useChat<
|
|
|
203
278
|
}
|
|
204
279
|
}, [client, options.live])
|
|
205
280
|
|
|
281
|
+
// ONLY THE VIEW ON SCREEN HOLDS A STREAM. See the same effect in
|
|
282
|
+
// `@tanstack/ai-react`. A page can own many chats and a browser allows only ~6
|
|
283
|
+
// connections per origin, so one long-lived stream per chat starves everything
|
|
284
|
+
// else once a handful of views have been open. `attach` is idempotent and
|
|
285
|
+
// `detach` keeps the transcript and the resume pointer, so a remount picks the
|
|
286
|
+
// run straight back up from the durable log.
|
|
287
|
+
//
|
|
288
|
+
// Immediate, unlike the deferred disposal below, which a remount can skip —
|
|
289
|
+
// correct for disposal, useless for a connection.
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
client.attach()
|
|
292
|
+
return () => {
|
|
293
|
+
client.detach()
|
|
294
|
+
}
|
|
295
|
+
}, [client])
|
|
296
|
+
|
|
206
297
|
// Cleanup on unmount: stop any in-flight requests
|
|
207
298
|
// Note: We only cleanup when client changes or component unmounts.
|
|
208
299
|
// DO NOT include isLoading in dependencies - that would cause the cleanup
|
|
209
300
|
// to run when isLoading changes, aborting continuation requests.
|
|
210
301
|
useEffect(() => {
|
|
302
|
+
if (cleanupDisposalRef.current?.client === client) {
|
|
303
|
+
clearTimeout(cleanupDisposalRef.current.timeout)
|
|
304
|
+
cleanupDisposalRef.current = null
|
|
305
|
+
}
|
|
211
306
|
if (cleanupInvalidationRef.current) {
|
|
212
307
|
clearTimeout(cleanupInvalidationRef.current)
|
|
213
308
|
cleanupInvalidationRef.current = null
|
|
214
309
|
}
|
|
215
310
|
activeClientRef.current = client
|
|
216
311
|
client.mountDevtools()
|
|
312
|
+
// Delivery-durability resume is transparent: the resumable SSE connection
|
|
313
|
+
// adapter reattaches via the browser's native Last-Event-ID on reconnect.
|
|
314
|
+
// We only seed interrupt (state) resume from the client here.
|
|
315
|
+
syncResumeState(client)
|
|
217
316
|
|
|
218
317
|
return () => {
|
|
219
318
|
cleanupInvalidationRef.current = setTimeout(() => {
|
|
@@ -231,9 +330,18 @@ export function useChat<
|
|
|
231
330
|
} else {
|
|
232
331
|
client.stop()
|
|
233
332
|
}
|
|
234
|
-
|
|
333
|
+
const disposal = {
|
|
334
|
+
client,
|
|
335
|
+
timeout: setTimeout(() => {
|
|
336
|
+
client.dispose()
|
|
337
|
+
if (cleanupDisposalRef.current === disposal) {
|
|
338
|
+
cleanupDisposalRef.current = null
|
|
339
|
+
}
|
|
340
|
+
}, 0),
|
|
341
|
+
}
|
|
342
|
+
cleanupDisposalRef.current = disposal
|
|
235
343
|
}
|
|
236
|
-
}, [client])
|
|
344
|
+
}, [client, syncResumeState])
|
|
237
345
|
|
|
238
346
|
// All callback options are read through optionsRef at call time, so fresh
|
|
239
347
|
// closures from each render are picked up without recreating the client.
|
|
@@ -242,28 +350,40 @@ export function useChat<
|
|
|
242
350
|
content: string | MultimodalContent,
|
|
243
351
|
sendOptions?: SendMessageOptions,
|
|
244
352
|
) => {
|
|
245
|
-
|
|
353
|
+
try {
|
|
354
|
+
await client.sendMessage(content, undefined, sendOptions)
|
|
355
|
+
} finally {
|
|
356
|
+
syncResumeState(client)
|
|
357
|
+
}
|
|
246
358
|
},
|
|
247
|
-
[client],
|
|
359
|
+
[client, syncResumeState],
|
|
248
360
|
)
|
|
249
361
|
|
|
250
362
|
const cancelQueued = useCallback(
|
|
251
363
|
(id: string) => {
|
|
252
364
|
client.cancelQueued(id)
|
|
253
365
|
},
|
|
254
|
-
[client],
|
|
366
|
+
[client, syncResumeState],
|
|
255
367
|
)
|
|
256
368
|
|
|
257
369
|
const append = useCallback(
|
|
258
370
|
async (message: ModelMessage | UIMessage) => {
|
|
259
|
-
|
|
371
|
+
try {
|
|
372
|
+
await client.append(message)
|
|
373
|
+
} finally {
|
|
374
|
+
syncResumeState(client)
|
|
375
|
+
}
|
|
260
376
|
},
|
|
261
|
-
[client],
|
|
377
|
+
[client, syncResumeState],
|
|
262
378
|
)
|
|
263
379
|
|
|
264
380
|
const reload = useCallback(async () => {
|
|
265
|
-
|
|
266
|
-
|
|
381
|
+
try {
|
|
382
|
+
await client.reload()
|
|
383
|
+
} finally {
|
|
384
|
+
syncResumeState(client)
|
|
385
|
+
}
|
|
386
|
+
}, [client, syncResumeState])
|
|
267
387
|
|
|
268
388
|
const stop = useCallback(() => {
|
|
269
389
|
client.stop()
|
|
@@ -271,7 +391,8 @@ export function useChat<
|
|
|
271
391
|
|
|
272
392
|
const clear = useCallback(() => {
|
|
273
393
|
client.clear()
|
|
274
|
-
|
|
394
|
+
syncResumeState(client)
|
|
395
|
+
}, [client, syncResumeState])
|
|
275
396
|
|
|
276
397
|
const setMessagesManually = useCallback(
|
|
277
398
|
(newMessages: Array<UIMessage<TTools>>) => {
|
|
@@ -296,7 +417,44 @@ export function useChat<
|
|
|
296
417
|
const addToolApprovalResponse = useCallback(
|
|
297
418
|
async (response: { id: string; approved: boolean }) => {
|
|
298
419
|
await client.addToolApprovalResponse(response)
|
|
420
|
+
syncResumeState(client)
|
|
299
421
|
},
|
|
422
|
+
[client, syncResumeState],
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
const resumeInterrupts = useCallback(
|
|
426
|
+
async (resumeItems: Array<RunAgentResumeItem>, state?: ChatResumeState) => {
|
|
427
|
+
const result = await client.resumeInterrupts(resumeItems, state)
|
|
428
|
+
syncResumeState(client)
|
|
429
|
+
return result
|
|
430
|
+
},
|
|
431
|
+
[client, syncResumeState],
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
const resolveInterrupts = useCallback(
|
|
435
|
+
(
|
|
436
|
+
resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => undefined),
|
|
437
|
+
) => {
|
|
438
|
+
if (typeof resolution === 'boolean') {
|
|
439
|
+
client.resolveInterrupts(resolution)
|
|
440
|
+
} else {
|
|
441
|
+
client.resolveInterrupts(resolution)
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
[client],
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
const cancelInterrupts = useCallback(() => {
|
|
448
|
+
client.cancelInterrupts()
|
|
449
|
+
}, [client])
|
|
450
|
+
|
|
451
|
+
const retryInterrupts = useCallback(() => {
|
|
452
|
+
client.retryInterrupts()
|
|
453
|
+
}, [client])
|
|
454
|
+
|
|
455
|
+
const resumeInterruptsUnsafe = useCallback(
|
|
456
|
+
(resumeItems: Array<RunAgentResumeItem>, state?: ChatResumeState) =>
|
|
457
|
+
client.resumeInterruptsUnsafe(resumeItems, state),
|
|
300
458
|
[client],
|
|
301
459
|
)
|
|
302
460
|
|
|
@@ -320,5 +478,15 @@ export function useChat<
|
|
|
320
478
|
addToolApprovalResponse,
|
|
321
479
|
queue,
|
|
322
480
|
cancelQueued,
|
|
481
|
+
runId,
|
|
482
|
+
interrupts: interruptState.interrupts,
|
|
483
|
+
pendingInterrupts: interruptState.pendingInterrupts,
|
|
484
|
+
interruptErrors: interruptState.interruptErrors,
|
|
485
|
+
resuming: interruptState.resuming,
|
|
486
|
+
resolveInterrupts,
|
|
487
|
+
cancelInterrupts,
|
|
488
|
+
retryInterrupts,
|
|
489
|
+
resumeInterruptsUnsafe,
|
|
490
|
+
resumeInterrupts,
|
|
323
491
|
}
|
|
324
492
|
}
|
package/dist/esm/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;"}
|