@tanstack/ai-client 0.18.6 → 0.19.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.
@@ -0,0 +1,76 @@
1
+ import { AudioPart } from '@tanstack/ai/client';
2
+ /** Lifecycle state of an {@link AudioRecorder}. */
3
+ export type AudioRecorderState = 'idle' | 'recording' | 'stopping';
4
+ export interface AudioRecorderOptions {
5
+ /** Constraints forwarded to `getUserMedia({ audio })`. Defaults to `true`. */
6
+ audio?: MediaTrackConstraints | boolean;
7
+ /**
8
+ * Preferred recorder mime type. Used only when
9
+ * `MediaRecorder.isTypeSupported` reports it; otherwise the browser default
10
+ * is used.
11
+ */
12
+ mimeType?: string;
13
+ /** Fired on `getUserMedia` rejection (permission denied) or recorder error. */
14
+ onError?: (error: Error) => void;
15
+ }
16
+ /**
17
+ * Resolves the value `stop()` produces from a recorder transform callback.
18
+ *
19
+ * - If the callback returns a value — sync or async — that value's awaited type
20
+ * is used (a returned `null` is a real value and is preserved).
21
+ * - If the callback returns nothing (`void`/`undefined`), or is absent, falls
22
+ * back to {@link AudioRecording}.
23
+ *
24
+ * @template TFn - The transform callback type (or undefined if not provided)
25
+ */
26
+ export type InferAudioRecordingOutput<TFn> = TFn extends (recording: AudioRecording) => infer R ? [Exclude<Awaited<R>, void | undefined>] extends [never] ? AudioRecording : Exclude<Awaited<R>, void | undefined> : AudioRecording;
27
+ export interface AudioRecording {
28
+ /** The raw recorded media blob. */
29
+ blob: Blob;
30
+ /** Base64 of the recorded bytes (no `data:` prefix). */
31
+ base64: string;
32
+ /** The recorder's native mime type, e.g. `audio/webm;codecs=opus`. */
33
+ mimeType: string;
34
+ /** Recording length in milliseconds. */
35
+ durationMs: number;
36
+ /**
37
+ * Ready-to-use audio content part for `sendMessage`/generation prompts:
38
+ * `{ type: 'audio', source: { type: 'data', value: base64, mimeType } }`.
39
+ */
40
+ part: AudioPart;
41
+ }
42
+ /**
43
+ * Framework-agnostic browser audio recorder. Wraps `getUserMedia` +
44
+ * `MediaRecorder`, returns the recorder's native output (no transcode), and
45
+ * builds a plug-and-play {@link AudioRecording.part}.
46
+ */
47
+ export declare class AudioRecorder {
48
+ private readonly options;
49
+ private recorder;
50
+ private stream;
51
+ private chunks;
52
+ private startedAt;
53
+ private _state;
54
+ private starting;
55
+ private pendingCancel;
56
+ private readonly listeners;
57
+ private stopResolve;
58
+ private stopReject;
59
+ constructor(options?: AudioRecorderOptions);
60
+ /** Feature-detect the browser media APIs. SSR/Worker-safe. */
61
+ static isSupported(): boolean;
62
+ get state(): AudioRecorderState;
63
+ subscribe(cb: (state: AudioRecorderState) => void): () => void;
64
+ private setState;
65
+ start(): Promise<void>;
66
+ stop(): Promise<AudioRecording>;
67
+ cancel(): void;
68
+ private finalize;
69
+ private handleError;
70
+ /** Invoke the user onError callback, isolating a throw so it can't disrupt
71
+ * internal state teardown or strand a pending promise. */
72
+ private notifyError;
73
+ private releaseStream;
74
+ /** Detach all event handlers so a stale recorder can't mutate our state. */
75
+ private detachRecorder;
76
+ }
@@ -0,0 +1,215 @@
1
+ import { arrayBufferToBase64 } from "@tanstack/ai-utils";
2
+ class AudioRecorder {
3
+ options;
4
+ recorder = null;
5
+ stream = null;
6
+ chunks = [];
7
+ startedAt = 0;
8
+ _state = "idle";
9
+ // True while start() is awaiting getUserMedia (state is still 'idle' then).
10
+ starting = false;
11
+ // Set by cancel()/teardown during that window so start() releases the
12
+ // freshly acquired stream instead of beginning a leaked recording.
13
+ pendingCancel = false;
14
+ listeners = /* @__PURE__ */ new Set();
15
+ stopResolve = null;
16
+ stopReject = null;
17
+ constructor(options = {}) {
18
+ this.options = options;
19
+ }
20
+ /** Feature-detect the browser media APIs. SSR/Worker-safe. */
21
+ static isSupported() {
22
+ return typeof navigator !== "undefined" && typeof navigator.mediaDevices !== "undefined" && typeof navigator.mediaDevices.getUserMedia === "function" && typeof MediaRecorder !== "undefined";
23
+ }
24
+ get state() {
25
+ return this._state;
26
+ }
27
+ subscribe(cb) {
28
+ this.listeners.add(cb);
29
+ return () => {
30
+ this.listeners.delete(cb);
31
+ };
32
+ }
33
+ setState(state) {
34
+ this._state = state;
35
+ for (const cb of this.listeners) {
36
+ cb(state);
37
+ }
38
+ }
39
+ async start() {
40
+ if (this._state !== "idle" || this.starting) {
41
+ return;
42
+ }
43
+ this.starting = true;
44
+ try {
45
+ const stream = await navigator.mediaDevices.getUserMedia({
46
+ audio: this.options.audio ?? true
47
+ });
48
+ if (this.pendingCancel) {
49
+ stream.getTracks().forEach((t) => t.stop());
50
+ return;
51
+ }
52
+ this.stream = stream;
53
+ const wanted = this.options.mimeType;
54
+ const useMimeType = wanted && typeof MediaRecorder.isTypeSupported === "function" && MediaRecorder.isTypeSupported(wanted) ? wanted : void 0;
55
+ const recorder = useMimeType ? new MediaRecorder(stream, { mimeType: useMimeType }) : new MediaRecorder(stream);
56
+ this.chunks = [];
57
+ recorder.ondataavailable = (e) => {
58
+ if (e.data.size > 0) {
59
+ this.chunks.push(e.data);
60
+ }
61
+ };
62
+ recorder.onstop = () => {
63
+ void this.finalize();
64
+ };
65
+ recorder.onerror = (event) => {
66
+ const detail = "error" in event && event.error instanceof Error ? event.error : new Error("Audio recording failed");
67
+ this.handleError(detail);
68
+ };
69
+ this.recorder = recorder;
70
+ this.startedAt = Date.now();
71
+ recorder.start();
72
+ this.setState("recording");
73
+ } catch (err) {
74
+ this.releaseStream();
75
+ this.recorder = null;
76
+ const error = err instanceof Error ? err : new Error("Failed to start recording");
77
+ this.setState("idle");
78
+ this.notifyError(error);
79
+ throw error;
80
+ } finally {
81
+ this.starting = false;
82
+ this.pendingCancel = false;
83
+ }
84
+ }
85
+ stop() {
86
+ if (this._state !== "recording" || !this.recorder) {
87
+ return Promise.reject(
88
+ new Error("AudioRecorder.stop() called while not recording")
89
+ );
90
+ }
91
+ this.setState("stopping");
92
+ const recorder = this.recorder;
93
+ return new Promise((resolve, reject) => {
94
+ const watchdog = setTimeout(() => {
95
+ if (this._state !== "stopping") {
96
+ return;
97
+ }
98
+ this.detachRecorder();
99
+ if (this.chunks.length > 0) {
100
+ void this.finalize();
101
+ } else {
102
+ this.handleError(
103
+ new Error("Recording stop timed out after 10s with no audio")
104
+ );
105
+ }
106
+ }, 1e4);
107
+ this.stopResolve = (rec) => {
108
+ clearTimeout(watchdog);
109
+ resolve(rec);
110
+ };
111
+ this.stopReject = (err) => {
112
+ clearTimeout(watchdog);
113
+ reject(err);
114
+ };
115
+ recorder.stop();
116
+ });
117
+ }
118
+ cancel() {
119
+ if (this.starting) {
120
+ this.pendingCancel = true;
121
+ return;
122
+ }
123
+ if (this._state === "idle") {
124
+ return;
125
+ }
126
+ const recorder = this.recorder;
127
+ if (recorder) {
128
+ this.detachRecorder();
129
+ try {
130
+ recorder.stop();
131
+ } catch (err) {
132
+ if (!(err instanceof DOMException && err.name === "InvalidStateError")) {
133
+ this.notifyError(
134
+ err instanceof Error ? err : new Error("Failed to stop recorder")
135
+ );
136
+ }
137
+ }
138
+ }
139
+ this.releaseStream();
140
+ this.recorder = null;
141
+ this.chunks = [];
142
+ const reject = this.stopReject;
143
+ this.stopResolve = null;
144
+ this.stopReject = null;
145
+ this.setState("idle");
146
+ reject?.(new Error("Recording cancelled"));
147
+ }
148
+ async finalize() {
149
+ const mimeType = this.recorder?.mimeType || "audio/webm";
150
+ const durationMs = Date.now() - this.startedAt;
151
+ try {
152
+ const blob = new Blob(this.chunks, { type: mimeType });
153
+ const base64 = arrayBufferToBase64(await blob.arrayBuffer());
154
+ const recording = {
155
+ blob,
156
+ base64,
157
+ mimeType,
158
+ durationMs,
159
+ part: {
160
+ type: "audio",
161
+ source: { type: "data", value: base64, mimeType }
162
+ }
163
+ };
164
+ this.releaseStream();
165
+ this.recorder = null;
166
+ this.chunks = [];
167
+ const resolve = this.stopResolve;
168
+ this.stopResolve = null;
169
+ this.stopReject = null;
170
+ this.setState("idle");
171
+ resolve?.(recording);
172
+ } catch (err) {
173
+ this.handleError(
174
+ err instanceof Error ? err : new Error("Failed to finalize recording")
175
+ );
176
+ }
177
+ }
178
+ handleError(error) {
179
+ this.releaseStream();
180
+ this.recorder = null;
181
+ this.chunks = [];
182
+ const reject = this.stopReject;
183
+ this.stopResolve = null;
184
+ this.stopReject = null;
185
+ this.setState("idle");
186
+ reject?.(error);
187
+ this.notifyError(error);
188
+ }
189
+ /** Invoke the user onError callback, isolating a throw so it can't disrupt
190
+ * internal state teardown or strand a pending promise. */
191
+ notifyError(error) {
192
+ try {
193
+ this.options.onError?.(error);
194
+ } catch {
195
+ }
196
+ }
197
+ releaseStream() {
198
+ this.stream?.getTracks().forEach((t) => t.stop());
199
+ this.stream = null;
200
+ }
201
+ /** Detach all event handlers so a stale recorder can't mutate our state. */
202
+ detachRecorder() {
203
+ const recorder = this.recorder;
204
+ if (!recorder) {
205
+ return;
206
+ }
207
+ recorder.onstop = null;
208
+ recorder.onerror = null;
209
+ recorder.ondataavailable = null;
210
+ }
211
+ }
212
+ export {
213
+ AudioRecorder
214
+ };
215
+ //# sourceMappingURL=audio-recorder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio-recorder.js","sources":["../../src/audio-recorder.ts"],"sourcesContent":["import { arrayBufferToBase64 } from '@tanstack/ai-utils'\nimport type { AudioPart } from '@tanstack/ai/client'\n\n/** Lifecycle state of an {@link AudioRecorder}. */\nexport type AudioRecorderState = 'idle' | 'recording' | 'stopping'\n\nexport interface AudioRecorderOptions {\n /** Constraints forwarded to `getUserMedia({ audio })`. Defaults to `true`. */\n audio?: MediaTrackConstraints | boolean\n /**\n * Preferred recorder mime type. Used only when\n * `MediaRecorder.isTypeSupported` reports it; otherwise the browser default\n * is used.\n */\n mimeType?: string\n /** Fired on `getUserMedia` rejection (permission denied) or recorder error. */\n onError?: (error: Error) => void\n}\n\n/**\n * Resolves the value `stop()` produces from a recorder transform callback.\n *\n * - If the callback returns a value — sync or async — that value's awaited type\n * is used (a returned `null` is a real value and is preserved).\n * - If the callback returns nothing (`void`/`undefined`), or is absent, falls\n * back to {@link AudioRecording}.\n *\n * @template TFn - The transform callback type (or undefined if not provided)\n */\nexport type InferAudioRecordingOutput<TFn> = TFn extends (\n recording: AudioRecording,\n) => infer R\n ? [Exclude<Awaited<R>, void | undefined>] extends [never]\n ? AudioRecording\n : Exclude<Awaited<R>, void | undefined>\n : AudioRecording\n\nexport interface AudioRecording {\n /** The raw recorded media blob. */\n blob: Blob\n /** Base64 of the recorded bytes (no `data:` prefix). */\n base64: string\n /** The recorder's native mime type, e.g. `audio/webm;codecs=opus`. */\n mimeType: string\n /** Recording length in milliseconds. */\n durationMs: number\n /**\n * Ready-to-use audio content part for `sendMessage`/generation prompts:\n * `{ type: 'audio', source: { type: 'data', value: base64, mimeType } }`.\n */\n part: AudioPart\n}\n\n/**\n * Framework-agnostic browser audio recorder. Wraps `getUserMedia` +\n * `MediaRecorder`, returns the recorder's native output (no transcode), and\n * builds a plug-and-play {@link AudioRecording.part}.\n */\nexport class AudioRecorder {\n private readonly options: AudioRecorderOptions\n private recorder: MediaRecorder | null = null\n private stream: MediaStream | null = null\n private chunks: Array<Blob> = []\n private startedAt = 0\n private _state: AudioRecorderState = 'idle'\n // True while start() is awaiting getUserMedia (state is still 'idle' then).\n private starting = false\n // Set by cancel()/teardown during that window so start() releases the\n // freshly acquired stream instead of beginning a leaked recording.\n private pendingCancel = false\n private readonly listeners = new Set<(state: AudioRecorderState) => void>()\n private stopResolve: ((recording: AudioRecording) => void) | null = null\n private stopReject: ((error: Error) => void) | null = null\n\n constructor(options: AudioRecorderOptions = {}) {\n this.options = options\n }\n\n /** Feature-detect the browser media APIs. SSR/Worker-safe. */\n static isSupported(): boolean {\n return (\n typeof navigator !== 'undefined' &&\n typeof navigator.mediaDevices !== 'undefined' &&\n typeof navigator.mediaDevices.getUserMedia === 'function' &&\n typeof MediaRecorder !== 'undefined'\n )\n }\n\n get state(): AudioRecorderState {\n return this._state\n }\n\n subscribe(cb: (state: AudioRecorderState) => void): () => void {\n this.listeners.add(cb)\n return () => {\n this.listeners.delete(cb)\n }\n }\n\n private setState(state: AudioRecorderState): void {\n this._state = state\n for (const cb of this.listeners) {\n cb(state)\n }\n }\n\n async start(): Promise<void> {\n if (this._state !== 'idle' || this.starting) {\n return\n }\n this.starting = true\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: this.options.audio ?? true,\n })\n // cancel()/teardown ran while we were awaiting the mic: release the\n // freshly acquired stream and bail rather than starting a recording the\n // caller can no longer stop (a leaked live microphone).\n if (this.pendingCancel) {\n stream.getTracks().forEach((t) => t.stop())\n return\n }\n this.stream = stream\n const wanted = this.options.mimeType\n const useMimeType =\n wanted &&\n typeof MediaRecorder.isTypeSupported === 'function' &&\n MediaRecorder.isTypeSupported(wanted)\n ? wanted\n : undefined\n const recorder = useMimeType\n ? new MediaRecorder(stream, { mimeType: useMimeType })\n : new MediaRecorder(stream)\n this.chunks = []\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) {\n this.chunks.push(e.data)\n }\n }\n recorder.onstop = () => {\n void this.finalize()\n }\n recorder.onerror = (event) => {\n const detail =\n 'error' in event && event.error instanceof Error\n ? event.error\n : new Error('Audio recording failed')\n this.handleError(detail)\n }\n this.recorder = recorder\n this.startedAt = Date.now()\n recorder.start()\n this.setState('recording')\n } catch (err) {\n this.releaseStream()\n this.recorder = null\n const error =\n err instanceof Error ? err : new Error('Failed to start recording')\n this.setState('idle')\n this.notifyError(error)\n throw error\n } finally {\n this.starting = false\n // Reset here (not before the await) so a cancel() that arrives mid-start\n // is observed above; clearing it afterward keeps the next start() clean.\n this.pendingCancel = false\n }\n }\n\n stop(): Promise<AudioRecording> {\n if (this._state !== 'recording' || !this.recorder) {\n return Promise.reject(\n new Error('AudioRecorder.stop() called while not recording'),\n )\n }\n this.setState('stopping')\n const recorder = this.recorder\n return new Promise<AudioRecording>((resolve, reject) => {\n // Some browsers/codecs never fire onstop; this watchdog unwedges the\n // recorder instead of leaking this promise forever.\n const watchdog = setTimeout(() => {\n if (this._state !== 'stopping') {\n return\n }\n // Detach handlers so a late onstop/onerror from the stalled recorder\n // can't reach back in and fire finalize()/onError a second time.\n this.detachRecorder()\n if (this.chunks.length > 0) {\n // onstop never fired, but ondataavailable already delivered the\n // audio — finalize from the buffered chunks rather than discarding a\n // recording the user successfully captured.\n void this.finalize()\n } else {\n this.handleError(\n new Error('Recording stop timed out after 10s with no audio'),\n )\n }\n }, 10_000)\n this.stopResolve = (rec) => {\n clearTimeout(watchdog)\n resolve(rec)\n }\n this.stopReject = (err) => {\n clearTimeout(watchdog)\n reject(err)\n }\n recorder.stop()\n })\n }\n\n cancel(): void {\n if (this.starting) {\n // A start() is awaiting getUserMedia; flag it so the resolved stream is\n // released instead of beginning a recording with no handle to stop it.\n this.pendingCancel = true\n return\n }\n if (this._state === 'idle') {\n return\n }\n const recorder = this.recorder\n if (recorder) {\n // Detach handlers so finalize()/onError never run for a discarded\n // recording.\n this.detachRecorder()\n try {\n recorder.stop()\n } catch (err) {\n // Stopping an already-inactive recorder throws InvalidStateError —\n // that's expected here. Anything else is unexpected; surface it rather\n // than swallowing it silently.\n if (\n !(err instanceof DOMException && err.name === 'InvalidStateError')\n ) {\n this.notifyError(\n err instanceof Error ? err : new Error('Failed to stop recorder'),\n )\n }\n }\n }\n this.releaseStream()\n this.recorder = null\n this.chunks = []\n const reject = this.stopReject\n this.stopResolve = null\n this.stopReject = null\n this.setState('idle')\n reject?.(new Error('Recording cancelled'))\n }\n\n private async finalize(): Promise<void> {\n const mimeType = this.recorder?.mimeType || 'audio/webm'\n const durationMs = Date.now() - this.startedAt\n try {\n const blob = new Blob(this.chunks, { type: mimeType })\n const base64 = arrayBufferToBase64(await blob.arrayBuffer())\n const recording: AudioRecording = {\n blob,\n base64,\n mimeType,\n durationMs,\n part: {\n type: 'audio',\n source: { type: 'data', value: base64, mimeType },\n },\n }\n this.releaseStream()\n this.recorder = null\n this.chunks = []\n const resolve = this.stopResolve\n this.stopResolve = null\n this.stopReject = null\n this.setState('idle')\n resolve?.(recording)\n } catch (err) {\n this.handleError(\n err instanceof Error ? err : new Error('Failed to finalize recording'),\n )\n }\n }\n\n private handleError(error: Error): void {\n this.releaseStream()\n this.recorder = null\n this.chunks = []\n const reject = this.stopReject\n this.stopResolve = null\n this.stopReject = null\n this.setState('idle')\n // Settle the pending stop() promise before invoking the user callback so a\n // throwing onError can't strand the awaiter (the two error channels are\n // independent — see start()/stop() docs).\n reject?.(error)\n this.notifyError(error)\n }\n\n /** Invoke the user onError callback, isolating a throw so it can't disrupt\n * internal state teardown or strand a pending promise. */\n private notifyError(error: Error): void {\n try {\n this.options.onError?.(error)\n } catch {\n // A user onError that throws must not propagate into recorder internals.\n }\n }\n\n private releaseStream(): void {\n this.stream?.getTracks().forEach((t) => t.stop())\n this.stream = null\n }\n\n /** Detach all event handlers so a stale recorder can't mutate our state. */\n private detachRecorder(): void {\n const recorder = this.recorder\n if (!recorder) {\n return\n }\n recorder.onstop = null\n recorder.onerror = null\n recorder.ondataavailable = null\n }\n}\n"],"names":[],"mappings":";AA0DO,MAAM,cAAc;AAAA,EACR;AAAA,EACT,WAAiC;AAAA,EACjC,SAA6B;AAAA,EAC7B,SAAsB,CAAA;AAAA,EACtB,YAAY;AAAA,EACZ,SAA6B;AAAA;AAAA,EAE7B,WAAW;AAAA;AAAA;AAAA,EAGX,gBAAgB;AAAA,EACP,gCAAgB,IAAA;AAAA,EACzB,cAA4D;AAAA,EAC5D,aAA8C;AAAA,EAEtD,YAAY,UAAgC,IAAI;AAC9C,SAAK,UAAU;AAAA,EACjB;AAAA;AAAA,EAGA,OAAO,cAAuB;AAC5B,WACE,OAAO,cAAc,eACrB,OAAO,UAAU,iBAAiB,eAClC,OAAO,UAAU,aAAa,iBAAiB,cAC/C,OAAO,kBAAkB;AAAA,EAE7B;AAAA,EAEA,IAAI,QAA4B;AAC9B,WAAO,KAAK;AAAA,EACd;AAAA,EAEA,UAAU,IAAqD;AAC7D,SAAK,UAAU,IAAI,EAAE;AACrB,WAAO,MAAM;AACX,WAAK,UAAU,OAAO,EAAE;AAAA,IAC1B;AAAA,EACF;AAAA,EAEQ,SAAS,OAAiC;AAChD,SAAK,SAAS;AACd,eAAW,MAAM,KAAK,WAAW;AAC/B,SAAG,KAAK;AAAA,IACV;AAAA,EACF;AAAA,EAEA,MAAM,QAAuB;AAC3B,QAAI,KAAK,WAAW,UAAU,KAAK,UAAU;AAC3C;AAAA,IACF;AACA,SAAK,WAAW;AAChB,QAAI;AACF,YAAM,SAAS,MAAM,UAAU,aAAa,aAAa;AAAA,QACvD,OAAO,KAAK,QAAQ,SAAS;AAAA,MAAA,CAC9B;AAID,UAAI,KAAK,eAAe;AACtB,eAAO,YAAY,QAAQ,CAAC,MAAM,EAAE,MAAM;AAC1C;AAAA,MACF;AACA,WAAK,SAAS;AACd,YAAM,SAAS,KAAK,QAAQ;AAC5B,YAAM,cACJ,UACA,OAAO,cAAc,oBAAoB,cACzC,cAAc,gBAAgB,MAAM,IAChC,SACA;AACN,YAAM,WAAW,cACb,IAAI,cAAc,QAAQ,EAAE,UAAU,YAAA,CAAa,IACnD,IAAI,cAAc,MAAM;AAC5B,WAAK,SAAS,CAAA;AACd,eAAS,kBAAkB,CAAC,MAAM;AAChC,YAAI,EAAE,KAAK,OAAO,GAAG;AACnB,eAAK,OAAO,KAAK,EAAE,IAAI;AAAA,QACzB;AAAA,MACF;AACA,eAAS,SAAS,MAAM;AACtB,aAAK,KAAK,SAAA;AAAA,MACZ;AACA,eAAS,UAAU,CAAC,UAAU;AAC5B,cAAM,SACJ,WAAW,SAAS,MAAM,iBAAiB,QACvC,MAAM,QACN,IAAI,MAAM,wBAAwB;AACxC,aAAK,YAAY,MAAM;AAAA,MACzB;AACA,WAAK,WAAW;AAChB,WAAK,YAAY,KAAK,IAAA;AACtB,eAAS,MAAA;AACT,WAAK,SAAS,WAAW;AAAA,IAC3B,SAAS,KAAK;AACZ,WAAK,cAAA;AACL,WAAK,WAAW;AAChB,YAAM,QACJ,eAAe,QAAQ,MAAM,IAAI,MAAM,2BAA2B;AACpE,WAAK,SAAS,MAAM;AACpB,WAAK,YAAY,KAAK;AACtB,YAAM;AAAA,IACR,UAAA;AACE,WAAK,WAAW;AAGhB,WAAK,gBAAgB;AAAA,IACvB;AAAA,EACF;AAAA,EAEA,OAAgC;AAC9B,QAAI,KAAK,WAAW,eAAe,CAAC,KAAK,UAAU;AACjD,aAAO,QAAQ;AAAA,QACb,IAAI,MAAM,iDAAiD;AAAA,MAAA;AAAA,IAE/D;AACA,SAAK,SAAS,UAAU;AACxB,UAAM,WAAW,KAAK;AACtB,WAAO,IAAI,QAAwB,CAAC,SAAS,WAAW;AAGtD,YAAM,WAAW,WAAW,MAAM;AAChC,YAAI,KAAK,WAAW,YAAY;AAC9B;AAAA,QACF;AAGA,aAAK,eAAA;AACL,YAAI,KAAK,OAAO,SAAS,GAAG;AAI1B,eAAK,KAAK,SAAA;AAAA,QACZ,OAAO;AACL,eAAK;AAAA,YACH,IAAI,MAAM,kDAAkD;AAAA,UAAA;AAAA,QAEhE;AAAA,MACF,GAAG,GAAM;AACT,WAAK,cAAc,CAAC,QAAQ;AAC1B,qBAAa,QAAQ;AACrB,gBAAQ,GAAG;AAAA,MACb;AACA,WAAK,aAAa,CAAC,QAAQ;AACzB,qBAAa,QAAQ;AACrB,eAAO,GAAG;AAAA,MACZ;AACA,eAAS,KAAA;AAAA,IACX,CAAC;AAAA,EACH;AAAA,EAEA,SAAe;AACb,QAAI,KAAK,UAAU;AAGjB,WAAK,gBAAgB;AACrB;AAAA,IACF;AACA,QAAI,KAAK,WAAW,QAAQ;AAC1B;AAAA,IACF;AACA,UAAM,WAAW,KAAK;AACtB,QAAI,UAAU;AAGZ,WAAK,eAAA;AACL,UAAI;AACF,iBAAS,KAAA;AAAA,MACX,SAAS,KAAK;AAIZ,YACE,EAAE,eAAe,gBAAgB,IAAI,SAAS,sBAC9C;AACA,eAAK;AAAA,YACH,eAAe,QAAQ,MAAM,IAAI,MAAM,yBAAyB;AAAA,UAAA;AAAA,QAEpE;AAAA,MACF;AAAA,IACF;AACA,SAAK,cAAA;AACL,SAAK,WAAW;AAChB,SAAK,SAAS,CAAA;AACd,UAAM,SAAS,KAAK;AACpB,SAAK,cAAc;AACnB,SAAK,aAAa;AAClB,SAAK,SAAS,MAAM;AACpB,aAAS,IAAI,MAAM,qBAAqB,CAAC;AAAA,EAC3C;AAAA,EAEA,MAAc,WAA0B;AACtC,UAAM,WAAW,KAAK,UAAU,YAAY;AAC5C,UAAM,aAAa,KAAK,IAAA,IAAQ,KAAK;AACrC,QAAI;AACF,YAAM,OAAO,IAAI,KAAK,KAAK,QAAQ,EAAE,MAAM,UAAU;AACrD,YAAM,SAAS,oBAAoB,MAAM,KAAK,aAAa;AAC3D,YAAM,YAA4B;AAAA,QAChC;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,MAAM;AAAA,UACJ,MAAM;AAAA,UACN,QAAQ,EAAE,MAAM,QAAQ,OAAO,QAAQ,SAAA;AAAA,QAAS;AAAA,MAClD;AAEF,WAAK,cAAA;AACL,WAAK,WAAW;AAChB,WAAK,SAAS,CAAA;AACd,YAAM,UAAU,KAAK;AACrB,WAAK,cAAc;AACnB,WAAK,aAAa;AAClB,WAAK,SAAS,MAAM;AACpB,gBAAU,SAAS;AAAA,IACrB,SAAS,KAAK;AACZ,WAAK;AAAA,QACH,eAAe,QAAQ,MAAM,IAAI,MAAM,8BAA8B;AAAA,MAAA;AAAA,IAEzE;AAAA,EACF;AAAA,EAEQ,YAAY,OAAoB;AACtC,SAAK,cAAA;AACL,SAAK,WAAW;AAChB,SAAK,SAAS,CAAA;AACd,UAAM,SAAS,KAAK;AACpB,SAAK,cAAc;AACnB,SAAK,aAAa;AAClB,SAAK,SAAS,MAAM;AAIpB,aAAS,KAAK;AACd,SAAK,YAAY,KAAK;AAAA,EACxB;AAAA;AAAA;AAAA,EAIQ,YAAY,OAAoB;AACtC,QAAI;AACF,WAAK,QAAQ,UAAU,KAAK;AAAA,IAC9B,QAAQ;AAAA,IAER;AAAA,EACF;AAAA,EAEQ,gBAAsB;AAC5B,SAAK,QAAQ,YAAY,QAAQ,CAAC,MAAM,EAAE,MAAM;AAChD,SAAK,SAAS;AAAA,EAChB;AAAA;AAAA,EAGQ,iBAAuB;AAC7B,UAAM,WAAW,KAAK;AACtB,QAAI,CAAC,UAAU;AACb;AAAA,IACF;AACA,aAAS,SAAS;AAClB,aAAS,UAAU;AACnB,aAAS,kBAAkB;AAAA,EAC7B;AACF;"}
@@ -3,7 +3,26 @@ import { ConnectConnectionAdapter } from './connection-adapters.js';
3
3
  import { AIDevtoolsClientMetadata } from './devtools.js';
4
4
  import { GenerationDevtoolsBridgeFactory, VideoDevtoolsBridgeFactory } from './devtools-noop.js';
5
5
  /**
6
- * Infers the output type from an `onResult` callback's return type.
6
+ * Maps an `onResult` transform's raw return type to the stored output type.
7
+ *
8
+ * - A concrete return (excluding null/void/undefined) becomes the output type.
9
+ * - A return of only null/void/undefined falls back to TResult (the transform
10
+ * reacted to the result or chose to keep it, rather than replacing it).
11
+ *
12
+ * Hooks infer `TReturn` directly from the `onResult` return position — a
13
+ * covariant inference site that works even for an optional nested property —
14
+ * which both contextually types the callback parameter as `TResult` and
15
+ * narrows `result`. See issue #848.
16
+ *
17
+ * @template TResult - The raw result type from the generation
18
+ * @template TReturn - The transform's return type (defaults to `void` when no
19
+ * transform is provided)
20
+ */
21
+ export type InferGenerationOutputFromReturn<TResult, TReturn> = [
22
+ Exclude<TReturn, null | void | undefined>
23
+ ] extends [never] ? TResult : Exclude<TReturn, null | void | undefined>;
24
+ /**
25
+ * Infers the output type from an `onResult` callback's type.
7
26
  *
8
27
  * - If the callback returns a concrete type (excluding null/void/undefined), uses that type.
9
28
  * - If the callback only returns null/void/undefined, or is not provided, falls back to TResult.
@@ -11,7 +30,7 @@ import { GenerationDevtoolsBridgeFactory, VideoDevtoolsBridgeFactory } from './d
11
30
  * @template TResult - The raw result type from the generation
12
31
  * @template TFn - The onResult callback type (or undefined if not provided)
13
32
  */
14
- export type InferGenerationOutput<TResult, TFn> = TFn extends (result: any) => infer R ? [Exclude<R, null | void | undefined>] extends [never] ? TResult : Exclude<R, null | void | undefined> : TResult;
33
+ export type InferGenerationOutput<TResult, TFn> = TFn extends (result: any) => infer R ? InferGenerationOutputFromReturn<TResult, R> : TResult;
15
34
  /**
16
35
  * State machine for generation clients.
17
36
  * Simpler than ChatClientState since generation is a single request/response cycle.
@@ -1 +1 @@
1
- {"version":3,"file":"generation-types.js","sources":["../../src/generation-types.ts"],"sourcesContent":["import type { MediaPrompt, StreamChunk } from '@tanstack/ai/client'\nimport type { ConnectConnectionAdapter } from './connection-adapters'\nimport type { AIDevtoolsClientMetadata } from './devtools'\nimport type {\n GenerationDevtoolsBridgeFactory,\n VideoDevtoolsBridgeFactory,\n} from './devtools-noop'\n\n// ===========================\n// Inference Utilities\n// ===========================\n\n/**\n * Infers the output type from an `onResult` callback's return type.\n *\n * - If the callback returns a concrete type (excluding null/void/undefined), uses that type.\n * - If the callback only returns null/void/undefined, or is not provided, falls back to TResult.\n *\n * @template TResult - The raw result type from the generation\n * @template TFn - The onResult callback type (or undefined if not provided)\n */\nexport type InferGenerationOutput<TResult, TFn> = TFn extends (\n result: any,\n) => infer R\n ? [Exclude<R, null | void | undefined>] extends [never]\n ? TResult\n : Exclude<R, null | void | undefined>\n : TResult\n\n// ===========================\n// State\n// ===========================\n\n/**\n * State machine for generation clients.\n * Simpler than ChatClientState since generation is a single request/response cycle.\n */\nexport type GenerationClientState = 'idle' | 'generating' | 'success' | 'error'\n\n// ===========================\n// Event Constants\n// ===========================\n\n/**\n * Well-known CUSTOM event names used by generation clients.\n * These events are emitted by the server-side streaming helpers\n * and consumed by the client-side GenerationClient.\n */\nexport const GENERATION_EVENTS = {\n /** The generation result payload */\n RESULT: 'generation:result',\n /** Progress update (0-100) with optional message */\n PROGRESS: 'generation:progress',\n /** Video job created with jobId */\n VIDEO_JOB_CREATED: 'video:job:created',\n /** Video job status update */\n VIDEO_STATUS: 'video:status',\n} as const\n\n// ===========================\n// Transport Types\n// ===========================\n\n/**\n * Options passed to a fetcher function by the generation client.\n */\nexport interface GenerationFetcherOptions {\n /** AbortSignal that is triggered when the user calls `stop()` */\n signal: AbortSignal\n}\n\n/**\n * A direct async function that performs a generation request.\n *\n * Can return the result directly, or return a `Response` with an SSE body\n * (e.g., from a TanStack Start server function using `toServerSentEventsResponse()`).\n * When a `Response` is returned, the client will parse it as an SSE stream.\n *\n * @template TInput - The input type for the generation request\n * @template TResult - The result type returned by the generation\n */\nexport type GenerationFetcher<TInput, TResult> = (\n input: TInput,\n options?: GenerationFetcherOptions,\n) => Promise<TResult | Response>\n\n/**\n * Transport configuration for generation clients.\n * Supports either a connect-based streaming adapter or a direct fetcher function.\n */\nexport type GenerationTransport<TInput, TResult> =\n | { connection: ConnectConnectionAdapter; fetcher?: never }\n | { fetcher: GenerationFetcher<TInput, TResult>; connection?: never }\n\n// ===========================\n// Client Options\n// ===========================\n\n/**\n * Options for the GenerationClient.\n *\n * @template TInput - The input type for the generation request (used by consuming code)\n * @template TResult - The result type returned by the generation\n * @template TOutput - The output type after optional transform (defaults to TResult)\n */\n// eslint-disable-next-line @typescript-eslint/naming-convention -- _TInput is unused in the interface body but part of the public positional generic API (callers supply it for inference)\nexport interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {\n /** Unique identifier for this generation client instance */\n id?: string\n\n /** Additional body parameters to send with connect-based adapter requests */\n body?: Record<string, any>\n\n /** Metadata used to register this generation hook with TanStack AI Devtools */\n devtools?: Partial<AIDevtoolsClientMetadata>\n\n /**\n * Factory that constructs the devtools bridge. Default is a no-op\n * factory; the real implementation lives in `@tanstack/ai-client/devtools`.\n */\n devtoolsBridgeFactory?: GenerationDevtoolsBridgeFactory\n\n /**\n * Callback when a result is received. Can optionally return a transformed value\n * that replaces the stored result.\n *\n * - Return a non-null value to transform and store it as the result\n * - Return `null` to keep the previous result unchanged\n * - Return nothing (`void`) to store the raw result as-is\n */\n onResult?: (result: TResult) => TOutput | null | void\n /** Callback when an error occurs */\n onError?: (error: Error) => void\n /** Callback when progress is reported (0-100) */\n onProgress?: (progress: number, message?: string) => void\n /** Callback for each stream chunk (connect-based adapter mode only) */\n onChunk?: (chunk: StreamChunk) => void\n\n // Framework state callbacks (set by hooks, not users)\n /** @internal Called when result changes */\n onResultChange?: (result: TOutput | null) => void\n /** @internal Called when loading state changes */\n onLoadingChange?: (isLoading: boolean) => void\n /** @internal Called when error state changes */\n onErrorChange?: (error: Error | undefined) => void\n /** @internal Called when generation status changes */\n onStatusChange?: (status: GenerationClientState) => void\n}\n\n// ===========================\n// Video-Specific Options\n// ===========================\n\n/**\n * Video status information returned during job polling.\n */\nexport interface VideoStatusInfo {\n /** Job identifier */\n jobId: string\n /** Current status of the video generation job */\n status: 'pending' | 'processing' | 'completed' | 'failed'\n /** Progress percentage (0-100), if available */\n progress?: number\n /** URL to the generated video (when completed) */\n url?: string\n /** Error message if status is 'failed' */\n error?: string\n}\n\n/**\n * Composite result for video generation (job completion).\n */\nexport interface VideoGenerateResult {\n /** Job identifier */\n jobId: string\n /** Final status */\n status: 'completed'\n /** URL to the generated video */\n url: string\n /** When the URL expires, if applicable */\n expiresAt?: Date\n}\n\n/**\n * Options for the VideoGenerationClient.\n */\nexport interface VideoGenerationClientOptions<\n TOutput = VideoGenerateResult,\n> extends Omit<\n GenerationClientOptions<VideoGenerateInput, VideoGenerateResult, TOutput>,\n 'devtoolsBridgeFactory'\n> {\n /**\n * Factory that constructs the video devtools bridge. Default is a no-op\n * factory; the real implementation lives in `@tanstack/ai-client/devtools`.\n */\n devtoolsBridgeFactory?: VideoDevtoolsBridgeFactory\n\n /** Callback when a video job is created */\n onJobCreated?: (jobId: string) => void\n /** Callback on each status update */\n onStatusUpdate?: (status: VideoStatusInfo) => void\n\n // Framework state callbacks\n /** @internal Called when jobId changes */\n onJobIdChange?: (jobId: string | null) => void\n /** @internal Called when video status changes */\n onVideoStatusChange?: (status: VideoStatusInfo | null) => void\n}\n\n// ===========================\n// Input Types\n// ===========================\n\n/**\n * Input for image generation.\n */\nexport interface ImageGenerateInput {\n /**\n * Description of the desired image(s): plain text, or an ordered array of\n * content parts (text + image) for image-conditioned generation\n * (image-to-image, multi-reference, edit / inpaint).\n */\n prompt: MediaPrompt\n /** Number of images to generate (default: 1) */\n numberOfImages?: number\n /** Image size in WIDTHxHEIGHT format (e.g., \"1024x1024\") */\n size?: string\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n\n/**\n * Input for audio generation (music, sound effects).\n */\nexport interface AudioGenerateInput {\n /** Text description of the desired audio */\n prompt: string\n /** Desired duration in seconds */\n duration?: number\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n\n/**\n * Input for text-to-speech generation.\n */\nexport interface SpeechGenerateInput {\n /** The text to convert to speech */\n text: string\n /** The voice to use for generation */\n voice?: string\n /** The output audio format */\n format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'\n /** The speed of the generated audio (0.25 to 4.0) */\n speed?: number\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n\n/**\n * Input for audio transcription.\n */\nexport interface TranscriptionGenerateInput {\n /** The audio data to transcribe - can be base64 string, File, Blob, or ArrayBuffer */\n audio: string | File | Blob | ArrayBuffer\n /** The language of the audio in ISO-639-1 format (e.g., 'en') */\n language?: string\n /** An optional prompt to guide the transcription */\n prompt?: string\n /** The format of the transcription output */\n responseFormat?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n\n/**\n * Input for text summarization.\n */\nexport interface SummarizeGenerateInput {\n /** The text to summarize */\n text: string\n /** Maximum length of the summary */\n maxLength?: number\n /** Style of the summary */\n style?: 'bullet-points' | 'paragraph' | 'concise'\n /** Topics to focus on */\n focus?: Array<string>\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n\n/**\n * Input for video generation.\n */\nexport interface VideoGenerateInput {\n /**\n * Description of the desired video: plain text, or an ordered array of\n * content parts (text + image) for image-conditioned generation\n * (image-to-video, start/end frames).\n */\n prompt: MediaPrompt\n /** Video size — format depends on provider (e.g., \"16:9\", \"1280x720\") */\n size?: string\n /** Video duration in seconds */\n duration?: number\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n"],"names":[],"mappings":"AAgDO,MAAM,oBAAoB;AAAA;AAAA,EAE/B,QAAQ;AAAA;AAAA,EAER,UAAU;AAAA;AAAA,EAEV,mBAAmB;AAAA;AAAA,EAEnB,cAAc;AAChB;"}
1
+ {"version":3,"file":"generation-types.js","sources":["../../src/generation-types.ts"],"sourcesContent":["import type { MediaPrompt, StreamChunk } from '@tanstack/ai/client'\nimport type { ConnectConnectionAdapter } from './connection-adapters'\nimport type { AIDevtoolsClientMetadata } from './devtools'\nimport type {\n GenerationDevtoolsBridgeFactory,\n VideoDevtoolsBridgeFactory,\n} from './devtools-noop'\n\n// ===========================\n// Inference Utilities\n// ===========================\n\n/**\n * Maps an `onResult` transform's raw return type to the stored output type.\n *\n * - A concrete return (excluding null/void/undefined) becomes the output type.\n * - A return of only null/void/undefined falls back to TResult (the transform\n * reacted to the result or chose to keep it, rather than replacing it).\n *\n * Hooks infer `TReturn` directly from the `onResult` return position — a\n * covariant inference site that works even for an optional nested property —\n * which both contextually types the callback parameter as `TResult` and\n * narrows `result`. See issue #848.\n *\n * @template TResult - The raw result type from the generation\n * @template TReturn - The transform's return type (defaults to `void` when no\n * transform is provided)\n */\nexport type InferGenerationOutputFromReturn<TResult, TReturn> = [\n Exclude<TReturn, null | void | undefined>,\n] extends [never]\n ? TResult\n : Exclude<TReturn, null | void | undefined>\n\n/**\n * Infers the output type from an `onResult` callback's type.\n *\n * - If the callback returns a concrete type (excluding null/void/undefined), uses that type.\n * - If the callback only returns null/void/undefined, or is not provided, falls back to TResult.\n *\n * @template TResult - The raw result type from the generation\n * @template TFn - The onResult callback type (or undefined if not provided)\n */\nexport type InferGenerationOutput<TResult, TFn> = TFn extends (\n result: any,\n) => infer R\n ? InferGenerationOutputFromReturn<TResult, R>\n : TResult\n\n// ===========================\n// State\n// ===========================\n\n/**\n * State machine for generation clients.\n * Simpler than ChatClientState since generation is a single request/response cycle.\n */\nexport type GenerationClientState = 'idle' | 'generating' | 'success' | 'error'\n\n// ===========================\n// Event Constants\n// ===========================\n\n/**\n * Well-known CUSTOM event names used by generation clients.\n * These events are emitted by the server-side streaming helpers\n * and consumed by the client-side GenerationClient.\n */\nexport const GENERATION_EVENTS = {\n /** The generation result payload */\n RESULT: 'generation:result',\n /** Progress update (0-100) with optional message */\n PROGRESS: 'generation:progress',\n /** Video job created with jobId */\n VIDEO_JOB_CREATED: 'video:job:created',\n /** Video job status update */\n VIDEO_STATUS: 'video:status',\n} as const\n\n// ===========================\n// Transport Types\n// ===========================\n\n/**\n * Options passed to a fetcher function by the generation client.\n */\nexport interface GenerationFetcherOptions {\n /** AbortSignal that is triggered when the user calls `stop()` */\n signal: AbortSignal\n}\n\n/**\n * A direct async function that performs a generation request.\n *\n * Can return the result directly, or return a `Response` with an SSE body\n * (e.g., from a TanStack Start server function using `toServerSentEventsResponse()`).\n * When a `Response` is returned, the client will parse it as an SSE stream.\n *\n * @template TInput - The input type for the generation request\n * @template TResult - The result type returned by the generation\n */\nexport type GenerationFetcher<TInput, TResult> = (\n input: TInput,\n options?: GenerationFetcherOptions,\n) => Promise<TResult | Response>\n\n/**\n * Transport configuration for generation clients.\n * Supports either a connect-based streaming adapter or a direct fetcher function.\n */\nexport type GenerationTransport<TInput, TResult> =\n | { connection: ConnectConnectionAdapter; fetcher?: never }\n | { fetcher: GenerationFetcher<TInput, TResult>; connection?: never }\n\n// ===========================\n// Client Options\n// ===========================\n\n/**\n * Options for the GenerationClient.\n *\n * @template TInput - The input type for the generation request (used by consuming code)\n * @template TResult - The result type returned by the generation\n * @template TOutput - The output type after optional transform (defaults to TResult)\n */\n// eslint-disable-next-line @typescript-eslint/naming-convention -- _TInput is unused in the interface body but part of the public positional generic API (callers supply it for inference)\nexport interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {\n /** Unique identifier for this generation client instance */\n id?: string\n\n /** Additional body parameters to send with connect-based adapter requests */\n body?: Record<string, any>\n\n /** Metadata used to register this generation hook with TanStack AI Devtools */\n devtools?: Partial<AIDevtoolsClientMetadata>\n\n /**\n * Factory that constructs the devtools bridge. Default is a no-op\n * factory; the real implementation lives in `@tanstack/ai-client/devtools`.\n */\n devtoolsBridgeFactory?: GenerationDevtoolsBridgeFactory\n\n /**\n * Callback when a result is received. Can optionally return a transformed value\n * that replaces the stored result.\n *\n * - Return a non-null value to transform and store it as the result\n * - Return `null` to keep the previous result unchanged\n * - Return nothing (`void`) to store the raw result as-is\n */\n onResult?: (result: TResult) => TOutput | null | void\n /** Callback when an error occurs */\n onError?: (error: Error) => void\n /** Callback when progress is reported (0-100) */\n onProgress?: (progress: number, message?: string) => void\n /** Callback for each stream chunk (connect-based adapter mode only) */\n onChunk?: (chunk: StreamChunk) => void\n\n // Framework state callbacks (set by hooks, not users)\n /** @internal Called when result changes */\n onResultChange?: (result: TOutput | null) => void\n /** @internal Called when loading state changes */\n onLoadingChange?: (isLoading: boolean) => void\n /** @internal Called when error state changes */\n onErrorChange?: (error: Error | undefined) => void\n /** @internal Called when generation status changes */\n onStatusChange?: (status: GenerationClientState) => void\n}\n\n// ===========================\n// Video-Specific Options\n// ===========================\n\n/**\n * Video status information returned during job polling.\n */\nexport interface VideoStatusInfo {\n /** Job identifier */\n jobId: string\n /** Current status of the video generation job */\n status: 'pending' | 'processing' | 'completed' | 'failed'\n /** Progress percentage (0-100), if available */\n progress?: number\n /** URL to the generated video (when completed) */\n url?: string\n /** Error message if status is 'failed' */\n error?: string\n}\n\n/**\n * Composite result for video generation (job completion).\n */\nexport interface VideoGenerateResult {\n /** Job identifier */\n jobId: string\n /** Final status */\n status: 'completed'\n /** URL to the generated video */\n url: string\n /** When the URL expires, if applicable */\n expiresAt?: Date\n}\n\n/**\n * Options for the VideoGenerationClient.\n */\nexport interface VideoGenerationClientOptions<\n TOutput = VideoGenerateResult,\n> extends Omit<\n GenerationClientOptions<VideoGenerateInput, VideoGenerateResult, TOutput>,\n 'devtoolsBridgeFactory'\n> {\n /**\n * Factory that constructs the video devtools bridge. Default is a no-op\n * factory; the real implementation lives in `@tanstack/ai-client/devtools`.\n */\n devtoolsBridgeFactory?: VideoDevtoolsBridgeFactory\n\n /** Callback when a video job is created */\n onJobCreated?: (jobId: string) => void\n /** Callback on each status update */\n onStatusUpdate?: (status: VideoStatusInfo) => void\n\n // Framework state callbacks\n /** @internal Called when jobId changes */\n onJobIdChange?: (jobId: string | null) => void\n /** @internal Called when video status changes */\n onVideoStatusChange?: (status: VideoStatusInfo | null) => void\n}\n\n// ===========================\n// Input Types\n// ===========================\n\n/**\n * Input for image generation.\n */\nexport interface ImageGenerateInput {\n /**\n * Description of the desired image(s): plain text, or an ordered array of\n * content parts (text + image) for image-conditioned generation\n * (image-to-image, multi-reference, edit / inpaint).\n */\n prompt: MediaPrompt\n /** Number of images to generate (default: 1) */\n numberOfImages?: number\n /** Image size in WIDTHxHEIGHT format (e.g., \"1024x1024\") */\n size?: string\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n\n/**\n * Input for audio generation (music, sound effects).\n */\nexport interface AudioGenerateInput {\n /** Text description of the desired audio */\n prompt: string\n /** Desired duration in seconds */\n duration?: number\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n\n/**\n * Input for text-to-speech generation.\n */\nexport interface SpeechGenerateInput {\n /** The text to convert to speech */\n text: string\n /** The voice to use for generation */\n voice?: string\n /** The output audio format */\n format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'\n /** The speed of the generated audio (0.25 to 4.0) */\n speed?: number\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n\n/**\n * Input for audio transcription.\n */\nexport interface TranscriptionGenerateInput {\n /** The audio data to transcribe - can be base64 string, File, Blob, or ArrayBuffer */\n audio: string | File | Blob | ArrayBuffer\n /** The language of the audio in ISO-639-1 format (e.g., 'en') */\n language?: string\n /** An optional prompt to guide the transcription */\n prompt?: string\n /** The format of the transcription output */\n responseFormat?: 'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n\n/**\n * Input for text summarization.\n */\nexport interface SummarizeGenerateInput {\n /** The text to summarize */\n text: string\n /** Maximum length of the summary */\n maxLength?: number\n /** Style of the summary */\n style?: 'bullet-points' | 'paragraph' | 'concise'\n /** Topics to focus on */\n focus?: Array<string>\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n\n/**\n * Input for video generation.\n */\nexport interface VideoGenerateInput {\n /**\n * Description of the desired video: plain text, or an ordered array of\n * content parts (text + image) for image-conditioned generation\n * (image-to-video, start/end frames).\n */\n prompt: MediaPrompt\n /** Video size — format depends on provider (e.g., \"16:9\", \"1280x720\") */\n size?: string\n /** Video duration in seconds */\n duration?: number\n /** Model-specific options */\n modelOptions?: Record<string, any>\n}\n"],"names":[],"mappings":"AAoEO,MAAM,oBAAoB;AAAA;AAAA,EAE/B,QAAQ;AAAA;AAAA,EAER,UAAU;AAAA;AAAA,EAEV,mBAAmB;AAAA;AAAA,EAEnB,cAAc;AAChB;"}
@@ -1,9 +1,13 @@
1
+ export { AudioRecorder } from './audio-recorder.js';
2
+ export type { AudioRecorderOptions, AudioRecorderState, AudioRecording, InferAudioRecordingOutput, } from './audio-recorder.js';
1
3
  export { ChatClient } from './chat-client.js';
4
+ export { createMcpAppBridge } from './mcp-app-bridge.js';
5
+ export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge.js';
2
6
  export { RealtimeClient } from './realtime-client.js';
3
7
  export { GenerationClient } from './generation-client.js';
4
8
  export { VideoGenerationClient } from './video-generation-client.js';
5
9
  export type { UIMessage, MessagePart, TextPart, ToolCallPart, ToolResultPart, ThinkingPart, StructuredOutputPart, ChatClientPersistence, ChatClientOptions, ClientContextOptionFromTools, ChatRequestBody, InferChatMessages, InferredClientContext, ChatClientState, ConnectionStatus, ChatFetcher, ChatFetcherInput, ChatFetcherOptions, ChatTransport, DistributedOmit, MultimodalContent, } from './types.js';
6
- export type { InferGenerationOutput, GenerationClientState, GenerationClientOptions, GenerationFetcher, GenerationFetcherOptions, GenerationTransport, VideoGenerationClientOptions, VideoStatusInfo, VideoGenerateResult, ImageGenerateInput, AudioGenerateInput, SpeechGenerateInput, TranscriptionGenerateInput, SummarizeGenerateInput, VideoGenerateInput, } from './generation-types.js';
10
+ export type { InferGenerationOutput, InferGenerationOutputFromReturn, GenerationClientState, GenerationClientOptions, GenerationFetcher, GenerationFetcherOptions, GenerationTransport, VideoGenerationClientOptions, VideoStatusInfo, VideoGenerateResult, ImageGenerateInput, AudioGenerateInput, SpeechGenerateInput, TranscriptionGenerateInput, SummarizeGenerateInput, VideoGenerateInput, } from './generation-types.js';
7
11
  export { GENERATION_EVENTS } from './generation-types.js';
8
12
  export { UnsupportedResponseStreamError } from './response-stream.js';
9
13
  export { clientTools, createChatClientOptions } from './types.js';
package/dist/esm/index.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { AudioRecorder } from "./audio-recorder.js";
1
2
  import { ChatClient } from "./chat-client.js";
3
+ import { createMcpAppBridge } from "./mcp-app-bridge.js";
2
4
  import { RealtimeClient } from "./realtime-client.js";
3
5
  import { GenerationClient } from "./generation-client.js";
4
6
  import { VideoGenerationClient } from "./video-generation-client.js";
@@ -9,6 +11,7 @@ import { createAIDevtoolsGenerationPreview } from "./devtools.js";
9
11
  import { StreamTruncatedError, fetchHttpStream, fetchServerSentEvents, rpcStream, stream, xhrHttpStream, xhrServerSentEvents } from "./connection-adapters.js";
10
12
  import { BatchStrategy, CompositeStrategy, ImmediateStrategy, PartialJSONParser, PunctuationStrategy, StreamProcessor, WordBoundaryStrategy, convertMessagesToModelMessages, defaultJSONParser, generateMessageId, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, parsePartialJSON, uiMessageToModelMessages } from "@tanstack/ai/client";
11
13
  export {
14
+ AudioRecorder,
12
15
  BatchStrategy,
13
16
  ChatClient,
14
17
  CompositeStrategy,
@@ -27,6 +30,7 @@ export {
27
30
  convertMessagesToModelMessages,
28
31
  createAIDevtoolsGenerationPreview,
29
32
  createChatClientOptions,
33
+ createMcpAppBridge,
30
34
  defaultJSONParser,
31
35
  fetchHttpStream,
32
36
  fetchServerSentEvents,
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;"}
@@ -0,0 +1,27 @@
1
+ export interface CreateMcpAppBridgeOptions {
2
+ threadId: string;
3
+ callEndpoint: string;
4
+ chat: {
5
+ sendMessage: (content: string, body?: Record<string, unknown>) => Promise<void>;
6
+ };
7
+ fetchImpl?: typeof fetch;
8
+ onLink?: (url: string) => void;
9
+ }
10
+ export interface McpAppBridge {
11
+ callTool: (input: {
12
+ serverId?: string;
13
+ toolName: string;
14
+ args?: Record<string, unknown>;
15
+ /**
16
+ * Reserved — forwarded to the call handler for correlation purposes but
17
+ * not consumed by the handler. Accepted on the wire; the handler does not
18
+ * read it (mirrors the `meta` convention on `UIResourcePart`).
19
+ */
20
+ messageId?: string;
21
+ }) => Promise<unknown>;
22
+ sendPrompt: (text: string) => Promise<void>;
23
+ openLink: (url: string) => {
24
+ isError: boolean;
25
+ };
26
+ }
27
+ export declare function createMcpAppBridge(options: CreateMcpAppBridgeOptions): McpAppBridge;
@@ -0,0 +1,70 @@
1
+ function isToolCallResponse(value) {
2
+ return value !== null && typeof value === "object" && "ok" in value && typeof value.ok === "boolean";
3
+ }
4
+ const SAFE_LINK_SCHEMES = /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]);
5
+ function isSafeLink(url) {
6
+ try {
7
+ return SAFE_LINK_SCHEMES.has(new URL(url).protocol);
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+ function createMcpAppBridge(options) {
13
+ const { threadId, callEndpoint, chat, fetchImpl, onLink } = options;
14
+ const doFetch = fetchImpl ?? fetch;
15
+ return {
16
+ async callTool(input) {
17
+ const response = await doFetch(callEndpoint, {
18
+ method: "POST",
19
+ headers: { "content-type": "application/json" },
20
+ body: JSON.stringify({
21
+ threadId,
22
+ serverId: input.serverId,
23
+ toolName: input.toolName,
24
+ args: input.args,
25
+ messageId: input.messageId
26
+ })
27
+ });
28
+ if (!response.ok) {
29
+ throw new Error(`MCP app tool call failed: HTTP ${response.status}`);
30
+ }
31
+ const raw = await response.json();
32
+ if (!isToolCallResponse(raw)) {
33
+ throw new Error("MCP app tool call failed");
34
+ }
35
+ if (!raw.ok) {
36
+ throw new Error(raw.error ?? "MCP app tool call failed");
37
+ }
38
+ return raw.result;
39
+ },
40
+ async sendPrompt(text) {
41
+ await chat.sendMessage(text);
42
+ },
43
+ openLink(url) {
44
+ if (!isSafeLink(url)) {
45
+ console.warn(
46
+ "[mcp-app-bridge] openLink rejected: unsupported URL scheme",
47
+ url
48
+ );
49
+ return { isError: true };
50
+ }
51
+ if (onLink) {
52
+ try {
53
+ onLink(url);
54
+ return { isError: false };
55
+ } catch (err) {
56
+ console.warn("[mcp-app-bridge] openLink: onLink handler threw", err);
57
+ return { isError: true };
58
+ }
59
+ }
60
+ console.warn(
61
+ "[mcp-app-bridge] openLink ignored: no onLink handler configured"
62
+ );
63
+ return { isError: true };
64
+ }
65
+ };
66
+ }
67
+ export {
68
+ createMcpAppBridge
69
+ };
70
+ //# sourceMappingURL=mcp-app-bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-app-bridge.js","sources":["../../src/mcp-app-bridge.ts"],"sourcesContent":["export interface CreateMcpAppBridgeOptions {\n threadId: string\n callEndpoint: string\n chat: {\n sendMessage: (\n content: string,\n body?: Record<string, unknown>,\n ) => Promise<void>\n }\n fetchImpl?: typeof fetch\n onLink?: (url: string) => void\n}\n\nexport interface McpAppBridge {\n callTool: (input: {\n serverId?: string\n toolName: string\n args?: Record<string, unknown>\n /**\n * Reserved — forwarded to the call handler for correlation purposes but\n * not consumed by the handler. Accepted on the wire; the handler does not\n * read it (mirrors the `meta` convention on `UIResourcePart`).\n */\n messageId?: string\n }) => Promise<unknown>\n sendPrompt: (text: string) => Promise<void>\n openLink: (url: string) => { isError: boolean }\n}\n\ninterface ToolCallResponse {\n ok: boolean\n result?: unknown\n error?: string\n}\n\nfunction isToolCallResponse(value: unknown): value is ToolCallResponse {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'ok' in value &&\n typeof value.ok === 'boolean'\n )\n}\n\n// Links arrive from an untrusted sandboxed widget. Only hand http(s)/mailto\n// URLs to the host's onLink; reject javascript:/data:/file:/etc. so a widget\n// can't smuggle a script-executing or local-resource URL through the bridge.\nconst SAFE_LINK_SCHEMES = new Set(['http:', 'https:', 'mailto:'])\nfunction isSafeLink(url: string): boolean {\n try {\n return SAFE_LINK_SCHEMES.has(new URL(url).protocol)\n } catch {\n return false\n }\n}\n\nexport function createMcpAppBridge(\n options: CreateMcpAppBridgeOptions,\n): McpAppBridge {\n const { threadId, callEndpoint, chat, fetchImpl, onLink } = options\n const doFetch = fetchImpl ?? fetch\n\n return {\n async callTool(input) {\n const response = await doFetch(callEndpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n threadId,\n serverId: input.serverId,\n toolName: input.toolName,\n args: input.args,\n messageId: input.messageId,\n }),\n })\n\n if (!response.ok) {\n throw new Error(`MCP app tool call failed: HTTP ${response.status}`)\n }\n\n const raw: unknown = await response.json()\n if (!isToolCallResponse(raw)) {\n throw new Error('MCP app tool call failed')\n }\n\n if (!raw.ok) {\n throw new Error(raw.error ?? 'MCP app tool call failed')\n }\n\n return raw.result\n },\n\n async sendPrompt(text) {\n await chat.sendMessage(text)\n },\n\n openLink(url) {\n if (!isSafeLink(url)) {\n console.warn(\n '[mcp-app-bridge] openLink rejected: unsupported URL scheme',\n url,\n )\n return { isError: true }\n }\n if (onLink) {\n try {\n onLink(url)\n return { isError: false }\n } catch (err) {\n console.warn('[mcp-app-bridge] openLink: onLink handler threw', err)\n return { isError: true }\n }\n }\n console.warn(\n '[mcp-app-bridge] openLink ignored: no onLink handler configured',\n )\n return { isError: true }\n },\n }\n}\n"],"names":[],"mappings":"AAmCA,SAAS,mBAAmB,OAA2C;AACrE,SACE,UAAU,QACV,OAAO,UAAU,YACjB,QAAQ,SACR,OAAO,MAAM,OAAO;AAExB;AAKA,MAAM,oBAAoB,oBAAI,IAAI,CAAC,SAAS,UAAU,SAAS,CAAC;AAChE,SAAS,WAAW,KAAsB;AACxC,MAAI;AACF,WAAO,kBAAkB,IAAI,IAAI,IAAI,GAAG,EAAE,QAAQ;AAAA,EACpD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,mBACd,SACc;AACd,QAAM,EAAE,UAAU,cAAc,MAAM,WAAW,WAAW;AAC5D,QAAM,UAAU,aAAa;AAE7B,SAAO;AAAA,IACL,MAAM,SAAS,OAAO;AACpB,YAAM,WAAW,MAAM,QAAQ,cAAc;AAAA,QAC3C,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAA;AAAA,QAC3B,MAAM,KAAK,UAAU;AAAA,UACnB;AAAA,UACA,UAAU,MAAM;AAAA,UAChB,UAAU,MAAM;AAAA,UAChB,MAAM,MAAM;AAAA,UACZ,WAAW,MAAM;AAAA,QAAA,CAClB;AAAA,MAAA,CACF;AAED,UAAI,CAAC,SAAS,IAAI;AAChB,cAAM,IAAI,MAAM,kCAAkC,SAAS,MAAM,EAAE;AAAA,MACrE;AAEA,YAAM,MAAe,MAAM,SAAS,KAAA;AACpC,UAAI,CAAC,mBAAmB,GAAG,GAAG;AAC5B,cAAM,IAAI,MAAM,0BAA0B;AAAA,MAC5C;AAEA,UAAI,CAAC,IAAI,IAAI;AACX,cAAM,IAAI,MAAM,IAAI,SAAS,0BAA0B;AAAA,MACzD;AAEA,aAAO,IAAI;AAAA,IACb;AAAA,IAEA,MAAM,WAAW,MAAM;AACrB,YAAM,KAAK,YAAY,IAAI;AAAA,IAC7B;AAAA,IAEA,SAAS,KAAK;AACZ,UAAI,CAAC,WAAW,GAAG,GAAG;AACpB,gBAAQ;AAAA,UACN;AAAA,UACA;AAAA,QAAA;AAEF,eAAO,EAAE,SAAS,KAAA;AAAA,MACpB;AACA,UAAI,QAAQ;AACV,YAAI;AACF,iBAAO,GAAG;AACV,iBAAO,EAAE,SAAS,MAAA;AAAA,QACpB,SAAS,KAAK;AACZ,kBAAQ,KAAK,mDAAmD,GAAG;AACnE,iBAAO,EAAE,SAAS,KAAA;AAAA,QACpB;AAAA,MACF;AACA,cAAQ;AAAA,QACN;AAAA,MAAA;AAEF,aAAO,EAAE,SAAS,KAAA;AAAA,IACpB;AAAA,EAAA;AAEJ;"}
@@ -1,4 +1,4 @@
1
- import { AnyClientTool, AudioPart, ChunkStrategy, ContentPart, DocumentPart, ImagePart, InferToolInput, InferToolOutput, ModelMessage, StreamChunk, StructuredOutputPart, VideoPart } from '@tanstack/ai/client';
1
+ import { AnyClientTool, AudioPart, ChunkStrategy, ContentPart, DocumentPart, ImagePart, InferToolInput, InferToolOutput, ModelMessage, StreamChunk, StructuredOutputPart, UIResourcePart, VideoPart } from '@tanstack/ai/client';
2
2
  import { ConnectionAdapter } from './connection-adapters.js';
3
3
  import { AIDevtoolsClientMetadata } from './devtools.js';
4
4
  import { ChatDevtoolsBridgeFactory } from './devtools-noop.js';
@@ -171,7 +171,7 @@ export interface ThinkingPart {
171
171
  type: 'thinking';
172
172
  content: string;
173
173
  }
174
- export type MessagePart<TTools extends ReadonlyArray<AnyClientTool> = any, TData = unknown> = TextPart | ImagePart | AudioPart | VideoPart | DocumentPart | ToolCallPart<TTools> | ToolResultPart | ThinkingPart | StructuredOutputPart<TData>;
174
+ export type MessagePart<TTools extends ReadonlyArray<AnyClientTool> = any, TData = unknown> = TextPart | ImagePart | AudioPart | VideoPart | DocumentPart | ToolCallPart<TTools> | ToolResultPart | ThinkingPart | StructuredOutputPart<TData> | UIResourcePart;
175
175
  /**
176
176
  * UIMessage - Domain-specific message format optimized for building chat UIs
177
177
  * Contains parts that can be text, tool calls, or tool results.
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sources":["../../src/types.ts"],"sourcesContent":["import type {\n AnyClientTool,\n AudioPart,\n ChunkStrategy,\n ContentPart,\n DocumentPart,\n ImagePart,\n InferToolInput,\n InferToolOutput,\n ModelMessage,\n StreamChunk,\n StructuredOutputPart,\n VideoPart,\n} from '@tanstack/ai/client'\nimport type { ConnectionAdapter } from './connection-adapters'\nimport type { AIDevtoolsClientMetadata } from './devtools'\nimport type { ChatDevtoolsBridgeFactory } from './devtools-noop'\n\nexport type { StructuredOutputPart } from '@tanstack/ai/client'\n\n/**\n * `messages` is the full UIMessage history (not a delta). `data` is the\n * merged body — `ChatClientOptions.body` plus any per-call data passed to\n * `sendMessage(...)`. `threadId` / `runId` are the AG-UI correlation ids\n * the chat client uses to track this turn — forward them to your server\n * if it needs to correlate requests.\n */\nexport interface ChatFetcherInput {\n messages: Array<UIMessage>\n data?: Record<string, unknown>\n threadId: string\n runId: string\n}\n\nexport interface ChatFetcherOptions {\n /** Fires when `stop()` is called or the request is superseded. */\n signal: AbortSignal\n}\n\n/**\n * Direct function that performs a chat request. Mirrors\n * `GenerationFetcher`. Returns either a `Response` (SSE body parsed by the\n * chat client) or an `AsyncIterable<StreamChunk>` (yielded directly). May\n * return the value synchronously, as a `Promise`, or as an async generator\n * (`async function*`) — the chat client awaits whichever shape is returned.\n *\n * @example\n * ```ts\n * useChat({\n * fetcher: ({ messages }, { signal }) =>\n * chatFn({ data: { messages }, signal }),\n * })\n * ```\n */\nexport type ChatFetcher = (\n input: ChatFetcherInput,\n options: ChatFetcherOptions,\n) =>\n | Response\n | AsyncIterable<StreamChunk>\n | Promise<Response | AsyncIterable<StreamChunk>>\n\n/**\n * Distributive `Omit` — applies `Omit<O, K>` per branch of a union so\n * discriminated unions survive omission. Plain `Omit` collapses unions\n * into a single object shape, which would erase the `ChatTransport` XOR\n * when framework hooks omit React-managed callbacks from\n * `ChatClientOptions`.\n */\nexport type DistributedOmit<\n TObject,\n TKeys extends keyof any,\n> = TObject extends unknown ? Omit<TObject, TKeys> : never\n\n/**\n * Discriminated union enforcing that exactly one of `connection` or\n * `fetcher` is provided. Mirrors `GenerationTransport`.\n */\nexport type ChatTransport =\n | { connection: ConnectionAdapter; fetcher?: never }\n | { fetcher: ChatFetcher; connection?: never }\n\n/**\n * Tool call states - track the lifecycle of a tool call\n */\nexport type ToolCallState =\n | 'awaiting-input' // Received start but no arguments yet\n | 'input-streaming' // Partial arguments received\n | 'input-complete' // All arguments received\n | 'approval-requested' // Waiting for user approval\n | 'approval-responded' // User has approved/denied\n | 'complete' // Result is complete\n | 'error' // Tool execution failed (terminal)\n\n/**\n * Tool result states - track the lifecycle of a tool result\n */\nexport type ToolResultState =\n | 'streaming' // Placeholder for future streamed output\n | 'complete' // Result is complete\n | 'error' // Error occurred\n\n/**\n * ChatClient state - track the lifecycle of a chat\n */\nexport type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error'\n\n/**\n * Connection lifecycle state for the subscription loop.\n */\nexport type ConnectionStatus =\n | 'disconnected'\n | 'connecting'\n | 'connected'\n | 'error'\n\n/**\n * Multimodal content input for sending messages with rich media.\n * Allows sending text, images, audio, video, and documents to the LLM.\n *\n * @example\n * ```ts\n * // Send an image with a question\n * client.sendMessage({\n * content: [\n * { type: 'text', content: 'What is in this image?' },\n * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }\n * ],\n * id: 'custom-message-id' // optional\n * })\n * ```\n */\nexport interface MultimodalContent {\n /**\n * The content of the message.\n * Can be a simple string or an array of content parts for multimodal messages.\n */\n content: string | Array<ContentPart>\n /**\n * Optional custom ID for the message.\n * If not provided, a unique ID will be generated.\n */\n id?: string\n}\n\n/**\n * Message parts - building blocks of UIMessage\n */\nexport interface TextPart {\n type: 'text'\n content: string\n}\n\n/**\n * Helper type that creates a tool-call part for a specific tool.\n * This is a conditional type to enable proper distribution over union types,\n * creating a discriminated union where `name` is the discriminant.\n */\ntype ToolCallPartForTool<T> = T extends AnyClientTool\n ? {\n type: 'tool-call'\n id: string\n name: T['name']\n arguments: string // JSON string (may be incomplete)\n /** Parsed tool input (typed from inputSchema) */\n input?: InferToolInput<T>\n state: ToolCallState\n /** Approval metadata if tool requires user approval */\n approval?: {\n id: string // Unique approval ID\n needsApproval: boolean // Always true if present\n approved?: boolean // User's decision (undefined until responded)\n }\n /** Tool execution output (for client tools or after approval) */\n output?: InferToolOutput<T>\n }\n : never\n\n/**\n * Fallback tool-call part type when tools are not typed\n */\ntype UntypedToolCallPart = {\n type: 'tool-call'\n id: string\n name: string\n arguments: string\n input?: any\n state: ToolCallState\n approval?: {\n id: string\n needsApproval: boolean\n approved?: boolean\n }\n output?: any\n}\n\n/**\n * Tool call part that creates a proper discriminated union.\n * When TTools is typed, checking `part.name === 'toolName'` will narrow\n * `part.output` to the correct type for that tool.\n *\n * The discriminant is `name`, so code like:\n * ```ts\n * if (part.name === 'recommendGuitar') {\n * // part.output is now typed to the recommendGuitar tool's output\n * }\n * ```\n */\nexport type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =\n // Check if we have a concrete tools array (not 'any' or 'never')\n [TTools] extends [never]\n ? UntypedToolCallPart\n : unknown extends TTools\n ? UntypedToolCallPart\n : TTools extends ReadonlyArray<infer Tool>\n ? Tool extends AnyClientTool\n ? ToolCallPartForTool<Tool>\n : UntypedToolCallPart\n : UntypedToolCallPart\n\nexport interface ToolResultPart {\n type: 'tool-result'\n toolCallId: string\n content: string | Array<ContentPart>\n state: ToolResultState\n error?: string // Error message if state is \"error\"\n}\n\nexport interface ThinkingPart {\n type: 'thinking'\n content: string\n}\n\nexport type MessagePart<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> =\n | TextPart\n | ImagePart\n | AudioPart\n | VideoPart\n | DocumentPart\n | ToolCallPart<TTools>\n | ToolResultPart\n | ThinkingPart\n | StructuredOutputPart<TData>\n\n/**\n * UIMessage - Domain-specific message format optimized for building chat UIs\n * Contains parts that can be text, tool calls, or tool results.\n *\n * `TTools` narrows the tool-call/result part types based on the registered\n * tools. `TData` is the schema-inferred type for any `structured-output` part\n * on the message — defaulted to `unknown` so untyped consumers (the core\n * stream processor, the wire converter) don't need to thread a schema generic\n * everywhere; the hook layer (`useChat({ outputSchema })`) substitutes it on\n * the public return so `m.parts.find(p => p.type === 'structured-output').data`\n * is typed without manual casts.\n */\nexport interface UIMessage<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> {\n id: string\n role: 'system' | 'user' | 'assistant'\n parts: Array<MessagePart<TTools, TData>>\n createdAt?: Date\n}\n\nexport interface ChatClientPersistence<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> {\n getItem: (\n id: string,\n ) =>\n | Array<UIMessage<TTools>>\n | null\n | undefined\n | Promise<Array<UIMessage<TTools>> | null | undefined>\n setItem: (\n id: string,\n messages: Array<UIMessage<TTools>>,\n ) => void | Promise<void>\n removeItem: (id: string) => void | Promise<void>\n}\n\ntype IsUnknown<T> = unknown extends T\n ? [T] extends [unknown]\n ? true\n : false\n : false\n\ntype KnownContext<T> = IsUnknown<T> extends true ? never : T\n\ntype MergeContext<TLeft, TRight> = [TLeft] extends [never]\n ? TRight\n : [TRight] extends [never]\n ? TLeft\n : TLeft & TRight\n\ntype UnionToIntersection<T> = [T] extends [never]\n ? never\n : (T extends unknown ? (value: T) => void : never) extends (\n value: infer TIntersection,\n ) => void\n ? TIntersection\n : never\n\ntype DefinedContext<T> = Exclude<T, undefined>\n\ntype ContextFromExecute<T> = T extends (...args: any) => any\n ? NonNullable<Parameters<T>[1]> extends { context: infer TContext }\n ? KnownContext<TContext>\n : never\n : never\n\ntype ContextFromClientTool<T> = T extends AnyClientTool\n ? T extends { execute?: infer TExecute }\n ? ContextFromExecute<TExecute>\n : never\n : never\n\ntype RequiredContextFromClientToolUnion<T> = T extends unknown\n ? undefined extends ContextFromClientTool<T>\n ? never\n : ContextFromClientTool<T>\n : never\n\ntype ContextFromClientToolUnion<T> = [\n UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>,\n] extends [never]\n ? never\n : [RequiredContextFromClientToolUnion<T>] extends [never]\n ? UnionToIntersection<DefinedContext<ContextFromClientTool<T>>> | undefined\n : UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>\n\ntype ContextFromClientTools<TTools> =\n IsUnknown<TTools> extends true\n ? never\n : TTools extends readonly [infer THead, ...infer TTail]\n ? MergeContext<\n ContextFromClientTool<THead>,\n ContextFromClientTools<TTail>\n >\n : TTools extends ReadonlyArray<infer TItem>\n ? ContextFromClientToolUnion<TItem>\n : never\n\nexport type InferredClientContext<TTools> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? unknown\n : ContextFromClientTools<TTools>\n\nexport type ClientContextOptionFromTools<TTools, TContext> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? { context?: TContext }\n : undefined extends ContextFromClientTools<TTools>\n ? { context?: TContext & ContextFromClientTools<TTools> }\n : { context: TContext & ContextFromClientTools<TTools> }\n\n/**\n * Base options for `ChatClient`, excluding the transport (`connection` or\n * `fetcher`) which is supplied separately via `ChatTransport` so the XOR\n * is preserved when composing the final `ChatClientOptions` type.\n */\nexport interface ChatClientBaseOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = unknown,\n> {\n /**\n * Initial messages to populate the chat\n */\n initialMessages?: Array<UIMessage<TTools>>\n\n /**\n * Optional persistence adapter for chat messages.\n */\n persistence?: ChatClientPersistence<TTools>\n\n /**\n * Unique identifier for this chat instance\n * Used for managing multiple chats\n */\n id?: string\n\n /**\n * Thread ID to use for this chat session. Persists across sends within\n * the session. If omitted, a unique thread ID is generated.\n */\n threadId?: string\n\n /**\n * Arbitrary client-controlled JSON forwarded to the server in the\n * AG-UI `RunAgentInput.forwardedProps` field. Use this for per-session\n * options like provider/model selection or feature flags that the\n * server endpoint should read.\n *\n * Replaces the legacy `body` option. If both are provided,\n * `forwardedProps` wins on key collision.\n */\n forwardedProps?: Record<string, any>\n\n /**\n * @deprecated Use `forwardedProps` instead. `body` continues to work\n * unchanged — its values are merged into the AG-UI\n * `RunAgentInput.forwardedProps` field on the wire and are also\n * mirrored under the legacy `data` field for servers that have not\n * migrated yet. Will be removed in a future major release.\n */\n body?: Record<string, any>\n\n /**\n * Client-local runtime context passed to client tool implementations.\n *\n * This value is not serialized to the server. Use `forwardedProps` for\n * explicit client-to-server handoff of serializable values.\n */\n context?: TContext\n\n /**\n * Callback when a response is received\n */\n onResponse?: (response?: Response) => void | Promise<void>\n\n /**\n * Callback when a stream chunk is received\n */\n onChunk?: (chunk: StreamChunk) => void\n\n /**\n * Callback when the response is finished\n */\n onFinish?: (message: UIMessage<TTools>) => void\n\n /**\n * Callback when an error occurs\n */\n onError?: (error: Error) => void\n\n /**\n * Callback when messages change\n */\n onMessagesChange?: (messages: Array<UIMessage<TTools>>) => void\n\n /**\n * Callback when loading state changes\n */\n onLoadingChange?: (isLoading: boolean) => void\n\n /**\n * Callback when error state changes\n */\n onErrorChange?: (error: Error | undefined) => void\n\n /**\n * Callback when chat status changes\n */\n onStatusChange?: (status: ChatClientState) => void\n\n /**\n * Callback when subscription lifecycle changes.\n * This is independent from request lifecycle (`isLoading`, `status`).\n */\n onSubscriptionChange?: (isSubscribed: boolean) => void\n\n /**\n * Callback when connection lifecycle changes.\n */\n onConnectionStatusChange?: (status: ConnectionStatus) => void\n\n /**\n * Callback when session generation activity changes.\n * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).\n * Unlike `onLoadingChange` (request-local), this reflects shared generation\n * activity visible to all subscribers (e.g. across tabs/devices).\n */\n onSessionGeneratingChange?: (isGenerating: boolean) => void\n\n /**\n * Callback when a custom event is received from a server-side tool.\n * Custom events are emitted by tools using `context.emitCustomEvent()` during execution.\n *\n * @param eventType - The name of the custom event\n * @param data - The event payload data\n * @param context - Additional context including the toolCallId that emitted the event\n */\n onCustomEvent?: (\n eventType: string,\n data: unknown,\n context: { toolCallId?: string },\n ) => void\n\n /**\n * Client-side tools with execution logic\n * When provided, tools with execute functions will be called automatically\n */\n tools?: TTools\n\n /**\n * Devtools hook metadata for this client instance.\n */\n devtools?: Partial<AIDevtoolsClientMetadata>\n\n /**\n * Factory that constructs the devtools bridge. Default is a no-op\n * factory, which keeps `@tanstack/ai-client/devtools` (the heavy\n * bridge implementation) out of the main entry's bundle. Frameworks\n * that need live devtools should pass the real factory from\n * `@tanstack/ai-client/devtools`.\n */\n devtoolsBridgeFactory?: ChatDevtoolsBridgeFactory\n\n /**\n * Stream processing options (optional)\n * Configure chunking strategy\n */\n streamProcessor?: {\n /**\n * Strategy for when to emit text updates\n * Defaults to ImmediateStrategy (every chunk)\n */\n chunkStrategy?: ChunkStrategy\n }\n}\n\n/**\n * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be\n * provided — the type-level XOR is enforced via `ChatTransport`.\n */\nexport type ChatClientOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = InferredClientContext<TTools>,\n> = DistributedOmit<ChatClientBaseOptions<TTools, TContext>, 'context'> &\n ClientContextOptionFromTools<TTools, TContext> &\n ChatTransport\n\nexport interface ChatRequestBody {\n messages: Array<ModelMessage>\n data?: Record<string, any>\n}\n\n/**\n * Create a typed array of client tools with proper type inference.\n * This eliminates the need for `as const` when defining tool arrays.\n *\n * @example\n * ```ts\n * const tools = clientTools(\n * myTool1.client(() => result1),\n * myTool2.client(() => result2),\n * )\n *\n * // tools is now properly typed as a tuple with literal tool names\n * // This enables type narrowing when checking part.name === 'toolName'\n * ```\n */\nexport function clientTools<const T extends Array<AnyClientTool>>(\n ...tools: T\n): T {\n return tools\n}\n\n/**\n * Helper to create typed chat client options\n * Use this to get proper type inference for messages\n *\n * @example\n * ```ts\n * const tools = clientTools(myTool1, myTool2)\n *\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools,\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * ```\n */\nexport function createChatClientOptions<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(\n options: ChatClientOptions<TTools, TContext>,\n): ChatClientOptions<TTools, TContext> {\n return options\n}\n\n/**\n * Extract the message type from chat options\n *\n * @example\n * ```ts\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools: [myTool1, myTool2],\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * // MyMessages is now Array<UIMessage<[typeof myTool1, typeof myTool2]>>\n * ```\n */\nexport type InferChatMessages<T> =\n T extends ChatClientOptions<infer TTools, any>\n ? Array<UIMessage<TTools>>\n : never\n"],"names":[],"mappings":"AA8iBO,SAAS,eACX,OACA;AACH,SAAO;AACT;AAkBO,SAAS,wBAId,SACqC;AACrC,SAAO;AACT;"}
1
+ {"version":3,"file":"types.js","sources":["../../src/types.ts"],"sourcesContent":["import type {\n AnyClientTool,\n AudioPart,\n ChunkStrategy,\n ContentPart,\n DocumentPart,\n ImagePart,\n InferToolInput,\n InferToolOutput,\n ModelMessage,\n StreamChunk,\n StructuredOutputPart,\n UIResourcePart,\n VideoPart,\n} from '@tanstack/ai/client'\nimport type { ConnectionAdapter } from './connection-adapters'\nimport type { AIDevtoolsClientMetadata } from './devtools'\nimport type { ChatDevtoolsBridgeFactory } from './devtools-noop'\n\nexport type { StructuredOutputPart } from '@tanstack/ai/client'\n\n/**\n * `messages` is the full UIMessage history (not a delta). `data` is the\n * merged body — `ChatClientOptions.body` plus any per-call data passed to\n * `sendMessage(...)`. `threadId` / `runId` are the AG-UI correlation ids\n * the chat client uses to track this turn — forward them to your server\n * if it needs to correlate requests.\n */\nexport interface ChatFetcherInput {\n messages: Array<UIMessage>\n data?: Record<string, unknown>\n threadId: string\n runId: string\n}\n\nexport interface ChatFetcherOptions {\n /** Fires when `stop()` is called or the request is superseded. */\n signal: AbortSignal\n}\n\n/**\n * Direct function that performs a chat request. Mirrors\n * `GenerationFetcher`. Returns either a `Response` (SSE body parsed by the\n * chat client) or an `AsyncIterable<StreamChunk>` (yielded directly). May\n * return the value synchronously, as a `Promise`, or as an async generator\n * (`async function*`) — the chat client awaits whichever shape is returned.\n *\n * @example\n * ```ts\n * useChat({\n * fetcher: ({ messages }, { signal }) =>\n * chatFn({ data: { messages }, signal }),\n * })\n * ```\n */\nexport type ChatFetcher = (\n input: ChatFetcherInput,\n options: ChatFetcherOptions,\n) =>\n | Response\n | AsyncIterable<StreamChunk>\n | Promise<Response | AsyncIterable<StreamChunk>>\n\n/**\n * Distributive `Omit` — applies `Omit<O, K>` per branch of a union so\n * discriminated unions survive omission. Plain `Omit` collapses unions\n * into a single object shape, which would erase the `ChatTransport` XOR\n * when framework hooks omit React-managed callbacks from\n * `ChatClientOptions`.\n */\nexport type DistributedOmit<\n TObject,\n TKeys extends keyof any,\n> = TObject extends unknown ? Omit<TObject, TKeys> : never\n\n/**\n * Discriminated union enforcing that exactly one of `connection` or\n * `fetcher` is provided. Mirrors `GenerationTransport`.\n */\nexport type ChatTransport =\n | { connection: ConnectionAdapter; fetcher?: never }\n | { fetcher: ChatFetcher; connection?: never }\n\n/**\n * Tool call states - track the lifecycle of a tool call\n */\nexport type ToolCallState =\n | 'awaiting-input' // Received start but no arguments yet\n | 'input-streaming' // Partial arguments received\n | 'input-complete' // All arguments received\n | 'approval-requested' // Waiting for user approval\n | 'approval-responded' // User has approved/denied\n | 'complete' // Result is complete\n | 'error' // Tool execution failed (terminal)\n\n/**\n * Tool result states - track the lifecycle of a tool result\n */\nexport type ToolResultState =\n | 'streaming' // Placeholder for future streamed output\n | 'complete' // Result is complete\n | 'error' // Error occurred\n\n/**\n * ChatClient state - track the lifecycle of a chat\n */\nexport type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error'\n\n/**\n * Connection lifecycle state for the subscription loop.\n */\nexport type ConnectionStatus =\n | 'disconnected'\n | 'connecting'\n | 'connected'\n | 'error'\n\n/**\n * Multimodal content input for sending messages with rich media.\n * Allows sending text, images, audio, video, and documents to the LLM.\n *\n * @example\n * ```ts\n * // Send an image with a question\n * client.sendMessage({\n * content: [\n * { type: 'text', content: 'What is in this image?' },\n * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }\n * ],\n * id: 'custom-message-id' // optional\n * })\n * ```\n */\nexport interface MultimodalContent {\n /**\n * The content of the message.\n * Can be a simple string or an array of content parts for multimodal messages.\n */\n content: string | Array<ContentPart>\n /**\n * Optional custom ID for the message.\n * If not provided, a unique ID will be generated.\n */\n id?: string\n}\n\n/**\n * Message parts - building blocks of UIMessage\n */\nexport interface TextPart {\n type: 'text'\n content: string\n}\n\n/**\n * Helper type that creates a tool-call part for a specific tool.\n * This is a conditional type to enable proper distribution over union types,\n * creating a discriminated union where `name` is the discriminant.\n */\ntype ToolCallPartForTool<T> = T extends AnyClientTool\n ? {\n type: 'tool-call'\n id: string\n name: T['name']\n arguments: string // JSON string (may be incomplete)\n /** Parsed tool input (typed from inputSchema) */\n input?: InferToolInput<T>\n state: ToolCallState\n /** Approval metadata if tool requires user approval */\n approval?: {\n id: string // Unique approval ID\n needsApproval: boolean // Always true if present\n approved?: boolean // User's decision (undefined until responded)\n }\n /** Tool execution output (for client tools or after approval) */\n output?: InferToolOutput<T>\n }\n : never\n\n/**\n * Fallback tool-call part type when tools are not typed\n */\ntype UntypedToolCallPart = {\n type: 'tool-call'\n id: string\n name: string\n arguments: string\n input?: any\n state: ToolCallState\n approval?: {\n id: string\n needsApproval: boolean\n approved?: boolean\n }\n output?: any\n}\n\n/**\n * Tool call part that creates a proper discriminated union.\n * When TTools is typed, checking `part.name === 'toolName'` will narrow\n * `part.output` to the correct type for that tool.\n *\n * The discriminant is `name`, so code like:\n * ```ts\n * if (part.name === 'recommendGuitar') {\n * // part.output is now typed to the recommendGuitar tool's output\n * }\n * ```\n */\nexport type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =\n // Check if we have a concrete tools array (not 'any' or 'never')\n [TTools] extends [never]\n ? UntypedToolCallPart\n : unknown extends TTools\n ? UntypedToolCallPart\n : TTools extends ReadonlyArray<infer Tool>\n ? Tool extends AnyClientTool\n ? ToolCallPartForTool<Tool>\n : UntypedToolCallPart\n : UntypedToolCallPart\n\nexport interface ToolResultPart {\n type: 'tool-result'\n toolCallId: string\n content: string | Array<ContentPart>\n state: ToolResultState\n error?: string // Error message if state is \"error\"\n}\n\nexport interface ThinkingPart {\n type: 'thinking'\n content: string\n}\n\nexport type MessagePart<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> =\n | TextPart\n | ImagePart\n | AudioPart\n | VideoPart\n | DocumentPart\n | ToolCallPart<TTools>\n | ToolResultPart\n | ThinkingPart\n | StructuredOutputPart<TData>\n | UIResourcePart\n\n/**\n * UIMessage - Domain-specific message format optimized for building chat UIs\n * Contains parts that can be text, tool calls, or tool results.\n *\n * `TTools` narrows the tool-call/result part types based on the registered\n * tools. `TData` is the schema-inferred type for any `structured-output` part\n * on the message — defaulted to `unknown` so untyped consumers (the core\n * stream processor, the wire converter) don't need to thread a schema generic\n * everywhere; the hook layer (`useChat({ outputSchema })`) substitutes it on\n * the public return so `m.parts.find(p => p.type === 'structured-output').data`\n * is typed without manual casts.\n */\nexport interface UIMessage<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TData = unknown,\n> {\n id: string\n role: 'system' | 'user' | 'assistant'\n parts: Array<MessagePart<TTools, TData>>\n createdAt?: Date\n}\n\nexport interface ChatClientPersistence<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> {\n getItem: (\n id: string,\n ) =>\n | Array<UIMessage<TTools>>\n | null\n | undefined\n | Promise<Array<UIMessage<TTools>> | null | undefined>\n setItem: (\n id: string,\n messages: Array<UIMessage<TTools>>,\n ) => void | Promise<void>\n removeItem: (id: string) => void | Promise<void>\n}\n\ntype IsUnknown<T> = unknown extends T\n ? [T] extends [unknown]\n ? true\n : false\n : false\n\ntype KnownContext<T> = IsUnknown<T> extends true ? never : T\n\ntype MergeContext<TLeft, TRight> = [TLeft] extends [never]\n ? TRight\n : [TRight] extends [never]\n ? TLeft\n : TLeft & TRight\n\ntype UnionToIntersection<T> = [T] extends [never]\n ? never\n : (T extends unknown ? (value: T) => void : never) extends (\n value: infer TIntersection,\n ) => void\n ? TIntersection\n : never\n\ntype DefinedContext<T> = Exclude<T, undefined>\n\ntype ContextFromExecute<T> = T extends (...args: any) => any\n ? NonNullable<Parameters<T>[1]> extends { context: infer TContext }\n ? KnownContext<TContext>\n : never\n : never\n\ntype ContextFromClientTool<T> = T extends AnyClientTool\n ? T extends { execute?: infer TExecute }\n ? ContextFromExecute<TExecute>\n : never\n : never\n\ntype RequiredContextFromClientToolUnion<T> = T extends unknown\n ? undefined extends ContextFromClientTool<T>\n ? never\n : ContextFromClientTool<T>\n : never\n\ntype ContextFromClientToolUnion<T> = [\n UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>,\n] extends [never]\n ? never\n : [RequiredContextFromClientToolUnion<T>] extends [never]\n ? UnionToIntersection<DefinedContext<ContextFromClientTool<T>>> | undefined\n : UnionToIntersection<DefinedContext<ContextFromClientTool<T>>>\n\ntype ContextFromClientTools<TTools> =\n IsUnknown<TTools> extends true\n ? never\n : TTools extends readonly [infer THead, ...infer TTail]\n ? MergeContext<\n ContextFromClientTool<THead>,\n ContextFromClientTools<TTail>\n >\n : TTools extends ReadonlyArray<infer TItem>\n ? ContextFromClientToolUnion<TItem>\n : never\n\nexport type InferredClientContext<TTools> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? unknown\n : ContextFromClientTools<TTools>\n\nexport type ClientContextOptionFromTools<TTools, TContext> = [\n ContextFromClientTools<TTools>,\n] extends [never]\n ? { context?: TContext }\n : undefined extends ContextFromClientTools<TTools>\n ? { context?: TContext & ContextFromClientTools<TTools> }\n : { context: TContext & ContextFromClientTools<TTools> }\n\n/**\n * Base options for `ChatClient`, excluding the transport (`connection` or\n * `fetcher`) which is supplied separately via `ChatTransport` so the XOR\n * is preserved when composing the final `ChatClientOptions` type.\n */\nexport interface ChatClientBaseOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = unknown,\n> {\n /**\n * Initial messages to populate the chat\n */\n initialMessages?: Array<UIMessage<TTools>>\n\n /**\n * Optional persistence adapter for chat messages.\n */\n persistence?: ChatClientPersistence<TTools>\n\n /**\n * Unique identifier for this chat instance\n * Used for managing multiple chats\n */\n id?: string\n\n /**\n * Thread ID to use for this chat session. Persists across sends within\n * the session. If omitted, a unique thread ID is generated.\n */\n threadId?: string\n\n /**\n * Arbitrary client-controlled JSON forwarded to the server in the\n * AG-UI `RunAgentInput.forwardedProps` field. Use this for per-session\n * options like provider/model selection or feature flags that the\n * server endpoint should read.\n *\n * Replaces the legacy `body` option. If both are provided,\n * `forwardedProps` wins on key collision.\n */\n forwardedProps?: Record<string, any>\n\n /**\n * @deprecated Use `forwardedProps` instead. `body` continues to work\n * unchanged — its values are merged into the AG-UI\n * `RunAgentInput.forwardedProps` field on the wire and are also\n * mirrored under the legacy `data` field for servers that have not\n * migrated yet. Will be removed in a future major release.\n */\n body?: Record<string, any>\n\n /**\n * Client-local runtime context passed to client tool implementations.\n *\n * This value is not serialized to the server. Use `forwardedProps` for\n * explicit client-to-server handoff of serializable values.\n */\n context?: TContext\n\n /**\n * Callback when a response is received\n */\n onResponse?: (response?: Response) => void | Promise<void>\n\n /**\n * Callback when a stream chunk is received\n */\n onChunk?: (chunk: StreamChunk) => void\n\n /**\n * Callback when the response is finished\n */\n onFinish?: (message: UIMessage<TTools>) => void\n\n /**\n * Callback when an error occurs\n */\n onError?: (error: Error) => void\n\n /**\n * Callback when messages change\n */\n onMessagesChange?: (messages: Array<UIMessage<TTools>>) => void\n\n /**\n * Callback when loading state changes\n */\n onLoadingChange?: (isLoading: boolean) => void\n\n /**\n * Callback when error state changes\n */\n onErrorChange?: (error: Error | undefined) => void\n\n /**\n * Callback when chat status changes\n */\n onStatusChange?: (status: ChatClientState) => void\n\n /**\n * Callback when subscription lifecycle changes.\n * This is independent from request lifecycle (`isLoading`, `status`).\n */\n onSubscriptionChange?: (isSubscribed: boolean) => void\n\n /**\n * Callback when connection lifecycle changes.\n */\n onConnectionStatusChange?: (status: ConnectionStatus) => void\n\n /**\n * Callback when session generation activity changes.\n * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).\n * Unlike `onLoadingChange` (request-local), this reflects shared generation\n * activity visible to all subscribers (e.g. across tabs/devices).\n */\n onSessionGeneratingChange?: (isGenerating: boolean) => void\n\n /**\n * Callback when a custom event is received from a server-side tool.\n * Custom events are emitted by tools using `context.emitCustomEvent()` during execution.\n *\n * @param eventType - The name of the custom event\n * @param data - The event payload data\n * @param context - Additional context including the toolCallId that emitted the event\n */\n onCustomEvent?: (\n eventType: string,\n data: unknown,\n context: { toolCallId?: string },\n ) => void\n\n /**\n * Client-side tools with execution logic\n * When provided, tools with execute functions will be called automatically\n */\n tools?: TTools\n\n /**\n * Devtools hook metadata for this client instance.\n */\n devtools?: Partial<AIDevtoolsClientMetadata>\n\n /**\n * Factory that constructs the devtools bridge. Default is a no-op\n * factory, which keeps `@tanstack/ai-client/devtools` (the heavy\n * bridge implementation) out of the main entry's bundle. Frameworks\n * that need live devtools should pass the real factory from\n * `@tanstack/ai-client/devtools`.\n */\n devtoolsBridgeFactory?: ChatDevtoolsBridgeFactory\n\n /**\n * Stream processing options (optional)\n * Configure chunking strategy\n */\n streamProcessor?: {\n /**\n * Strategy for when to emit text updates\n * Defaults to ImmediateStrategy (every chunk)\n */\n chunkStrategy?: ChunkStrategy\n }\n}\n\n/**\n * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be\n * provided — the type-level XOR is enforced via `ChatTransport`.\n */\nexport type ChatClientOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n TContext = InferredClientContext<TTools>,\n> = DistributedOmit<ChatClientBaseOptions<TTools, TContext>, 'context'> &\n ClientContextOptionFromTools<TTools, TContext> &\n ChatTransport\n\nexport interface ChatRequestBody {\n messages: Array<ModelMessage>\n data?: Record<string, any>\n}\n\n/**\n * Create a typed array of client tools with proper type inference.\n * This eliminates the need for `as const` when defining tool arrays.\n *\n * @example\n * ```ts\n * const tools = clientTools(\n * myTool1.client(() => result1),\n * myTool2.client(() => result2),\n * )\n *\n * // tools is now properly typed as a tuple with literal tool names\n * // This enables type narrowing when checking part.name === 'toolName'\n * ```\n */\nexport function clientTools<const T extends Array<AnyClientTool>>(\n ...tools: T\n): T {\n return tools\n}\n\n/**\n * Helper to create typed chat client options\n * Use this to get proper type inference for messages\n *\n * @example\n * ```ts\n * const tools = clientTools(myTool1, myTool2)\n *\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools,\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * ```\n */\nexport function createChatClientOptions<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(\n options: ChatClientOptions<TTools, TContext>,\n): ChatClientOptions<TTools, TContext> {\n return options\n}\n\n/**\n * Extract the message type from chat options\n *\n * @example\n * ```ts\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools: [myTool1, myTool2],\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * // MyMessages is now Array<UIMessage<[typeof myTool1, typeof myTool2]>>\n * ```\n */\nexport type InferChatMessages<T> =\n T extends ChatClientOptions<infer TTools, any>\n ? Array<UIMessage<TTools>>\n : never\n"],"names":[],"mappings":"AAgjBO,SAAS,eACX,OACA;AACH,SAAO;AACT;AAkBO,SAAS,wBAId,SACqC;AACrC,SAAO;AACT;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-client",
3
- "version": "0.18.6",
3
+ "version": "0.19.1",
4
4
  "description": "Framework-agnostic headless client for TanStack AI chat, realtime sessions, streaming transports, and media generations.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -49,8 +49,9 @@
49
49
  "src"
50
50
  ],
51
51
  "dependencies": {
52
- "@tanstack/ai": "0.37.0",
53
- "@tanstack/ai-event-client": "0.6.8"
52
+ "@tanstack/ai": "0.39.0",
53
+ "@tanstack/ai-event-client": "0.6.8",
54
+ "@tanstack/ai-utils": "0.3.1"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@standard-schema/spec": "^1.1.0",
@@ -0,0 +1,322 @@
1
+ import { arrayBufferToBase64 } from '@tanstack/ai-utils'
2
+ import type { AudioPart } from '@tanstack/ai/client'
3
+
4
+ /** Lifecycle state of an {@link AudioRecorder}. */
5
+ export type AudioRecorderState = 'idle' | 'recording' | 'stopping'
6
+
7
+ export interface AudioRecorderOptions {
8
+ /** Constraints forwarded to `getUserMedia({ audio })`. Defaults to `true`. */
9
+ audio?: MediaTrackConstraints | boolean
10
+ /**
11
+ * Preferred recorder mime type. Used only when
12
+ * `MediaRecorder.isTypeSupported` reports it; otherwise the browser default
13
+ * is used.
14
+ */
15
+ mimeType?: string
16
+ /** Fired on `getUserMedia` rejection (permission denied) or recorder error. */
17
+ onError?: (error: Error) => void
18
+ }
19
+
20
+ /**
21
+ * Resolves the value `stop()` produces from a recorder transform callback.
22
+ *
23
+ * - If the callback returns a value — sync or async — that value's awaited type
24
+ * is used (a returned `null` is a real value and is preserved).
25
+ * - If the callback returns nothing (`void`/`undefined`), or is absent, falls
26
+ * back to {@link AudioRecording}.
27
+ *
28
+ * @template TFn - The transform callback type (or undefined if not provided)
29
+ */
30
+ export type InferAudioRecordingOutput<TFn> = TFn extends (
31
+ recording: AudioRecording,
32
+ ) => infer R
33
+ ? [Exclude<Awaited<R>, void | undefined>] extends [never]
34
+ ? AudioRecording
35
+ : Exclude<Awaited<R>, void | undefined>
36
+ : AudioRecording
37
+
38
+ export interface AudioRecording {
39
+ /** The raw recorded media blob. */
40
+ blob: Blob
41
+ /** Base64 of the recorded bytes (no `data:` prefix). */
42
+ base64: string
43
+ /** The recorder's native mime type, e.g. `audio/webm;codecs=opus`. */
44
+ mimeType: string
45
+ /** Recording length in milliseconds. */
46
+ durationMs: number
47
+ /**
48
+ * Ready-to-use audio content part for `sendMessage`/generation prompts:
49
+ * `{ type: 'audio', source: { type: 'data', value: base64, mimeType } }`.
50
+ */
51
+ part: AudioPart
52
+ }
53
+
54
+ /**
55
+ * Framework-agnostic browser audio recorder. Wraps `getUserMedia` +
56
+ * `MediaRecorder`, returns the recorder's native output (no transcode), and
57
+ * builds a plug-and-play {@link AudioRecording.part}.
58
+ */
59
+ export class AudioRecorder {
60
+ private readonly options: AudioRecorderOptions
61
+ private recorder: MediaRecorder | null = null
62
+ private stream: MediaStream | null = null
63
+ private chunks: Array<Blob> = []
64
+ private startedAt = 0
65
+ private _state: AudioRecorderState = 'idle'
66
+ // True while start() is awaiting getUserMedia (state is still 'idle' then).
67
+ private starting = false
68
+ // Set by cancel()/teardown during that window so start() releases the
69
+ // freshly acquired stream instead of beginning a leaked recording.
70
+ private pendingCancel = false
71
+ private readonly listeners = new Set<(state: AudioRecorderState) => void>()
72
+ private stopResolve: ((recording: AudioRecording) => void) | null = null
73
+ private stopReject: ((error: Error) => void) | null = null
74
+
75
+ constructor(options: AudioRecorderOptions = {}) {
76
+ this.options = options
77
+ }
78
+
79
+ /** Feature-detect the browser media APIs. SSR/Worker-safe. */
80
+ static isSupported(): boolean {
81
+ return (
82
+ typeof navigator !== 'undefined' &&
83
+ typeof navigator.mediaDevices !== 'undefined' &&
84
+ typeof navigator.mediaDevices.getUserMedia === 'function' &&
85
+ typeof MediaRecorder !== 'undefined'
86
+ )
87
+ }
88
+
89
+ get state(): AudioRecorderState {
90
+ return this._state
91
+ }
92
+
93
+ subscribe(cb: (state: AudioRecorderState) => void): () => void {
94
+ this.listeners.add(cb)
95
+ return () => {
96
+ this.listeners.delete(cb)
97
+ }
98
+ }
99
+
100
+ private setState(state: AudioRecorderState): void {
101
+ this._state = state
102
+ for (const cb of this.listeners) {
103
+ cb(state)
104
+ }
105
+ }
106
+
107
+ async start(): Promise<void> {
108
+ if (this._state !== 'idle' || this.starting) {
109
+ return
110
+ }
111
+ this.starting = true
112
+ try {
113
+ const stream = await navigator.mediaDevices.getUserMedia({
114
+ audio: this.options.audio ?? true,
115
+ })
116
+ // cancel()/teardown ran while we were awaiting the mic: release the
117
+ // freshly acquired stream and bail rather than starting a recording the
118
+ // caller can no longer stop (a leaked live microphone).
119
+ if (this.pendingCancel) {
120
+ stream.getTracks().forEach((t) => t.stop())
121
+ return
122
+ }
123
+ this.stream = stream
124
+ const wanted = this.options.mimeType
125
+ const useMimeType =
126
+ wanted &&
127
+ typeof MediaRecorder.isTypeSupported === 'function' &&
128
+ MediaRecorder.isTypeSupported(wanted)
129
+ ? wanted
130
+ : undefined
131
+ const recorder = useMimeType
132
+ ? new MediaRecorder(stream, { mimeType: useMimeType })
133
+ : new MediaRecorder(stream)
134
+ this.chunks = []
135
+ recorder.ondataavailable = (e) => {
136
+ if (e.data.size > 0) {
137
+ this.chunks.push(e.data)
138
+ }
139
+ }
140
+ recorder.onstop = () => {
141
+ void this.finalize()
142
+ }
143
+ recorder.onerror = (event) => {
144
+ const detail =
145
+ 'error' in event && event.error instanceof Error
146
+ ? event.error
147
+ : new Error('Audio recording failed')
148
+ this.handleError(detail)
149
+ }
150
+ this.recorder = recorder
151
+ this.startedAt = Date.now()
152
+ recorder.start()
153
+ this.setState('recording')
154
+ } catch (err) {
155
+ this.releaseStream()
156
+ this.recorder = null
157
+ const error =
158
+ err instanceof Error ? err : new Error('Failed to start recording')
159
+ this.setState('idle')
160
+ this.notifyError(error)
161
+ throw error
162
+ } finally {
163
+ this.starting = false
164
+ // Reset here (not before the await) so a cancel() that arrives mid-start
165
+ // is observed above; clearing it afterward keeps the next start() clean.
166
+ this.pendingCancel = false
167
+ }
168
+ }
169
+
170
+ stop(): Promise<AudioRecording> {
171
+ if (this._state !== 'recording' || !this.recorder) {
172
+ return Promise.reject(
173
+ new Error('AudioRecorder.stop() called while not recording'),
174
+ )
175
+ }
176
+ this.setState('stopping')
177
+ const recorder = this.recorder
178
+ return new Promise<AudioRecording>((resolve, reject) => {
179
+ // Some browsers/codecs never fire onstop; this watchdog unwedges the
180
+ // recorder instead of leaking this promise forever.
181
+ const watchdog = setTimeout(() => {
182
+ if (this._state !== 'stopping') {
183
+ return
184
+ }
185
+ // Detach handlers so a late onstop/onerror from the stalled recorder
186
+ // can't reach back in and fire finalize()/onError a second time.
187
+ this.detachRecorder()
188
+ if (this.chunks.length > 0) {
189
+ // onstop never fired, but ondataavailable already delivered the
190
+ // audio — finalize from the buffered chunks rather than discarding a
191
+ // recording the user successfully captured.
192
+ void this.finalize()
193
+ } else {
194
+ this.handleError(
195
+ new Error('Recording stop timed out after 10s with no audio'),
196
+ )
197
+ }
198
+ }, 10_000)
199
+ this.stopResolve = (rec) => {
200
+ clearTimeout(watchdog)
201
+ resolve(rec)
202
+ }
203
+ this.stopReject = (err) => {
204
+ clearTimeout(watchdog)
205
+ reject(err)
206
+ }
207
+ recorder.stop()
208
+ })
209
+ }
210
+
211
+ cancel(): void {
212
+ if (this.starting) {
213
+ // A start() is awaiting getUserMedia; flag it so the resolved stream is
214
+ // released instead of beginning a recording with no handle to stop it.
215
+ this.pendingCancel = true
216
+ return
217
+ }
218
+ if (this._state === 'idle') {
219
+ return
220
+ }
221
+ const recorder = this.recorder
222
+ if (recorder) {
223
+ // Detach handlers so finalize()/onError never run for a discarded
224
+ // recording.
225
+ this.detachRecorder()
226
+ try {
227
+ recorder.stop()
228
+ } catch (err) {
229
+ // Stopping an already-inactive recorder throws InvalidStateError —
230
+ // that's expected here. Anything else is unexpected; surface it rather
231
+ // than swallowing it silently.
232
+ if (
233
+ !(err instanceof DOMException && err.name === 'InvalidStateError')
234
+ ) {
235
+ this.notifyError(
236
+ err instanceof Error ? err : new Error('Failed to stop recorder'),
237
+ )
238
+ }
239
+ }
240
+ }
241
+ this.releaseStream()
242
+ this.recorder = null
243
+ this.chunks = []
244
+ const reject = this.stopReject
245
+ this.stopResolve = null
246
+ this.stopReject = null
247
+ this.setState('idle')
248
+ reject?.(new Error('Recording cancelled'))
249
+ }
250
+
251
+ private async finalize(): Promise<void> {
252
+ const mimeType = this.recorder?.mimeType || 'audio/webm'
253
+ const durationMs = Date.now() - this.startedAt
254
+ try {
255
+ const blob = new Blob(this.chunks, { type: mimeType })
256
+ const base64 = arrayBufferToBase64(await blob.arrayBuffer())
257
+ const recording: AudioRecording = {
258
+ blob,
259
+ base64,
260
+ mimeType,
261
+ durationMs,
262
+ part: {
263
+ type: 'audio',
264
+ source: { type: 'data', value: base64, mimeType },
265
+ },
266
+ }
267
+ this.releaseStream()
268
+ this.recorder = null
269
+ this.chunks = []
270
+ const resolve = this.stopResolve
271
+ this.stopResolve = null
272
+ this.stopReject = null
273
+ this.setState('idle')
274
+ resolve?.(recording)
275
+ } catch (err) {
276
+ this.handleError(
277
+ err instanceof Error ? err : new Error('Failed to finalize recording'),
278
+ )
279
+ }
280
+ }
281
+
282
+ private handleError(error: Error): void {
283
+ this.releaseStream()
284
+ this.recorder = null
285
+ this.chunks = []
286
+ const reject = this.stopReject
287
+ this.stopResolve = null
288
+ this.stopReject = null
289
+ this.setState('idle')
290
+ // Settle the pending stop() promise before invoking the user callback so a
291
+ // throwing onError can't strand the awaiter (the two error channels are
292
+ // independent — see start()/stop() docs).
293
+ reject?.(error)
294
+ this.notifyError(error)
295
+ }
296
+
297
+ /** Invoke the user onError callback, isolating a throw so it can't disrupt
298
+ * internal state teardown or strand a pending promise. */
299
+ private notifyError(error: Error): void {
300
+ try {
301
+ this.options.onError?.(error)
302
+ } catch {
303
+ // A user onError that throws must not propagate into recorder internals.
304
+ }
305
+ }
306
+
307
+ private releaseStream(): void {
308
+ this.stream?.getTracks().forEach((t) => t.stop())
309
+ this.stream = null
310
+ }
311
+
312
+ /** Detach all event handlers so a stale recorder can't mutate our state. */
313
+ private detachRecorder(): void {
314
+ const recorder = this.recorder
315
+ if (!recorder) {
316
+ return
317
+ }
318
+ recorder.onstop = null
319
+ recorder.onerror = null
320
+ recorder.ondataavailable = null
321
+ }
322
+ }
@@ -11,7 +11,29 @@ import type {
11
11
  // ===========================
12
12
 
13
13
  /**
14
- * Infers the output type from an `onResult` callback's return type.
14
+ * Maps an `onResult` transform's raw return type to the stored output type.
15
+ *
16
+ * - A concrete return (excluding null/void/undefined) becomes the output type.
17
+ * - A return of only null/void/undefined falls back to TResult (the transform
18
+ * reacted to the result or chose to keep it, rather than replacing it).
19
+ *
20
+ * Hooks infer `TReturn` directly from the `onResult` return position — a
21
+ * covariant inference site that works even for an optional nested property —
22
+ * which both contextually types the callback parameter as `TResult` and
23
+ * narrows `result`. See issue #848.
24
+ *
25
+ * @template TResult - The raw result type from the generation
26
+ * @template TReturn - The transform's return type (defaults to `void` when no
27
+ * transform is provided)
28
+ */
29
+ export type InferGenerationOutputFromReturn<TResult, TReturn> = [
30
+ Exclude<TReturn, null | void | undefined>,
31
+ ] extends [never]
32
+ ? TResult
33
+ : Exclude<TReturn, null | void | undefined>
34
+
35
+ /**
36
+ * Infers the output type from an `onResult` callback's type.
15
37
  *
16
38
  * - If the callback returns a concrete type (excluding null/void/undefined), uses that type.
17
39
  * - If the callback only returns null/void/undefined, or is not provided, falls back to TResult.
@@ -22,9 +44,7 @@ import type {
22
44
  export type InferGenerationOutput<TResult, TFn> = TFn extends (
23
45
  result: any,
24
46
  ) => infer R
25
- ? [Exclude<R, null | void | undefined>] extends [never]
26
- ? TResult
27
- : Exclude<R, null | void | undefined>
47
+ ? InferGenerationOutputFromReturn<TResult, R>
28
48
  : TResult
29
49
 
30
50
  // ===========================
package/src/index.ts CHANGED
@@ -1,4 +1,13 @@
1
+ export { AudioRecorder } from './audio-recorder'
2
+ export type {
3
+ AudioRecorderOptions,
4
+ AudioRecorderState,
5
+ AudioRecording,
6
+ InferAudioRecordingOutput,
7
+ } from './audio-recorder'
1
8
  export { ChatClient } from './chat-client'
9
+ export { createMcpAppBridge } from './mcp-app-bridge'
10
+ export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge'
2
11
  export { RealtimeClient } from './realtime-client'
3
12
  export { GenerationClient } from './generation-client'
4
13
  export { VideoGenerationClient } from './video-generation-client'
@@ -30,6 +39,7 @@ export type {
30
39
  // Generation client types
31
40
  export type {
32
41
  InferGenerationOutput,
42
+ InferGenerationOutputFromReturn,
33
43
  GenerationClientState,
34
44
  GenerationClientOptions,
35
45
  GenerationFetcher,
@@ -0,0 +1,120 @@
1
+ export interface CreateMcpAppBridgeOptions {
2
+ threadId: string
3
+ callEndpoint: string
4
+ chat: {
5
+ sendMessage: (
6
+ content: string,
7
+ body?: Record<string, unknown>,
8
+ ) => Promise<void>
9
+ }
10
+ fetchImpl?: typeof fetch
11
+ onLink?: (url: string) => void
12
+ }
13
+
14
+ export interface McpAppBridge {
15
+ callTool: (input: {
16
+ serverId?: string
17
+ toolName: string
18
+ args?: Record<string, unknown>
19
+ /**
20
+ * Reserved — forwarded to the call handler for correlation purposes but
21
+ * not consumed by the handler. Accepted on the wire; the handler does not
22
+ * read it (mirrors the `meta` convention on `UIResourcePart`).
23
+ */
24
+ messageId?: string
25
+ }) => Promise<unknown>
26
+ sendPrompt: (text: string) => Promise<void>
27
+ openLink: (url: string) => { isError: boolean }
28
+ }
29
+
30
+ interface ToolCallResponse {
31
+ ok: boolean
32
+ result?: unknown
33
+ error?: string
34
+ }
35
+
36
+ function isToolCallResponse(value: unknown): value is ToolCallResponse {
37
+ return (
38
+ value !== null &&
39
+ typeof value === 'object' &&
40
+ 'ok' in value &&
41
+ typeof value.ok === 'boolean'
42
+ )
43
+ }
44
+
45
+ // Links arrive from an untrusted sandboxed widget. Only hand http(s)/mailto
46
+ // URLs to the host's onLink; reject javascript:/data:/file:/etc. so a widget
47
+ // can't smuggle a script-executing or local-resource URL through the bridge.
48
+ const SAFE_LINK_SCHEMES = new Set(['http:', 'https:', 'mailto:'])
49
+ function isSafeLink(url: string): boolean {
50
+ try {
51
+ return SAFE_LINK_SCHEMES.has(new URL(url).protocol)
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ export function createMcpAppBridge(
58
+ options: CreateMcpAppBridgeOptions,
59
+ ): McpAppBridge {
60
+ const { threadId, callEndpoint, chat, fetchImpl, onLink } = options
61
+ const doFetch = fetchImpl ?? fetch
62
+
63
+ return {
64
+ async callTool(input) {
65
+ const response = await doFetch(callEndpoint, {
66
+ method: 'POST',
67
+ headers: { 'content-type': 'application/json' },
68
+ body: JSON.stringify({
69
+ threadId,
70
+ serverId: input.serverId,
71
+ toolName: input.toolName,
72
+ args: input.args,
73
+ messageId: input.messageId,
74
+ }),
75
+ })
76
+
77
+ if (!response.ok) {
78
+ throw new Error(`MCP app tool call failed: HTTP ${response.status}`)
79
+ }
80
+
81
+ const raw: unknown = await response.json()
82
+ if (!isToolCallResponse(raw)) {
83
+ throw new Error('MCP app tool call failed')
84
+ }
85
+
86
+ if (!raw.ok) {
87
+ throw new Error(raw.error ?? 'MCP app tool call failed')
88
+ }
89
+
90
+ return raw.result
91
+ },
92
+
93
+ async sendPrompt(text) {
94
+ await chat.sendMessage(text)
95
+ },
96
+
97
+ openLink(url) {
98
+ if (!isSafeLink(url)) {
99
+ console.warn(
100
+ '[mcp-app-bridge] openLink rejected: unsupported URL scheme',
101
+ url,
102
+ )
103
+ return { isError: true }
104
+ }
105
+ if (onLink) {
106
+ try {
107
+ onLink(url)
108
+ return { isError: false }
109
+ } catch (err) {
110
+ console.warn('[mcp-app-bridge] openLink: onLink handler threw', err)
111
+ return { isError: true }
112
+ }
113
+ }
114
+ console.warn(
115
+ '[mcp-app-bridge] openLink ignored: no onLink handler configured',
116
+ )
117
+ return { isError: true }
118
+ },
119
+ }
120
+ }
package/src/types.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  ModelMessage,
11
11
  StreamChunk,
12
12
  StructuredOutputPart,
13
+ UIResourcePart,
13
14
  VideoPart,
14
15
  } from '@tanstack/ai/client'
15
16
  import type { ConnectionAdapter } from './connection-adapters'
@@ -244,6 +245,7 @@ export type MessagePart<
244
245
  | ToolResultPart
245
246
  | ThinkingPart
246
247
  | StructuredOutputPart<TData>
248
+ | UIResourcePart
247
249
 
248
250
  /**
249
251
  * UIMessage - Domain-specific message format optimized for building chat UIs