@tanstack/ai-client 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ import { ConnectionAdapter } from './connection-adapters.js';
2
+ import { GenerationClientState, GenerationFetcher, VideoGenerateInput, VideoGenerateResult, VideoGenerationClientOptions, VideoStatusInfo } from './generation-types.js';
3
+ /**
4
+ * A specialized client for job-based video generation.
5
+ *
6
+ * Video generation is asynchronous: a job is created, then polled for status
7
+ * until completion. This client handles the full lifecycle.
8
+ *
9
+ * Supports two transport modes:
10
+ * - **ConnectionAdapter** — Server handles the polling loop internally and
11
+ * streams status updates via CUSTOM events.
12
+ * - **Fetcher** — Direct async function that returns a completed
13
+ * `VideoGenerateResult`.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // With ConnectionAdapter (server-driven polling)
18
+ * const client = new VideoGenerationClient({
19
+ * connection: fetchServerSentEvents('/api/generate/video'),
20
+ * onResultChange: setResult,
21
+ * onVideoStatusChange: setVideoStatus,
22
+ * })
23
+ *
24
+ * // With fetcher (direct result)
25
+ * const client = new VideoGenerationClient({
26
+ * fetcher: async (input) => {
27
+ * const res = await fetch('/api/video/generate', {
28
+ * method: 'POST',
29
+ * body: JSON.stringify(input),
30
+ * })
31
+ * return res.json() // { jobId, status: 'completed', url, expiresAt }
32
+ * },
33
+ * })
34
+ *
35
+ * await client.generate({ prompt: 'A flying car over a city' })
36
+ * ```
37
+ */
38
+ export declare class VideoGenerationClient<TOutput = VideoGenerateResult> {
39
+ private connection;
40
+ private fetcher;
41
+ private body;
42
+ private result;
43
+ private jobId;
44
+ private videoStatus;
45
+ private isLoading;
46
+ private error;
47
+ private status;
48
+ private abortController;
49
+ private callbacksRef;
50
+ constructor(options: VideoGenerationClientOptions<TOutput> & ({
51
+ connection: ConnectionAdapter;
52
+ fetcher?: never;
53
+ } | {
54
+ fetcher: GenerationFetcher<VideoGenerateInput, VideoGenerateResult>;
55
+ connection?: never;
56
+ }));
57
+ /**
58
+ * Trigger video generation.
59
+ * Only one generation can be in-flight at a time.
60
+ */
61
+ generate(input: VideoGenerateInput): Promise<void>;
62
+ /**
63
+ * Direct fetcher mode: call fetcher and set result.
64
+ */
65
+ private generateWithFetcher;
66
+ /**
67
+ * Process a stream of AG-UI events from the ConnectionAdapter.
68
+ * The server handles the polling loop and streams status updates.
69
+ */
70
+ private processStream;
71
+ /**
72
+ * Abort any in-flight generation or polling.
73
+ */
74
+ stop(): void;
75
+ /**
76
+ * Clear all state and return to idle.
77
+ */
78
+ reset(): void;
79
+ /**
80
+ * Update options without recreating the client.
81
+ */
82
+ updateOptions(options: Partial<Pick<VideoGenerationClientOptions<TOutput>, 'body' | 'onResult' | 'onError' | 'onProgress' | 'onChunk' | 'onJobCreated' | 'onStatusUpdate'>>): void;
83
+ getResult(): TOutput | null;
84
+ getJobId(): string | null;
85
+ getVideoStatus(): VideoStatusInfo | null;
86
+ getIsLoading(): boolean;
87
+ getError(): Error | undefined;
88
+ getStatus(): GenerationClientState;
89
+ private setResult;
90
+ private setJobId;
91
+ private setVideoStatus;
92
+ private setIsLoading;
93
+ private setError;
94
+ private setStatus;
95
+ }
@@ -0,0 +1,238 @@
1
+ import { GENERATION_EVENTS } from "./generation-types.js";
2
+ import { parseSSEResponse } from "./sse-parser.js";
3
+ class VideoGenerationClient {
4
+ constructor(options) {
5
+ this.result = null;
6
+ this.jobId = null;
7
+ this.videoStatus = null;
8
+ this.isLoading = false;
9
+ this.error = void 0;
10
+ this.status = "idle";
11
+ this.abortController = null;
12
+ this.connection = options.connection;
13
+ this.fetcher = options.fetcher;
14
+ this.body = options.body ?? {};
15
+ this.callbacksRef = {
16
+ onResult: options.onResult,
17
+ onError: options.onError,
18
+ onProgress: options.onProgress,
19
+ onChunk: options.onChunk,
20
+ onJobCreated: options.onJobCreated,
21
+ onStatusUpdate: options.onStatusUpdate,
22
+ onResultChange: options.onResultChange,
23
+ onLoadingChange: options.onLoadingChange,
24
+ onErrorChange: options.onErrorChange,
25
+ onStatusChange: options.onStatusChange,
26
+ onJobIdChange: options.onJobIdChange,
27
+ onVideoStatusChange: options.onVideoStatusChange
28
+ };
29
+ }
30
+ /**
31
+ * Trigger video generation.
32
+ * Only one generation can be in-flight at a time.
33
+ */
34
+ async generate(input) {
35
+ if (this.isLoading) return;
36
+ this.setIsLoading(true);
37
+ this.setStatus("generating");
38
+ this.setError(void 0);
39
+ this.setJobId(null);
40
+ this.setVideoStatus(null);
41
+ const abortController = new AbortController();
42
+ this.abortController = abortController;
43
+ const { signal } = abortController;
44
+ try {
45
+ if (this.fetcher) {
46
+ await this.generateWithFetcher(input, signal);
47
+ } else if (this.connection) {
48
+ const mergedData = { ...this.body, ...input };
49
+ const stream = this.connection.connect([], mergedData, signal);
50
+ await this.processStream(stream);
51
+ } else {
52
+ throw new Error(
53
+ "VideoGenerationClient requires either a connection or fetcher option"
54
+ );
55
+ }
56
+ } catch (err) {
57
+ if (signal.aborted) return;
58
+ const error = err instanceof Error ? err : new Error(String(err));
59
+ this.setError(error);
60
+ this.setStatus("error");
61
+ this.callbacksRef.onError?.(error);
62
+ } finally {
63
+ this.abortController = null;
64
+ this.setIsLoading(false);
65
+ }
66
+ }
67
+ /**
68
+ * Direct fetcher mode: call fetcher and set result.
69
+ */
70
+ async generateWithFetcher(input, signal) {
71
+ if (!this.fetcher) return;
72
+ const result = await this.fetcher(input, { signal });
73
+ if (signal.aborted) return;
74
+ if (result instanceof Response) {
75
+ await this.processStream(parseSSEResponse(result, signal));
76
+ } else {
77
+ this.setResult(result);
78
+ this.setStatus("success");
79
+ }
80
+ }
81
+ /**
82
+ * Process a stream of AG-UI events from the ConnectionAdapter.
83
+ * The server handles the polling loop and streams status updates.
84
+ */
85
+ async processStream(source) {
86
+ for await (const chunk of source) {
87
+ if (this.abortController?.signal.aborted) break;
88
+ this.callbacksRef.onChunk?.(chunk);
89
+ switch (chunk.type) {
90
+ case "CUSTOM": {
91
+ if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) {
92
+ const { jobId } = chunk.value;
93
+ this.setJobId(jobId);
94
+ this.callbacksRef.onJobCreated?.(jobId);
95
+ } else if (chunk.name === GENERATION_EVENTS.VIDEO_STATUS) {
96
+ const statusInfo = chunk.value;
97
+ this.setVideoStatus(statusInfo);
98
+ this.callbacksRef.onStatusUpdate?.(statusInfo);
99
+ if (statusInfo.progress !== void 0) {
100
+ this.callbacksRef.onProgress?.(statusInfo.progress);
101
+ }
102
+ } else if (chunk.name === GENERATION_EVENTS.RESULT) {
103
+ this.setResult(chunk.value);
104
+ } else if (chunk.name === GENERATION_EVENTS.PROGRESS) {
105
+ const { progress, message } = chunk.value;
106
+ this.callbacksRef.onProgress?.(progress, message);
107
+ }
108
+ break;
109
+ }
110
+ case "RUN_FINISHED": {
111
+ this.setStatus("success");
112
+ break;
113
+ }
114
+ case "RUN_ERROR": {
115
+ throw new Error(chunk.error.message);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ /**
121
+ * Abort any in-flight generation or polling.
122
+ */
123
+ stop() {
124
+ if (this.abortController) {
125
+ this.abortController.abort();
126
+ this.abortController = null;
127
+ }
128
+ this.setIsLoading(false);
129
+ if (this.status === "generating") {
130
+ this.setStatus("idle");
131
+ }
132
+ }
133
+ /**
134
+ * Clear all state and return to idle.
135
+ */
136
+ reset() {
137
+ this.stop();
138
+ this.setResult(null);
139
+ this.setJobId(null);
140
+ this.setVideoStatus(null);
141
+ this.setError(void 0);
142
+ this.setStatus("idle");
143
+ }
144
+ /**
145
+ * Update options without recreating the client.
146
+ */
147
+ updateOptions(options) {
148
+ if (options.body !== void 0) {
149
+ this.body = options.body ?? {};
150
+ }
151
+ if (options.onResult !== void 0) {
152
+ this.callbacksRef.onResult = options.onResult;
153
+ }
154
+ if (options.onError !== void 0) {
155
+ this.callbacksRef.onError = options.onError;
156
+ }
157
+ if (options.onProgress !== void 0) {
158
+ this.callbacksRef.onProgress = options.onProgress;
159
+ }
160
+ if (options.onChunk !== void 0) {
161
+ this.callbacksRef.onChunk = options.onChunk;
162
+ }
163
+ if (options.onJobCreated !== void 0) {
164
+ this.callbacksRef.onJobCreated = options.onJobCreated;
165
+ }
166
+ if (options.onStatusUpdate !== void 0) {
167
+ this.callbacksRef.onStatusUpdate = options.onStatusUpdate;
168
+ }
169
+ }
170
+ // ===========================
171
+ // Getters
172
+ // ===========================
173
+ getResult() {
174
+ return this.result;
175
+ }
176
+ getJobId() {
177
+ return this.jobId;
178
+ }
179
+ getVideoStatus() {
180
+ return this.videoStatus;
181
+ }
182
+ getIsLoading() {
183
+ return this.isLoading;
184
+ }
185
+ getError() {
186
+ return this.error;
187
+ }
188
+ getStatus() {
189
+ return this.status;
190
+ }
191
+ // ===========================
192
+ // Private state setters
193
+ // ===========================
194
+ setResult(rawResult) {
195
+ if (rawResult === null) {
196
+ this.result = null;
197
+ this.callbacksRef.onResultChange?.(null);
198
+ return;
199
+ }
200
+ if (this.callbacksRef.onResult) {
201
+ const transformed = this.callbacksRef.onResult(rawResult);
202
+ if (transformed === null) {
203
+ return;
204
+ }
205
+ if (transformed !== void 0) {
206
+ this.result = transformed;
207
+ this.callbacksRef.onResultChange?.(this.result);
208
+ return;
209
+ }
210
+ }
211
+ this.result = rawResult;
212
+ this.callbacksRef.onResultChange?.(this.result);
213
+ }
214
+ setJobId(jobId) {
215
+ this.jobId = jobId;
216
+ this.callbacksRef.onJobIdChange?.(jobId);
217
+ }
218
+ setVideoStatus(status) {
219
+ this.videoStatus = status;
220
+ this.callbacksRef.onVideoStatusChange?.(status);
221
+ }
222
+ setIsLoading(isLoading) {
223
+ this.isLoading = isLoading;
224
+ this.callbacksRef.onLoadingChange?.(isLoading);
225
+ }
226
+ setError(error) {
227
+ this.error = error;
228
+ this.callbacksRef.onErrorChange?.(error);
229
+ }
230
+ setStatus(status) {
231
+ this.status = status;
232
+ this.callbacksRef.onStatusChange?.(status);
233
+ }
234
+ }
235
+ export {
236
+ VideoGenerationClient
237
+ };
238
+ //# sourceMappingURL=video-generation-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"video-generation-client.js","sources":["../../src/video-generation-client.ts"],"sourcesContent":["import { GENERATION_EVENTS } from './generation-types'\nimport { parseSSEResponse } from './sse-parser'\nimport type { StreamChunk } from '@tanstack/ai'\nimport type { ConnectionAdapter } from './connection-adapters'\nimport type {\n GenerationClientState,\n GenerationFetcher,\n VideoGenerateInput,\n VideoGenerateResult,\n VideoGenerationClientOptions,\n VideoStatusInfo,\n} from './generation-types'\n\n/**\n * Callbacks stored in a ref so hooks can update them without recreating the client.\n */\ninterface VideoCallbacks<TOutput> {\n onResult?: (result: VideoGenerateResult) => TOutput | null | void\n onError?: (error: Error) => void\n onProgress?: (progress: number, message?: string) => void\n onChunk?: (chunk: StreamChunk) => void\n onJobCreated?: (jobId: string) => void\n onStatusUpdate?: (status: VideoStatusInfo) => void\n onResultChange?: (result: TOutput | null) => void\n onLoadingChange?: (isLoading: boolean) => void\n onErrorChange?: (error: Error | undefined) => void\n onStatusChange?: (status: GenerationClientState) => void\n onJobIdChange?: (jobId: string | null) => void\n onVideoStatusChange?: (status: VideoStatusInfo | null) => void\n}\n\n/**\n * A specialized client for job-based video generation.\n *\n * Video generation is asynchronous: a job is created, then polled for status\n * until completion. This client handles the full lifecycle.\n *\n * Supports two transport modes:\n * - **ConnectionAdapter** — Server handles the polling loop internally and\n * streams status updates via CUSTOM events.\n * - **Fetcher** — Direct async function that returns a completed\n * `VideoGenerateResult`.\n *\n * @example\n * ```typescript\n * // With ConnectionAdapter (server-driven polling)\n * const client = new VideoGenerationClient({\n * connection: fetchServerSentEvents('/api/generate/video'),\n * onResultChange: setResult,\n * onVideoStatusChange: setVideoStatus,\n * })\n *\n * // With fetcher (direct result)\n * const client = new VideoGenerationClient({\n * fetcher: async (input) => {\n * const res = await fetch('/api/video/generate', {\n * method: 'POST',\n * body: JSON.stringify(input),\n * })\n * return res.json() // { jobId, status: 'completed', url, expiresAt }\n * },\n * })\n *\n * await client.generate({ prompt: 'A flying car over a city' })\n * ```\n */\nexport class VideoGenerationClient<TOutput = VideoGenerateResult> {\n private connection: ConnectionAdapter | undefined\n private fetcher:\n | GenerationFetcher<VideoGenerateInput, VideoGenerateResult>\n | undefined\n private body: Record<string, any>\n\n private result: TOutput | null = null\n private jobId: string | null = null\n private videoStatus: VideoStatusInfo | null = null\n private isLoading = false\n private error: Error | undefined = undefined\n private status: GenerationClientState = 'idle'\n private abortController: AbortController | null = null\n private callbacksRef: VideoCallbacks<TOutput>\n\n constructor(\n options: VideoGenerationClientOptions<TOutput> &\n (\n | { connection: ConnectionAdapter; fetcher?: never }\n | {\n fetcher: GenerationFetcher<VideoGenerateInput, VideoGenerateResult>\n connection?: never\n }\n ),\n ) {\n this.connection = options.connection\n this.fetcher = options.fetcher\n this.body = options.body ?? {}\n\n this.callbacksRef = {\n onResult: options.onResult,\n onError: options.onError,\n onProgress: options.onProgress,\n onChunk: options.onChunk,\n onJobCreated: options.onJobCreated,\n onStatusUpdate: options.onStatusUpdate,\n onResultChange: options.onResultChange,\n onLoadingChange: options.onLoadingChange,\n onErrorChange: options.onErrorChange,\n onStatusChange: options.onStatusChange,\n onJobIdChange: options.onJobIdChange,\n onVideoStatusChange: options.onVideoStatusChange,\n }\n }\n\n /**\n * Trigger video generation.\n * Only one generation can be in-flight at a time.\n */\n async generate(input: VideoGenerateInput): Promise<void> {\n if (this.isLoading) return\n\n this.setIsLoading(true)\n this.setStatus('generating')\n this.setError(undefined)\n this.setJobId(null)\n this.setVideoStatus(null)\n\n const abortController = new AbortController()\n this.abortController = abortController\n const { signal } = abortController\n\n try {\n if (this.fetcher) {\n await this.generateWithFetcher(input, signal)\n } else if (this.connection) {\n const mergedData = { ...this.body, ...input }\n const stream = this.connection.connect([], mergedData, signal)\n await this.processStream(stream)\n } else {\n throw new Error(\n 'VideoGenerationClient requires either a connection or fetcher option',\n )\n }\n } catch (err: any) {\n if (signal.aborted) return\n const error = err instanceof Error ? err : new Error(String(err))\n this.setError(error)\n this.setStatus('error')\n this.callbacksRef.onError?.(error)\n } finally {\n this.abortController = null\n this.setIsLoading(false)\n }\n }\n\n /**\n * Direct fetcher mode: call fetcher and set result.\n */\n private async generateWithFetcher(\n input: VideoGenerateInput,\n signal: AbortSignal,\n ): Promise<void> {\n if (!this.fetcher) return\n\n // Fetcher returns a completed result directly, or a Response with SSE body\n const result = await this.fetcher(input, { signal })\n if (signal.aborted) return\n\n if (result instanceof Response) {\n // Server function returned SSE Response — parse stream\n await this.processStream(parseSSEResponse(result, signal))\n } else {\n this.setResult(result)\n this.setStatus('success')\n }\n }\n\n /**\n * Process a stream of AG-UI events from the ConnectionAdapter.\n * The server handles the polling loop and streams status updates.\n */\n private async processStream(\n source: AsyncIterable<StreamChunk>,\n ): Promise<void> {\n for await (const chunk of source) {\n if (this.abortController?.signal.aborted) break\n\n this.callbacksRef.onChunk?.(chunk)\n\n switch (chunk.type) {\n case 'CUSTOM': {\n if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) {\n const { jobId } = chunk.value as { jobId: string }\n this.setJobId(jobId)\n this.callbacksRef.onJobCreated?.(jobId)\n } else if (chunk.name === GENERATION_EVENTS.VIDEO_STATUS) {\n const statusInfo = chunk.value as VideoStatusInfo\n this.setVideoStatus(statusInfo)\n this.callbacksRef.onStatusUpdate?.(statusInfo)\n if (statusInfo.progress !== undefined) {\n this.callbacksRef.onProgress?.(statusInfo.progress)\n }\n } else if (chunk.name === GENERATION_EVENTS.RESULT) {\n this.setResult(chunk.value as VideoGenerateResult)\n } else if (chunk.name === GENERATION_EVENTS.PROGRESS) {\n const { progress, message } = chunk.value as {\n progress: number\n message?: string\n }\n this.callbacksRef.onProgress?.(progress, message)\n }\n break\n }\n case 'RUN_FINISHED': {\n this.setStatus('success')\n break\n }\n case 'RUN_ERROR': {\n throw new Error(chunk.error.message)\n }\n }\n }\n }\n\n /**\n * Abort any in-flight generation or polling.\n */\n stop(): void {\n if (this.abortController) {\n this.abortController.abort()\n this.abortController = null\n }\n this.setIsLoading(false)\n if (this.status === 'generating') {\n this.setStatus('idle')\n }\n }\n\n /**\n * Clear all state and return to idle.\n */\n reset(): void {\n this.stop()\n this.setResult(null)\n this.setJobId(null)\n this.setVideoStatus(null)\n this.setError(undefined)\n this.setStatus('idle')\n }\n\n /**\n * Update options without recreating the client.\n */\n updateOptions(\n options: Partial<\n Pick<\n VideoGenerationClientOptions<TOutput>,\n | 'body'\n | 'onResult'\n | 'onError'\n | 'onProgress'\n | 'onChunk'\n | 'onJobCreated'\n | 'onStatusUpdate'\n >\n >,\n ): void {\n if (options.body !== undefined) {\n this.body = options.body ?? {}\n }\n if (options.onResult !== undefined) {\n this.callbacksRef.onResult = options.onResult\n }\n if (options.onError !== undefined) {\n this.callbacksRef.onError = options.onError\n }\n if (options.onProgress !== undefined) {\n this.callbacksRef.onProgress = options.onProgress\n }\n if (options.onChunk !== undefined) {\n this.callbacksRef.onChunk = options.onChunk\n }\n if (options.onJobCreated !== undefined) {\n this.callbacksRef.onJobCreated = options.onJobCreated\n }\n if (options.onStatusUpdate !== undefined) {\n this.callbacksRef.onStatusUpdate = options.onStatusUpdate\n }\n }\n\n // ===========================\n // Getters\n // ===========================\n\n getResult(): TOutput | null {\n return this.result\n }\n\n getJobId(): string | null {\n return this.jobId\n }\n\n getVideoStatus(): VideoStatusInfo | null {\n return this.videoStatus\n }\n\n getIsLoading(): boolean {\n return this.isLoading\n }\n\n getError(): Error | undefined {\n return this.error\n }\n\n getStatus(): GenerationClientState {\n return this.status\n }\n\n // ===========================\n // Private state setters\n // ===========================\n\n private setResult(rawResult: VideoGenerateResult | null): void {\n if (rawResult === null) {\n this.result = null\n this.callbacksRef.onResultChange?.(null)\n return\n }\n\n if (this.callbacksRef.onResult) {\n const transformed = this.callbacksRef.onResult(rawResult)\n if (transformed === null) {\n // null return → keep previous result unchanged\n return\n }\n if (transformed !== undefined) {\n // Non-null, non-undefined → use transformed value\n this.result = transformed\n this.callbacksRef.onResultChange?.(this.result)\n return\n }\n }\n\n // No onResult callback, or callback returned void → use raw value\n this.result = rawResult as unknown as TOutput\n this.callbacksRef.onResultChange?.(this.result)\n }\n\n private setJobId(jobId: string | null): void {\n this.jobId = jobId\n this.callbacksRef.onJobIdChange?.(jobId)\n }\n\n private setVideoStatus(status: VideoStatusInfo | null): void {\n this.videoStatus = status\n this.callbacksRef.onVideoStatusChange?.(status)\n }\n\n private setIsLoading(isLoading: boolean): void {\n this.isLoading = isLoading\n this.callbacksRef.onLoadingChange?.(isLoading)\n }\n\n private setError(error: Error | undefined): void {\n this.error = error\n this.callbacksRef.onErrorChange?.(error)\n }\n\n private setStatus(status: GenerationClientState): void {\n this.status = status\n this.callbacksRef.onStatusChange?.(status)\n }\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-client",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Framework-agnostic headless client for TanStack AI",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "src"
32
32
  ],
33
33
  "dependencies": {
34
- "@tanstack/ai": "0.6.1"
34
+ "@tanstack/ai": "0.6.3"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@vitest/coverage-v8": "4.0.14",
@@ -0,0 +1,302 @@
1
+ import { GENERATION_EVENTS } from './generation-types'
2
+ import { parseSSEResponse } from './sse-parser'
3
+ import type { StreamChunk } from '@tanstack/ai'
4
+ import type { ConnectionAdapter } from './connection-adapters'
5
+ import type {
6
+ GenerationClientOptions,
7
+ GenerationClientState,
8
+ GenerationFetcher,
9
+ } from './generation-types'
10
+
11
+ /**
12
+ * Callbacks stored in a ref so hooks can update them without recreating the client.
13
+ */
14
+ interface GenerationCallbacks<TResult, TOutput> {
15
+ onResult?: (result: TResult) => TOutput | null | void
16
+ onError?: (error: Error) => void
17
+ onProgress?: (progress: number, message?: string) => void
18
+ onChunk?: (chunk: StreamChunk) => void
19
+ onResultChange?: (result: TOutput | null) => void
20
+ onLoadingChange?: (isLoading: boolean) => void
21
+ onErrorChange?: (error: Error | undefined) => void
22
+ onStatusChange?: (status: GenerationClientState) => void
23
+ }
24
+
25
+ /**
26
+ * A lightweight, generic client for one-shot generation tasks
27
+ * (image, speech, transcription, summarize).
28
+ *
29
+ * Supports two transport modes:
30
+ * - **ConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom).
31
+ * Server wraps results in StreamChunk events with CUSTOM event names.
32
+ * - **Fetcher** — Direct async function call. No streaming protocol needed.
33
+ *
34
+ * @template TInput - The input type for the generation request
35
+ * @template TResult - The result type returned by the generation
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * // With ConnectionAdapter (streaming)
40
+ * const client = new GenerationClient<ImageGenerateInput, ImageGenerationResult>({
41
+ * connection: fetchServerSentEvents('/api/generate/image'),
42
+ * onResultChange: setResult,
43
+ * onLoadingChange: setIsLoading,
44
+ * })
45
+ *
46
+ * // With fetcher (direct)
47
+ * const client = new GenerationClient<ImageGenerateInput, ImageGenerationResult>({
48
+ * fetcher: async (input) => {
49
+ * const res = await fetch('/api/generate/image', {
50
+ * method: 'POST',
51
+ * body: JSON.stringify(input),
52
+ * })
53
+ * return res.json()
54
+ * },
55
+ * })
56
+ *
57
+ * await client.generate({ prompt: 'A sunset over mountains' })
58
+ * ```
59
+ */
60
+ export class GenerationClient<
61
+ TInput extends Record<string, any>,
62
+ TResult,
63
+ TOutput = TResult,
64
+ > {
65
+ private connection: ConnectionAdapter | undefined
66
+ private fetcher: GenerationFetcher<TInput, TResult> | undefined
67
+ private body: Record<string, any>
68
+ private result: TOutput | null = null
69
+ private isLoading = false
70
+ private error: Error | undefined = undefined
71
+ private status: GenerationClientState = 'idle'
72
+ private abortController: AbortController | null = null
73
+ private callbacksRef: GenerationCallbacks<TResult, TOutput>
74
+
75
+ constructor(
76
+ options: GenerationClientOptions<TInput, TResult, TOutput> &
77
+ (
78
+ | { connection: ConnectionAdapter; fetcher?: never }
79
+ | {
80
+ fetcher: GenerationFetcher<TInput, TResult>
81
+ connection?: never
82
+ }
83
+ ),
84
+ ) {
85
+ this.connection = options.connection
86
+ this.fetcher = options.fetcher
87
+ this.body = options.body ?? {}
88
+
89
+ this.callbacksRef = {
90
+ onResult: options.onResult,
91
+ onError: options.onError,
92
+ onProgress: options.onProgress,
93
+ onChunk: options.onChunk,
94
+ onResultChange: options.onResultChange,
95
+ onLoadingChange: options.onLoadingChange,
96
+ onErrorChange: options.onErrorChange,
97
+ onStatusChange: options.onStatusChange,
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Trigger a generation request.
103
+ * Only one generation can be in-flight at a time; calling generate()
104
+ * while already generating will be a no-op.
105
+ */
106
+ async generate(input: TInput): Promise<void> {
107
+ if (this.isLoading) return
108
+
109
+ this.setIsLoading(true)
110
+ this.setStatus('generating')
111
+ this.setError(undefined)
112
+
113
+ const abortController = new AbortController()
114
+ this.abortController = abortController
115
+ const { signal } = abortController
116
+
117
+ try {
118
+ if (this.fetcher) {
119
+ // Direct fetch path
120
+ const result = await this.fetcher(input, { signal })
121
+ if (signal.aborted) return
122
+ if (result instanceof Response) {
123
+ // Server function returned SSE Response — parse stream
124
+ await this.processStream(parseSSEResponse(result, signal))
125
+ } else {
126
+ this.setResult(result)
127
+ this.setStatus('success')
128
+ }
129
+ } else if (this.connection) {
130
+ // ConnectionAdapter streaming path
131
+ const mergedData = { ...this.body, ...input }
132
+ const stream = this.connection.connect([], mergedData, signal)
133
+ await this.processStream(stream)
134
+ } else {
135
+ throw new Error(
136
+ 'GenerationClient requires either a connection or fetcher option',
137
+ )
138
+ }
139
+ } catch (err: any) {
140
+ if (signal.aborted) return
141
+ const error = err instanceof Error ? err : new Error(String(err))
142
+ this.setError(error)
143
+ this.setStatus('error')
144
+ this.callbacksRef.onError?.(error)
145
+ } finally {
146
+ this.abortController = null
147
+ this.setIsLoading(false)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Process a stream of AG-UI events from the ConnectionAdapter.
153
+ */
154
+ private async processStream(
155
+ source: AsyncIterable<StreamChunk>,
156
+ ): Promise<void> {
157
+ for await (const chunk of source) {
158
+ if (this.abortController?.signal.aborted) break
159
+
160
+ this.callbacksRef.onChunk?.(chunk)
161
+
162
+ switch (chunk.type) {
163
+ case 'CUSTOM': {
164
+ if (chunk.name === GENERATION_EVENTS.RESULT) {
165
+ this.setResult(chunk.value as TResult)
166
+ } else if (chunk.name === GENERATION_EVENTS.PROGRESS) {
167
+ const { progress, message } = chunk.value as {
168
+ progress: number
169
+ message?: string
170
+ }
171
+ this.callbacksRef.onProgress?.(progress, message)
172
+ }
173
+ break
174
+ }
175
+ case 'RUN_FINISHED': {
176
+ this.setStatus('success')
177
+ break
178
+ }
179
+ case 'RUN_ERROR': {
180
+ throw new Error(chunk.error.message)
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Abort any in-flight generation request.
188
+ */
189
+ stop(): void {
190
+ if (this.abortController) {
191
+ this.abortController.abort()
192
+ this.abortController = null
193
+ }
194
+ this.setIsLoading(false)
195
+ if (this.status === 'generating') {
196
+ this.setStatus('idle')
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Clear the result, error, and return to idle state.
202
+ */
203
+ reset(): void {
204
+ this.stop()
205
+ this.setResult(null)
206
+ this.setError(undefined)
207
+ this.setStatus('idle')
208
+ }
209
+
210
+ /**
211
+ * Update options without recreating the client.
212
+ */
213
+ updateOptions(
214
+ options: Partial<
215
+ Pick<
216
+ GenerationClientOptions<TInput, TResult, TOutput>,
217
+ 'body' | 'onResult' | 'onError' | 'onProgress' | 'onChunk'
218
+ >
219
+ >,
220
+ ): void {
221
+ if (options.body !== undefined) {
222
+ this.body = options.body ?? {}
223
+ }
224
+ if (options.onResult !== undefined) {
225
+ this.callbacksRef.onResult = options.onResult
226
+ }
227
+ if (options.onError !== undefined) {
228
+ this.callbacksRef.onError = options.onError
229
+ }
230
+ if (options.onProgress !== undefined) {
231
+ this.callbacksRef.onProgress = options.onProgress
232
+ }
233
+ if (options.onChunk !== undefined) {
234
+ this.callbacksRef.onChunk = options.onChunk
235
+ }
236
+ }
237
+
238
+ // ===========================
239
+ // Getters
240
+ // ===========================
241
+
242
+ getResult(): TOutput | null {
243
+ return this.result
244
+ }
245
+
246
+ getIsLoading(): boolean {
247
+ return this.isLoading
248
+ }
249
+
250
+ getError(): Error | undefined {
251
+ return this.error
252
+ }
253
+
254
+ getStatus(): GenerationClientState {
255
+ return this.status
256
+ }
257
+
258
+ // ===========================
259
+ // Private state setters
260
+ // ===========================
261
+
262
+ private setResult(rawResult: TResult | null): void {
263
+ if (rawResult === null) {
264
+ this.result = null
265
+ this.callbacksRef.onResultChange?.(null)
266
+ return
267
+ }
268
+
269
+ if (this.callbacksRef.onResult) {
270
+ const transformed = this.callbacksRef.onResult(rawResult)
271
+ if (transformed === null) {
272
+ // null return → keep previous result unchanged
273
+ return
274
+ }
275
+ if (transformed !== undefined) {
276
+ // Non-null, non-undefined → use transformed value
277
+ this.result = transformed
278
+ this.callbacksRef.onResultChange?.(this.result)
279
+ return
280
+ }
281
+ }
282
+
283
+ // No onResult callback, or callback returned void → use raw value
284
+ this.result = rawResult as unknown as TOutput
285
+ this.callbacksRef.onResultChange?.(this.result)
286
+ }
287
+
288
+ private setIsLoading(isLoading: boolean): void {
289
+ this.isLoading = isLoading
290
+ this.callbacksRef.onLoadingChange?.(isLoading)
291
+ }
292
+
293
+ private setError(error: Error | undefined): void {
294
+ this.error = error
295
+ this.callbacksRef.onErrorChange?.(error)
296
+ }
297
+
298
+ private setStatus(status: GenerationClientState): void {
299
+ this.status = status
300
+ this.callbacksRef.onStatusChange?.(status)
301
+ }
302
+ }