@tanstack/ai 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/activities/chat/index.d.ts +20 -0
- package/dist/esm/activities/chat/index.js +248 -213
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/middleware/compose.d.ts +66 -0
- package/dist/esm/activities/chat/middleware/compose.js +327 -0
- package/dist/esm/activities/chat/middleware/compose.js.map +1 -0
- package/dist/esm/activities/chat/middleware/index.d.ts +2 -0
- package/dist/esm/activities/chat/middleware/tool-cache-middleware.d.ts +89 -0
- package/dist/esm/activities/chat/middleware/tool-cache-middleware.js +76 -0
- package/dist/esm/activities/chat/middleware/tool-cache-middleware.js.map +1 -0
- package/dist/esm/activities/chat/middleware/types.d.ts +307 -0
- package/dist/esm/activities/chat/tools/tool-calls.d.ts +16 -1
- package/dist/esm/activities/chat/tools/tool-calls.js +148 -64
- package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
- package/dist/esm/activities/generateImage/index.d.ts +19 -6
- package/dist/esm/activities/generateImage/index.js +12 -3
- package/dist/esm/activities/generateImage/index.js.map +1 -1
- package/dist/esm/activities/generateSpeech/index.d.ts +19 -6
- package/dist/esm/activities/generateSpeech/index.js +12 -3
- package/dist/esm/activities/generateSpeech/index.js.map +1 -1
- package/dist/esm/activities/generateTranscription/index.d.ts +30 -6
- package/dist/esm/activities/generateTranscription/index.js +14 -3
- package/dist/esm/activities/generateTranscription/index.js.map +1 -1
- package/dist/esm/activities/generateVideo/index.d.ts +45 -7
- package/dist/esm/activities/generateVideo/index.js +91 -2
- package/dist/esm/activities/generateVideo/index.js.map +1 -1
- package/dist/esm/activities/stream-generation-result.d.ts +14 -0
- package/dist/esm/activities/stream-generation-result.js +40 -0
- package/dist/esm/activities/stream-generation-result.js.map +1 -0
- package/dist/esm/activities/summarize/index.js +3 -18
- package/dist/esm/activities/summarize/index.js.map +1 -1
- package/dist/esm/index.d.ts +3 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/middlewares/content-guard.d.ts +77 -0
- package/dist/esm/middlewares/content-guard.js +155 -0
- package/dist/esm/middlewares/content-guard.js.map +1 -0
- package/dist/esm/middlewares/index.d.ts +2 -0
- package/dist/esm/middlewares/index.js +7 -0
- package/dist/esm/middlewares/index.js.map +1 -0
- package/dist/esm/middlewares/tool-cache.d.ts +1 -0
- package/dist/esm/realtime/index.d.ts +30 -0
- package/dist/esm/realtime/index.js +8 -0
- package/dist/esm/realtime/index.js.map +1 -0
- package/dist/esm/realtime/types.d.ts +234 -0
- package/package.json +6 -6
- package/src/activities/chat/index.ts +322 -256
- package/src/activities/chat/middleware/compose.ts +392 -0
- package/src/activities/chat/middleware/index.ts +17 -0
- package/src/activities/chat/middleware/tool-cache-middleware.ts +189 -0
- package/src/activities/chat/middleware/types.ts +419 -0
- package/src/activities/chat/tools/tool-calls.ts +225 -87
- package/src/activities/generateImage/index.ts +50 -8
- package/src/activities/generateSpeech/index.ts +42 -8
- package/src/activities/generateTranscription/index.ts +60 -10
- package/src/activities/generateVideo/index.ts +174 -7
- package/src/activities/stream-generation-result.ts +62 -0
- package/src/activities/summarize/index.ts +4 -23
- package/src/index.ts +41 -2
- package/src/middlewares/content-guard.ts +285 -0
- package/src/middlewares/index.ts +13 -0
- package/src/middlewares/tool-cache.ts +6 -0
- package/src/realtime/index.ts +38 -0
- package/src/realtime/types.ts +294 -0
- package/dist/esm/event-client.d.ts +0 -394
- package/dist/esm/event-client.js +0 -13
- package/dist/esm/event-client.js.map +0 -1
- package/src/event-client.ts +0 -497
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* @experimental Video generation is an experimental feature and may change.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { aiEventClient } from '
|
|
10
|
+
import { aiEventClient } from '@tanstack/ai-event-client'
|
|
11
11
|
import type { VideoAdapter } from './adapter'
|
|
12
12
|
import type {
|
|
13
|
+
StreamChunk,
|
|
13
14
|
VideoJobResult,
|
|
14
15
|
VideoStatusResult,
|
|
15
16
|
VideoUrlResult,
|
|
@@ -67,10 +68,14 @@ interface VideoActivityBaseOptions<
|
|
|
67
68
|
* Options for creating a new video generation job.
|
|
68
69
|
* The model is extracted from the adapter's model property.
|
|
69
70
|
*
|
|
71
|
+
* @template TAdapter - The video adapter type
|
|
72
|
+
* @template TStream - Whether to stream the output
|
|
73
|
+
*
|
|
70
74
|
* @experimental Video generation is an experimental feature and may change.
|
|
71
75
|
*/
|
|
72
76
|
export type VideoCreateOptions<
|
|
73
77
|
TAdapter extends VideoAdapter<string, any, any, any>,
|
|
78
|
+
TStream extends boolean = false,
|
|
74
79
|
> = VideoActivityBaseOptions<TAdapter> & {
|
|
75
80
|
/** Request type - create a new job (default if not specified) */
|
|
76
81
|
request?: 'create'
|
|
@@ -80,6 +85,21 @@ export type VideoCreateOptions<
|
|
|
80
85
|
size?: VideoSizeForAdapter<TAdapter>
|
|
81
86
|
/** Video duration in seconds */
|
|
82
87
|
duration?: number
|
|
88
|
+
/**
|
|
89
|
+
* Whether to stream the video generation lifecycle.
|
|
90
|
+
* When true, returns an AsyncIterable<StreamChunk> that handles the full
|
|
91
|
+
* job lifecycle: create job, poll for status, yield updates, and yield final result.
|
|
92
|
+
* When false or not provided, returns a Promise<VideoJobResult>.
|
|
93
|
+
*
|
|
94
|
+
* @default false
|
|
95
|
+
*/
|
|
96
|
+
stream?: TStream
|
|
97
|
+
/** Polling interval in milliseconds (stream mode only). @default 2000 */
|
|
98
|
+
pollingInterval?: number
|
|
99
|
+
/** Maximum time to wait before timing out in milliseconds (stream mode only). @default 600000 */
|
|
100
|
+
maxDuration?: number
|
|
101
|
+
/** Custom run ID (stream mode only) */
|
|
102
|
+
runId?: string
|
|
83
103
|
} & ({} extends VideoProviderOptions<TAdapter>
|
|
84
104
|
? {
|
|
85
105
|
/** Provider-specific options for video generation */ modelOptions?: VideoProviderOptions<TAdapter>
|
|
@@ -125,28 +145,34 @@ export interface VideoUrlOptions<
|
|
|
125
145
|
export type VideoActivityOptions<
|
|
126
146
|
TAdapter extends VideoAdapter<string, any, any, any>,
|
|
127
147
|
TRequest extends 'create' | 'status' | 'url' = 'create',
|
|
148
|
+
TStream extends boolean = false,
|
|
128
149
|
> = TRequest extends 'status'
|
|
129
150
|
? VideoStatusOptions<TAdapter>
|
|
130
151
|
: TRequest extends 'url'
|
|
131
152
|
? VideoUrlOptions<TAdapter>
|
|
132
|
-
: VideoCreateOptions<TAdapter>
|
|
153
|
+
: VideoCreateOptions<TAdapter, TStream>
|
|
133
154
|
|
|
134
155
|
// ===========================
|
|
135
156
|
// Activity Result Types
|
|
136
157
|
// ===========================
|
|
137
158
|
|
|
138
159
|
/**
|
|
139
|
-
* Result type for the video activity, based on request type.
|
|
160
|
+
* Result type for the video activity, based on request type and streaming.
|
|
161
|
+
* - If stream is true (create request): AsyncIterable<StreamChunk>
|
|
162
|
+
* - Otherwise: Promise<VideoJobResult | VideoStatusResult | VideoUrlResult>
|
|
140
163
|
*
|
|
141
164
|
* @experimental Video generation is an experimental feature and may change.
|
|
142
165
|
*/
|
|
143
166
|
export type VideoActivityResult<
|
|
144
167
|
TRequest extends 'create' | 'status' | 'url' = 'create',
|
|
168
|
+
TStream extends boolean = false,
|
|
145
169
|
> = TRequest extends 'status'
|
|
146
170
|
? Promise<VideoStatusResult>
|
|
147
171
|
: TRequest extends 'url'
|
|
148
172
|
? Promise<VideoUrlResult>
|
|
149
|
-
:
|
|
173
|
+
: TStream extends true
|
|
174
|
+
? AsyncIterable<StreamChunk>
|
|
175
|
+
: Promise<VideoJobResult>
|
|
150
176
|
|
|
151
177
|
// ===========================
|
|
152
178
|
// Activity Implementation
|
|
@@ -158,6 +184,9 @@ export type VideoActivityResult<
|
|
|
158
184
|
* Uses AI video generation models to create videos based on natural language descriptions.
|
|
159
185
|
* Unlike image generation, video generation is asynchronous and requires polling for completion.
|
|
160
186
|
*
|
|
187
|
+
* When `stream: true` is passed, handles the full job lifecycle automatically:
|
|
188
|
+
* create job → poll for status → stream updates → yield final result.
|
|
189
|
+
*
|
|
161
190
|
* @experimental Video generation is an experimental feature and may change.
|
|
162
191
|
*
|
|
163
192
|
* @example Create a video generation job
|
|
@@ -173,10 +202,43 @@ export type VideoActivityResult<
|
|
|
173
202
|
*
|
|
174
203
|
* console.log('Job started:', jobId)
|
|
175
204
|
* ```
|
|
205
|
+
*
|
|
206
|
+
* @example Stream the full video generation lifecycle
|
|
207
|
+
* ```ts
|
|
208
|
+
* import { generateVideo, toServerSentEventsResponse } from '@tanstack/ai'
|
|
209
|
+
* import { openaiVideo } from '@tanstack/ai-openai'
|
|
210
|
+
*
|
|
211
|
+
* const stream = generateVideo({
|
|
212
|
+
* adapter: openaiVideo('sora-2'),
|
|
213
|
+
* prompt: 'A cat chasing a dog in a sunny park',
|
|
214
|
+
* stream: true,
|
|
215
|
+
* pollingInterval: 3000,
|
|
216
|
+
* })
|
|
217
|
+
*
|
|
218
|
+
* return toServerSentEventsResponse(stream)
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
export function generateVideo<
|
|
222
|
+
TAdapter extends VideoAdapter<string, any, any, any>,
|
|
223
|
+
TStream extends boolean = false,
|
|
224
|
+
>(
|
|
225
|
+
options: VideoCreateOptions<TAdapter, TStream>,
|
|
226
|
+
): VideoActivityResult<'create', TStream> {
|
|
227
|
+
if (options.stream) {
|
|
228
|
+
return runStreamingVideoGeneration(
|
|
229
|
+
options as VideoCreateOptions<TAdapter, true>,
|
|
230
|
+
) as VideoActivityResult<'create', TStream>
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return runCreateVideoJob(options) as VideoActivityResult<'create', TStream>
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Internal implementation of non-streaming video job creation.
|
|
176
238
|
*/
|
|
177
|
-
|
|
239
|
+
async function runCreateVideoJob<
|
|
178
240
|
TAdapter extends VideoAdapter<string, any, any, any>,
|
|
179
|
-
>(options: VideoCreateOptions<TAdapter>): Promise<VideoJobResult> {
|
|
241
|
+
>(options: VideoCreateOptions<TAdapter, boolean>): Promise<VideoJobResult> {
|
|
180
242
|
const { adapter, prompt, size, duration, modelOptions } = options
|
|
181
243
|
const model = adapter.model
|
|
182
244
|
|
|
@@ -189,6 +251,108 @@ export async function generateVideo<
|
|
|
189
251
|
})
|
|
190
252
|
}
|
|
191
253
|
|
|
254
|
+
function sleep(ms: number): Promise<void> {
|
|
255
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Internal streaming implementation for video generation.
|
|
260
|
+
* Handles the full job lifecycle: create job → poll for status → stream updates → yield final result.
|
|
261
|
+
*/
|
|
262
|
+
async function* runStreamingVideoGeneration<
|
|
263
|
+
TAdapter extends VideoAdapter<string, any, any, any>,
|
|
264
|
+
>(options: VideoCreateOptions<TAdapter, true>): AsyncIterable<StreamChunk> {
|
|
265
|
+
const { adapter, prompt, size, duration, modelOptions } = options
|
|
266
|
+
const model = adapter.model
|
|
267
|
+
const runId = options.runId ?? createId('run')
|
|
268
|
+
const pollingInterval = options.pollingInterval ?? 2000
|
|
269
|
+
const maxDuration = options.maxDuration ?? 600_000
|
|
270
|
+
|
|
271
|
+
yield {
|
|
272
|
+
type: 'RUN_STARTED',
|
|
273
|
+
runId,
|
|
274
|
+
timestamp: Date.now(),
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
// Create the video generation job
|
|
279
|
+
const jobResult = await adapter.createVideoJob({
|
|
280
|
+
model,
|
|
281
|
+
prompt,
|
|
282
|
+
size,
|
|
283
|
+
duration,
|
|
284
|
+
modelOptions,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
yield {
|
|
288
|
+
type: 'CUSTOM',
|
|
289
|
+
name: 'video:job:created',
|
|
290
|
+
value: { jobId: jobResult.jobId },
|
|
291
|
+
timestamp: Date.now(),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Poll for completion
|
|
295
|
+
const startTime = Date.now()
|
|
296
|
+
while (Date.now() - startTime < maxDuration) {
|
|
297
|
+
await sleep(pollingInterval)
|
|
298
|
+
|
|
299
|
+
const statusResult = await adapter.getVideoStatus(jobResult.jobId)
|
|
300
|
+
|
|
301
|
+
yield {
|
|
302
|
+
type: 'CUSTOM',
|
|
303
|
+
name: 'video:status',
|
|
304
|
+
value: {
|
|
305
|
+
jobId: jobResult.jobId,
|
|
306
|
+
status: statusResult.status,
|
|
307
|
+
progress: statusResult.progress,
|
|
308
|
+
error: statusResult.error,
|
|
309
|
+
},
|
|
310
|
+
timestamp: Date.now(),
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (statusResult.status === 'completed') {
|
|
314
|
+
const urlResult = await adapter.getVideoUrl(jobResult.jobId)
|
|
315
|
+
|
|
316
|
+
yield {
|
|
317
|
+
type: 'CUSTOM',
|
|
318
|
+
name: 'generation:result',
|
|
319
|
+
value: {
|
|
320
|
+
jobId: jobResult.jobId,
|
|
321
|
+
status: 'completed',
|
|
322
|
+
url: urlResult.url,
|
|
323
|
+
expiresAt: urlResult.expiresAt,
|
|
324
|
+
},
|
|
325
|
+
timestamp: Date.now(),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
yield {
|
|
329
|
+
type: 'RUN_FINISHED',
|
|
330
|
+
runId,
|
|
331
|
+
finishReason: 'stop',
|
|
332
|
+
timestamp: Date.now(),
|
|
333
|
+
}
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (statusResult.status === 'failed') {
|
|
338
|
+
throw new Error(statusResult.error || 'Video generation failed')
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
throw new Error('Video generation timed out')
|
|
343
|
+
} catch (error: any) {
|
|
344
|
+
yield {
|
|
345
|
+
type: 'RUN_ERROR',
|
|
346
|
+
runId,
|
|
347
|
+
error: {
|
|
348
|
+
message: error.message || 'Video generation failed',
|
|
349
|
+
code: error.code,
|
|
350
|
+
},
|
|
351
|
+
timestamp: Date.now(),
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
192
356
|
/**
|
|
193
357
|
* Get video job status - returns the current status, progress, and URL if available.
|
|
194
358
|
*
|
|
@@ -316,7 +480,10 @@ export async function getVideoJobStatus<
|
|
|
316
480
|
*/
|
|
317
481
|
export function createVideoOptions<
|
|
318
482
|
TAdapter extends VideoAdapter<string, any, any, any>,
|
|
319
|
-
|
|
483
|
+
TStream extends boolean = false,
|
|
484
|
+
>(
|
|
485
|
+
options: VideoCreateOptions<TAdapter, TStream>,
|
|
486
|
+
): VideoCreateOptions<TAdapter, TStream> {
|
|
320
487
|
return options
|
|
321
488
|
}
|
|
322
489
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal helper for wrapping one-shot generation results as StreamChunk
|
|
3
|
+
* async iterables. NOT exported from the package — used only by activity
|
|
4
|
+
* implementations to support `stream: true`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { StreamChunk } from '../types'
|
|
8
|
+
|
|
9
|
+
function createId(prefix: string): string {
|
|
10
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wrap a one-shot generation result as a StreamChunk async iterable.
|
|
15
|
+
*
|
|
16
|
+
* This allows non-streaming activities (image, speech, transcription, summarize)
|
|
17
|
+
* to be sent over the same streaming transport as chat.
|
|
18
|
+
*
|
|
19
|
+
* @param generator - An async function that performs the generation and returns the result
|
|
20
|
+
* @param options - Optional configuration (runId)
|
|
21
|
+
* @returns An AsyncIterable of StreamChunks with RUN_STARTED, CUSTOM(generation:result), and RUN_FINISHED events
|
|
22
|
+
*/
|
|
23
|
+
export async function* streamGenerationResult<TResult>(
|
|
24
|
+
generator: () => Promise<TResult>,
|
|
25
|
+
options?: { runId?: string },
|
|
26
|
+
): AsyncIterable<StreamChunk> {
|
|
27
|
+
const runId = options?.runId ?? createId('run')
|
|
28
|
+
|
|
29
|
+
yield {
|
|
30
|
+
type: 'RUN_STARTED',
|
|
31
|
+
runId,
|
|
32
|
+
timestamp: Date.now(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const result = await generator()
|
|
37
|
+
|
|
38
|
+
yield {
|
|
39
|
+
type: 'CUSTOM',
|
|
40
|
+
name: 'generation:result',
|
|
41
|
+
value: result as unknown,
|
|
42
|
+
timestamp: Date.now(),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
yield {
|
|
46
|
+
type: 'RUN_FINISHED',
|
|
47
|
+
runId,
|
|
48
|
+
finishReason: 'stop',
|
|
49
|
+
timestamp: Date.now(),
|
|
50
|
+
}
|
|
51
|
+
} catch (error: any) {
|
|
52
|
+
yield {
|
|
53
|
+
type: 'RUN_ERROR',
|
|
54
|
+
runId,
|
|
55
|
+
error: {
|
|
56
|
+
message: error.message || 'Generation failed',
|
|
57
|
+
code: error.code,
|
|
58
|
+
},
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* This is a self-contained module with implementation, types, and JSDoc.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { aiEventClient } from '
|
|
8
|
+
import { aiEventClient } from '@tanstack/ai-event-client'
|
|
9
|
+
import { streamGenerationResult } from '../stream-generation-result.js'
|
|
9
10
|
import type { SummarizeAdapter } from './adapter'
|
|
10
11
|
import type {
|
|
11
12
|
StreamChunk,
|
|
@@ -239,28 +240,8 @@ async function* runStreamingSummarize(
|
|
|
239
240
|
return
|
|
240
241
|
}
|
|
241
242
|
|
|
242
|
-
// Fall back to non-streaming
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// Yield TEXT_MESSAGE_CONTENT event with the summary
|
|
246
|
-
yield {
|
|
247
|
-
type: 'TEXT_MESSAGE_CONTENT',
|
|
248
|
-
messageId: result.id,
|
|
249
|
-
model: result.model,
|
|
250
|
-
timestamp: Date.now(),
|
|
251
|
-
delta: result.summary,
|
|
252
|
-
content: result.summary,
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Yield RUN_FINISHED event
|
|
256
|
-
yield {
|
|
257
|
-
type: 'RUN_FINISHED',
|
|
258
|
-
runId: result.id,
|
|
259
|
-
model: result.model,
|
|
260
|
-
timestamp: Date.now(),
|
|
261
|
-
finishReason: 'stop',
|
|
262
|
-
usage: result.usage,
|
|
263
|
-
}
|
|
243
|
+
// Fall back to non-streaming — wrap result with streamGenerationResult
|
|
244
|
+
yield* streamGenerationResult(() => adapter.summarize(summarizeOptions))
|
|
264
245
|
}
|
|
265
246
|
|
|
266
247
|
// ===========================
|
package/src/index.ts
CHANGED
|
@@ -70,14 +70,53 @@ export {
|
|
|
70
70
|
combineStrategies,
|
|
71
71
|
} from './activities/chat/agent-loop-strategies'
|
|
72
72
|
|
|
73
|
+
// Chat middleware
|
|
74
|
+
export type {
|
|
75
|
+
ChatMiddleware,
|
|
76
|
+
ChatMiddlewareContext,
|
|
77
|
+
ChatMiddlewarePhase,
|
|
78
|
+
ChatMiddlewareConfig,
|
|
79
|
+
ToolCallHookContext,
|
|
80
|
+
BeforeToolCallDecision,
|
|
81
|
+
AfterToolCallInfo,
|
|
82
|
+
IterationInfo,
|
|
83
|
+
ToolPhaseCompleteInfo,
|
|
84
|
+
UsageInfo,
|
|
85
|
+
FinishInfo,
|
|
86
|
+
AbortInfo,
|
|
87
|
+
ErrorInfo,
|
|
88
|
+
} from './activities/chat/middleware/index'
|
|
89
|
+
|
|
73
90
|
// All types
|
|
74
91
|
export * from './types'
|
|
75
92
|
|
|
76
93
|
// Utility functions
|
|
77
94
|
export { detectImageMimeType } from './utils'
|
|
78
95
|
|
|
79
|
-
//
|
|
80
|
-
export
|
|
96
|
+
// Realtime
|
|
97
|
+
export { realtimeToken } from './realtime/index'
|
|
98
|
+
export type {
|
|
99
|
+
RealtimeToken,
|
|
100
|
+
RealtimeTokenAdapter,
|
|
101
|
+
RealtimeTokenOptions,
|
|
102
|
+
RealtimeSessionConfig,
|
|
103
|
+
VADConfig,
|
|
104
|
+
RealtimeMessage,
|
|
105
|
+
RealtimeMessagePart,
|
|
106
|
+
RealtimeTextPart,
|
|
107
|
+
RealtimeAudioPart,
|
|
108
|
+
RealtimeToolCallPart,
|
|
109
|
+
RealtimeToolResultPart,
|
|
110
|
+
RealtimeImagePart,
|
|
111
|
+
RealtimeStatus,
|
|
112
|
+
RealtimeMode,
|
|
113
|
+
AudioVisualization,
|
|
114
|
+
RealtimeEvent,
|
|
115
|
+
RealtimeEventPayloads,
|
|
116
|
+
RealtimeEventHandler,
|
|
117
|
+
RealtimeErrorCode,
|
|
118
|
+
RealtimeError,
|
|
119
|
+
} from './realtime/index'
|
|
81
120
|
|
|
82
121
|
// Message converters
|
|
83
122
|
export {
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChatMiddleware,
|
|
3
|
+
ChatMiddlewareContext,
|
|
4
|
+
} from '../activities/chat/middleware/types'
|
|
5
|
+
import type { StreamChunk } from '../types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A content guard rule — either a regex pattern with replacement, or a transform function.
|
|
9
|
+
*/
|
|
10
|
+
export type ContentGuardRule =
|
|
11
|
+
| { pattern: RegExp; replacement: string }
|
|
12
|
+
| { fn: (text: string) => string }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Information passed to the onFiltered callback.
|
|
16
|
+
*/
|
|
17
|
+
export interface ContentFilteredInfo {
|
|
18
|
+
/** The message ID being filtered */
|
|
19
|
+
messageId: string
|
|
20
|
+
/** The original text before filtering */
|
|
21
|
+
original: string
|
|
22
|
+
/** The filtered text after rules applied */
|
|
23
|
+
filtered: string
|
|
24
|
+
/** Which strategy was used */
|
|
25
|
+
strategy: 'delta' | 'buffered'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Options for the content guard middleware.
|
|
30
|
+
*/
|
|
31
|
+
export interface ContentGuardMiddlewareOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Rules to apply to text content. Each rule is either a regex pattern
|
|
34
|
+
* with a replacement string, or a custom transform function.
|
|
35
|
+
* Rules are applied in order. Each rule receives the output of the previous.
|
|
36
|
+
*/
|
|
37
|
+
rules: Array<ContentGuardRule>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Matching strategy:
|
|
41
|
+
* - 'delta': Apply rules to each delta as it arrives. Fast, real-time,
|
|
42
|
+
* but patterns spanning chunk boundaries may be missed.
|
|
43
|
+
* - 'buffered': Accumulate content and apply rules to settled portions,
|
|
44
|
+
* holding back a look-behind buffer to catch cross-boundary patterns.
|
|
45
|
+
*
|
|
46
|
+
* @default 'buffered'
|
|
47
|
+
*/
|
|
48
|
+
strategy?: 'delta' | 'buffered'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Number of characters to hold back before emitting (buffered strategy only).
|
|
52
|
+
* Should be at least as long as the longest pattern you expect to match.
|
|
53
|
+
* Buffer is flushed when the stream ends.
|
|
54
|
+
*
|
|
55
|
+
* @default 50
|
|
56
|
+
*/
|
|
57
|
+
bufferSize?: number
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* If true, drop the entire chunk when any rule changes the content.
|
|
61
|
+
* @default false
|
|
62
|
+
*/
|
|
63
|
+
blockOnMatch?: boolean
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Callback when content is filtered by any rule.
|
|
67
|
+
*/
|
|
68
|
+
onFiltered?: (info: ContentFilteredInfo) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Apply all rules to a string, returning the transformed result.
|
|
73
|
+
*/
|
|
74
|
+
function applyRules(text: string, rules: Array<ContentGuardRule>): string {
|
|
75
|
+
let result = text
|
|
76
|
+
for (const rule of rules) {
|
|
77
|
+
if ('pattern' in rule) {
|
|
78
|
+
result = result.replace(rule.pattern, rule.replacement)
|
|
79
|
+
} else {
|
|
80
|
+
result = rule.fn(result)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Creates a middleware that filters or transforms streamed text content.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* import { contentGuardMiddleware } from '@tanstack/ai/middlewares'
|
|
92
|
+
*
|
|
93
|
+
* const guard = contentGuardMiddleware({
|
|
94
|
+
* rules: [
|
|
95
|
+
* { pattern: /\b\d{3}-\d{2}-\d{4}\b/g, replacement: '[SSN REDACTED]' },
|
|
96
|
+
* ],
|
|
97
|
+
* strategy: 'buffered',
|
|
98
|
+
* })
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function contentGuardMiddleware(
|
|
102
|
+
options: ContentGuardMiddlewareOptions,
|
|
103
|
+
): ChatMiddleware {
|
|
104
|
+
const {
|
|
105
|
+
rules,
|
|
106
|
+
strategy = 'buffered',
|
|
107
|
+
bufferSize = 50,
|
|
108
|
+
blockOnMatch = false,
|
|
109
|
+
onFiltered,
|
|
110
|
+
} = options
|
|
111
|
+
|
|
112
|
+
if (strategy === 'delta') {
|
|
113
|
+
return createDeltaStrategy(rules, blockOnMatch, onFiltered)
|
|
114
|
+
}
|
|
115
|
+
return createBufferedStrategy(rules, bufferSize, blockOnMatch, onFiltered)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createDeltaStrategy(
|
|
119
|
+
rules: Array<ContentGuardRule>,
|
|
120
|
+
blockOnMatch: boolean,
|
|
121
|
+
onFiltered?: (info: ContentFilteredInfo) => void,
|
|
122
|
+
): ChatMiddleware {
|
|
123
|
+
return {
|
|
124
|
+
name: 'content-guard',
|
|
125
|
+
|
|
126
|
+
onChunk(_ctx: ChatMiddlewareContext, chunk: StreamChunk) {
|
|
127
|
+
if (chunk.type !== 'TEXT_MESSAGE_CONTENT') return
|
|
128
|
+
|
|
129
|
+
const original = chunk.delta
|
|
130
|
+
const filtered = applyRules(original, rules)
|
|
131
|
+
|
|
132
|
+
if (filtered === original) return // unchanged, pass through
|
|
133
|
+
|
|
134
|
+
if (onFiltered) {
|
|
135
|
+
onFiltered({
|
|
136
|
+
messageId: chunk.messageId,
|
|
137
|
+
original,
|
|
138
|
+
filtered,
|
|
139
|
+
strategy: 'delta',
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (blockOnMatch) return null // drop chunk
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
...chunk,
|
|
147
|
+
delta: filtered,
|
|
148
|
+
content: undefined,
|
|
149
|
+
} as StreamChunk
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function createBufferedStrategy(
|
|
155
|
+
rules: Array<ContentGuardRule>,
|
|
156
|
+
bufferSize: number,
|
|
157
|
+
blockOnMatch: boolean,
|
|
158
|
+
onFiltered?: (info: ContentFilteredInfo) => void,
|
|
159
|
+
): ChatMiddleware {
|
|
160
|
+
let rawAccumulated = ''
|
|
161
|
+
let emittedFilteredLength = 0
|
|
162
|
+
let lastMessageId = ''
|
|
163
|
+
|
|
164
|
+
function resetState() {
|
|
165
|
+
rawAccumulated = ''
|
|
166
|
+
emittedFilteredLength = 0
|
|
167
|
+
lastMessageId = ''
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function flushBuffer(): StreamChunk | null {
|
|
171
|
+
if (rawAccumulated.length === 0) return null
|
|
172
|
+
|
|
173
|
+
const filtered = applyRules(rawAccumulated, rules)
|
|
174
|
+
|
|
175
|
+
if (blockOnMatch && filtered !== rawAccumulated) {
|
|
176
|
+
if (onFiltered) {
|
|
177
|
+
onFiltered({
|
|
178
|
+
messageId: lastMessageId,
|
|
179
|
+
original: rawAccumulated,
|
|
180
|
+
filtered,
|
|
181
|
+
strategy: 'buffered',
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
resetState()
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const remaining = filtered.slice(emittedFilteredLength)
|
|
189
|
+
if (remaining.length > 0) {
|
|
190
|
+
if (filtered !== rawAccumulated && onFiltered) {
|
|
191
|
+
onFiltered({
|
|
192
|
+
messageId: lastMessageId,
|
|
193
|
+
original: rawAccumulated,
|
|
194
|
+
filtered,
|
|
195
|
+
strategy: 'buffered',
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const flushed = {
|
|
200
|
+
type: 'TEXT_MESSAGE_CONTENT',
|
|
201
|
+
messageId: lastMessageId,
|
|
202
|
+
delta: remaining,
|
|
203
|
+
content: filtered,
|
|
204
|
+
timestamp: Date.now(),
|
|
205
|
+
} as StreamChunk
|
|
206
|
+
|
|
207
|
+
resetState()
|
|
208
|
+
return flushed
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
resetState()
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
name: 'content-guard',
|
|
217
|
+
|
|
218
|
+
onStart() {
|
|
219
|
+
resetState()
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
onChunk(_ctx: ChatMiddlewareContext, chunk: StreamChunk) {
|
|
223
|
+
// Flush buffer on stream end events
|
|
224
|
+
if (chunk.type === 'TEXT_MESSAGE_END' || chunk.type === 'RUN_FINISHED') {
|
|
225
|
+
const flushed = flushBuffer()
|
|
226
|
+
if (flushed) return [flushed, chunk]
|
|
227
|
+
return // pass through end event
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (chunk.type !== 'TEXT_MESSAGE_CONTENT') return // pass through
|
|
231
|
+
|
|
232
|
+
// Flush buffer on message boundary change
|
|
233
|
+
const pending: Array<StreamChunk> = []
|
|
234
|
+
if (lastMessageId && chunk.messageId !== lastMessageId) {
|
|
235
|
+
const flushed = flushBuffer()
|
|
236
|
+
if (flushed) pending.push(flushed)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
rawAccumulated += chunk.delta
|
|
240
|
+
lastMessageId = chunk.messageId
|
|
241
|
+
|
|
242
|
+
// Apply rules to full accumulated text, buffer in filtered space
|
|
243
|
+
const filtered = applyRules(rawAccumulated, rules)
|
|
244
|
+
const safeFilteredEnd = Math.max(0, filtered.length - bufferSize)
|
|
245
|
+
|
|
246
|
+
if (safeFilteredEnd <= emittedFilteredLength) {
|
|
247
|
+
return pending.length > 0 ? pending : null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (blockOnMatch && filtered !== rawAccumulated) {
|
|
251
|
+
if (onFiltered) {
|
|
252
|
+
onFiltered({
|
|
253
|
+
messageId: chunk.messageId,
|
|
254
|
+
original: rawAccumulated,
|
|
255
|
+
filtered,
|
|
256
|
+
strategy: 'buffered',
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
return pending.length > 0 ? pending : null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const newDelta = filtered.slice(emittedFilteredLength, safeFilteredEnd)
|
|
263
|
+
|
|
264
|
+
if (filtered !== rawAccumulated && onFiltered) {
|
|
265
|
+
onFiltered({
|
|
266
|
+
messageId: chunk.messageId,
|
|
267
|
+
original: rawAccumulated,
|
|
268
|
+
filtered,
|
|
269
|
+
strategy: 'buffered',
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
emittedFilteredLength = safeFilteredEnd
|
|
274
|
+
|
|
275
|
+
const emitChunk = {
|
|
276
|
+
...chunk,
|
|
277
|
+
delta: newDelta,
|
|
278
|
+
content: filtered.slice(0, safeFilteredEnd),
|
|
279
|
+
} as StreamChunk
|
|
280
|
+
|
|
281
|
+
pending.push(emitChunk)
|
|
282
|
+
return pending.length === 1 ? pending[0]! : pending
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
}
|