@tanstack/ai 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -13
- package/dist/esm/activities/chat/index.js +5 -3
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/generateLiveVideo/adapter.d.ts +69 -0
- package/dist/esm/activities/generateLiveVideo/adapter.js +23 -0
- package/dist/esm/activities/generateLiveVideo/adapter.js.map +1 -0
- package/dist/esm/activities/generateLiveVideo/index.d.ts +99 -0
- package/dist/esm/activities/generateLiveVideo/index.js +162 -0
- package/dist/esm/activities/generateLiveVideo/index.js.map +1 -0
- package/dist/esm/activities/generateVideo/index.js +3 -1
- package/dist/esm/activities/generateVideo/index.js.map +1 -1
- package/dist/esm/activities/generateWorld/adapter.d.ts +69 -0
- package/dist/esm/activities/generateWorld/adapter.js +23 -0
- package/dist/esm/activities/generateWorld/adapter.js.map +1 -0
- package/dist/esm/activities/generateWorld/index.d.ts +99 -0
- package/dist/esm/activities/generateWorld/index.js +162 -0
- package/dist/esm/activities/generateWorld/index.js.map +1 -0
- package/dist/esm/activities/index.d.ts +8 -2
- package/dist/esm/activities/index.js +11 -7
- package/dist/esm/activities/middleware/types.d.ts +1 -1
- package/dist/esm/client.d.ts +4 -2
- package/dist/esm/client.js +3 -1
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/index.d.ts +4 -2
- package/dist/esm/index.js +3 -1
- package/dist/esm/middlewares/otel.js +3 -1
- package/dist/esm/middlewares/otel.js.map +1 -1
- package/dist/esm/types.d.ts +112 -0
- package/package.json +2 -2
- package/skills/ai-core/adapter-configuration/SKILL.md +90 -42
- package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +39 -21
- package/skills/ai-core/adapter-configuration/references/byteplus-adapter.md +5 -0
- package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +14 -6
- package/skills/ai-core/adapter-configuration/references/grok-adapter.md +33 -25
- package/skills/ai-core/adapter-configuration/references/groq-adapter.md +7 -2
- package/skills/ai-core/adapter-configuration/references/ollama-adapter.md +25 -12
- package/skills/ai-core/adapter-configuration/references/openai-adapter.md +19 -9
- package/skills/ai-core/adapter-configuration/references/openrouter-adapter.md +34 -21
- package/skills/ai-core/ag-ui-protocol/SKILL.md +16 -10
- package/skills/ai-core/chat-experience/SKILL.md +228 -108
- package/skills/ai-core/client-persistence/SKILL.md +21 -9
- package/skills/ai-core/custom-backend-integration/SKILL.md +86 -52
- package/skills/ai-core/debug-logging/SKILL.md +100 -18
- package/skills/ai-core/locks/SKILL.md +35 -7
- package/skills/ai-core/media-generation/SKILL.md +114 -49
- package/skills/ai-core/middleware/SKILL.md +174 -69
- package/skills/ai-core/structured-outputs/SKILL.md +98 -49
- package/skills/ai-core/tool-calling/SKILL.md +245 -158
- package/src/activities/chat/index.ts +6 -7
- package/src/activities/generateLiveVideo/adapter.ts +99 -0
- package/src/activities/generateLiveVideo/index.ts +339 -0
- package/src/activities/generateVideo/index.ts +3 -4
- package/src/activities/generateWorld/adapter.ts +96 -0
- package/src/activities/generateWorld/index.ts +339 -0
- package/src/activities/index.ts +44 -0
- package/src/activities/middleware/types.ts +2 -0
- package/src/client.ts +8 -0
- package/src/index.ts +8 -0
- package/src/middlewares/otel.ts +2 -0
- package/src/types.ts +128 -0
|
@@ -24,26 +24,31 @@ sources:
|
|
|
24
24
|
```typescript
|
|
25
25
|
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
|
|
26
26
|
import { openaiText } from '@tanstack/ai-openai'
|
|
27
|
+
import { trackAnalytics, reportError } from './analytics'
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
29
|
+
export async function POST(request: Request) {
|
|
30
|
+
const { messages } = await request.json()
|
|
31
|
+
|
|
32
|
+
const stream = chat({
|
|
33
|
+
adapter: openaiText('gpt-5.5'),
|
|
34
|
+
messages,
|
|
35
|
+
middleware: [
|
|
36
|
+
{
|
|
37
|
+
onStart: (ctx) => {
|
|
38
|
+
console.log('Chat started:', ctx.model)
|
|
39
|
+
},
|
|
40
|
+
onFinish: (ctx, info) => {
|
|
41
|
+
trackAnalytics({ model: ctx.model, tokens: info.usage?.totalTokens })
|
|
42
|
+
},
|
|
43
|
+
onError: (ctx, info) => {
|
|
44
|
+
reportError(info.error)
|
|
45
|
+
},
|
|
41
46
|
},
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
47
|
+
],
|
|
48
|
+
})
|
|
45
49
|
|
|
46
|
-
return toServerSentEventsResponse(stream)
|
|
50
|
+
return toServerSentEventsResponse(stream)
|
|
51
|
+
}
|
|
47
52
|
```
|
|
48
53
|
|
|
49
54
|
## Hooks Reference
|
|
@@ -119,19 +124,30 @@ specific config changes that should not affect the agent-loop adapter calls.
|
|
|
119
124
|
**Signature:**
|
|
120
125
|
|
|
121
126
|
```ts
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
import type {
|
|
128
|
+
ChatMiddlewareContext,
|
|
129
|
+
StructuredOutputMiddlewareConfig,
|
|
130
|
+
} from '@tanstack/ai'
|
|
131
|
+
|
|
132
|
+
// Excerpt of the `ChatMiddleware` interface exported by '@tanstack/ai'
|
|
133
|
+
interface ChatMiddleware {
|
|
134
|
+
onStructuredOutputConfig?: (
|
|
135
|
+
ctx: ChatMiddlewareContext,
|
|
136
|
+
config: StructuredOutputMiddlewareConfig,
|
|
137
|
+
) =>
|
|
138
|
+
| void
|
|
139
|
+
| null
|
|
140
|
+
| Partial<StructuredOutputMiddlewareConfig>
|
|
141
|
+
| Promise<void | null | Partial<StructuredOutputMiddlewareConfig>>
|
|
142
|
+
}
|
|
130
143
|
```
|
|
131
144
|
|
|
132
145
|
**`StructuredOutputMiddlewareConfig` shape:**
|
|
133
146
|
|
|
134
147
|
```ts
|
|
148
|
+
import type { ChatMiddlewareConfig, JSONSchema } from '@tanstack/ai'
|
|
149
|
+
|
|
150
|
+
// As exported by '@tanstack/ai'
|
|
135
151
|
interface StructuredOutputMiddlewareConfig extends Omit<
|
|
136
152
|
ChatMiddlewareConfig,
|
|
137
153
|
'tools'
|
|
@@ -203,13 +219,17 @@ const analytics: ChatMiddleware = {
|
|
|
203
219
|
},
|
|
204
220
|
}
|
|
205
221
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
222
|
+
export async function POST(request: Request) {
|
|
223
|
+
const { messages } = await request.json()
|
|
224
|
+
|
|
225
|
+
const stream = chat({
|
|
226
|
+
adapter: openaiText('gpt-5.5'),
|
|
227
|
+
messages,
|
|
228
|
+
middleware: [analytics],
|
|
229
|
+
})
|
|
211
230
|
|
|
212
|
-
return toServerSentEventsResponse(stream)
|
|
231
|
+
return toServerSentEventsResponse(stream)
|
|
232
|
+
}
|
|
213
233
|
```
|
|
214
234
|
|
|
215
235
|
### Pattern 2: Tool Interception Middleware
|
|
@@ -278,11 +298,14 @@ native-combined schema.
|
|
|
278
298
|
|
|
279
299
|
```typescript
|
|
280
300
|
import type { ChatMiddleware } from '@tanstack/ai'
|
|
301
|
+
import { trace } from '@opentelemetry/api'
|
|
281
302
|
|
|
282
303
|
const tracing: ChatMiddleware = {
|
|
283
304
|
name: 'tracing',
|
|
284
305
|
onChunk(ctx, chunk) {
|
|
285
|
-
|
|
306
|
+
trace
|
|
307
|
+
.getActiveSpan()
|
|
308
|
+
?.addEvent('chunk', { phase: ctx.phase, type: chunk.type })
|
|
286
309
|
},
|
|
287
310
|
}
|
|
288
311
|
```
|
|
@@ -296,6 +319,7 @@ the native-combined path, it observes the structured stream with
|
|
|
296
319
|
|
|
297
320
|
```typescript
|
|
298
321
|
import type { ChatMiddleware } from '@tanstack/ai'
|
|
322
|
+
import { sharedDefs } from './defs'
|
|
299
323
|
|
|
300
324
|
const injectDefs: ChatMiddleware = {
|
|
301
325
|
name: 'inject-defs',
|
|
@@ -317,9 +341,27 @@ Middleware executes in array order (left-to-right). Ordering matters for hooks t
|
|
|
317
341
|
pipe or short-circuit:
|
|
318
342
|
|
|
319
343
|
```typescript
|
|
320
|
-
import {
|
|
344
|
+
import {
|
|
345
|
+
chat,
|
|
346
|
+
toolDefinition,
|
|
347
|
+
toServerSentEventsResponse,
|
|
348
|
+
type ChatMiddleware,
|
|
349
|
+
} from '@tanstack/ai'
|
|
321
350
|
import { toolCacheMiddleware } from '@tanstack/ai/middlewares'
|
|
322
351
|
import { openaiText } from '@tanstack/ai-openai'
|
|
352
|
+
import { z } from 'zod'
|
|
353
|
+
|
|
354
|
+
const weatherTool = toolDefinition({
|
|
355
|
+
name: 'getWeather',
|
|
356
|
+
description: 'Get the current weather for a city',
|
|
357
|
+
inputSchema: z.object({ city: z.string() }),
|
|
358
|
+
}).server(async ({ city }) => ({ city, tempC: 21 }))
|
|
359
|
+
|
|
360
|
+
const stockTool = toolDefinition({
|
|
361
|
+
name: 'getStock',
|
|
362
|
+
description: 'Get the latest price for a ticker symbol',
|
|
363
|
+
inputSchema: z.object({ symbol: z.string() }),
|
|
364
|
+
}).server(async ({ symbol }) => ({ symbol, price: 123.45 }))
|
|
323
365
|
|
|
324
366
|
const logging: ChatMiddleware = {
|
|
325
367
|
name: 'logging',
|
|
@@ -347,16 +389,22 @@ const configTransform: ChatMiddleware = {
|
|
|
347
389
|
},
|
|
348
390
|
}
|
|
349
391
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
392
|
+
export async function POST(request: Request) {
|
|
393
|
+
const { messages } = await request.json()
|
|
394
|
+
|
|
395
|
+
const stream = chat({
|
|
396
|
+
adapter: openaiText('gpt-5.5'),
|
|
397
|
+
messages,
|
|
398
|
+
tools: [weatherTool, stockTool],
|
|
399
|
+
middleware: [
|
|
400
|
+
logging, // Runs first
|
|
401
|
+
configTransform, // Transforms config second
|
|
402
|
+
toolCacheMiddleware({ ttl: 60_000 }), // Caches tool results third
|
|
403
|
+
],
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
return toServerSentEventsResponse(stream)
|
|
407
|
+
}
|
|
360
408
|
```
|
|
361
409
|
|
|
362
410
|
**Composition rules by hook:**
|
|
@@ -378,7 +426,21 @@ Not a built-in. Cap fan-out with `onBeforeToolCall` skip + `onShouldContinue`.
|
|
|
378
426
|
See `docs/chat/agentic-cycle.md` ("Tool-call budgets").
|
|
379
427
|
|
|
380
428
|
```typescript
|
|
381
|
-
import {
|
|
429
|
+
import {
|
|
430
|
+
chat,
|
|
431
|
+
maxIterations,
|
|
432
|
+
toolDefinition,
|
|
433
|
+
toServerSentEventsResponse,
|
|
434
|
+
type ChatMiddleware,
|
|
435
|
+
} from '@tanstack/ai'
|
|
436
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
437
|
+
import { z } from 'zod'
|
|
438
|
+
|
|
439
|
+
const weatherTool = toolDefinition({
|
|
440
|
+
name: 'getWeather',
|
|
441
|
+
description: 'Get the current weather for a city',
|
|
442
|
+
inputSchema: z.object({ city: z.string() }),
|
|
443
|
+
}).server(async ({ city }) => ({ city, tempC: 21 }))
|
|
382
444
|
|
|
383
445
|
function toolCallBudget(opts: {
|
|
384
446
|
max?: number
|
|
@@ -409,13 +471,19 @@ function toolCallBudget(opts: {
|
|
|
409
471
|
}
|
|
410
472
|
}
|
|
411
473
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
474
|
+
export async function POST(request: Request) {
|
|
475
|
+
const { messages } = await request.json()
|
|
476
|
+
|
|
477
|
+
const stream = chat({
|
|
478
|
+
adapter: openaiText('gpt-5.5'),
|
|
479
|
+
messages,
|
|
480
|
+
tools: [weatherTool],
|
|
481
|
+
agentLoopStrategy: maxIterations(20),
|
|
482
|
+
middleware: [toolCallBudget({ maxPerTurn: 10, max: 20 })],
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
return toServerSentEventsResponse(stream)
|
|
486
|
+
}
|
|
419
487
|
```
|
|
420
488
|
|
|
421
489
|
## Built-in: toolCacheMiddleware
|
|
@@ -423,21 +491,35 @@ chat({
|
|
|
423
491
|
Caches tool call results by name + arguments. Import from `@tanstack/ai/middlewares`:
|
|
424
492
|
|
|
425
493
|
```typescript
|
|
426
|
-
import { chat } from '@tanstack/ai'
|
|
494
|
+
import { chat, toolDefinition, toServerSentEventsResponse } from '@tanstack/ai'
|
|
427
495
|
import { toolCacheMiddleware } from '@tanstack/ai/middlewares'
|
|
496
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
497
|
+
import { z } from 'zod'
|
|
428
498
|
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
499
|
+
const weatherTool = toolDefinition({
|
|
500
|
+
name: 'getWeather',
|
|
501
|
+
description: 'Get the current weather for a city',
|
|
502
|
+
inputSchema: z.object({ city: z.string() }),
|
|
503
|
+
}).server(async ({ city }) => ({ city, tempC: 21 }))
|
|
504
|
+
|
|
505
|
+
export async function POST(request: Request) {
|
|
506
|
+
const { messages } = await request.json()
|
|
507
|
+
|
|
508
|
+
const stream = chat({
|
|
509
|
+
adapter: openaiText('gpt-5.5'),
|
|
510
|
+
messages,
|
|
511
|
+
tools: [weatherTool],
|
|
512
|
+
middleware: [
|
|
513
|
+
toolCacheMiddleware({
|
|
514
|
+
ttl: 60_000, // Cache entries expire after 60 seconds
|
|
515
|
+
maxSize: 50, // Max 50 entries (LRU eviction)
|
|
516
|
+
toolNames: ['getWeather'], // Only cache specific tools
|
|
517
|
+
}),
|
|
518
|
+
],
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
return toServerSentEventsResponse(stream)
|
|
522
|
+
}
|
|
441
523
|
```
|
|
442
524
|
|
|
443
525
|
Options: `maxSize` (default 100), `ttl` (default Infinity), `toolNames` (default all),
|
|
@@ -550,7 +632,12 @@ implement, and what `@tanstack/ai-sandbox`'s run driver resolves per run — its
|
|
|
550
632
|
`snapshot()` method alongside `append`, `read`, and `close`:
|
|
551
633
|
|
|
552
634
|
```ts
|
|
553
|
-
|
|
635
|
+
import type { StreamChunk } from '@tanstack/ai'
|
|
636
|
+
|
|
637
|
+
// Excerpt of the `StreamDurability` interface exported by '@tanstack/ai'
|
|
638
|
+
interface StreamDurability<TOffset extends string = string> {
|
|
639
|
+
snapshot: () => Promise<Array<{ offset: TOffset; chunk: StreamChunk }>>
|
|
640
|
+
}
|
|
554
641
|
```
|
|
555
642
|
|
|
556
643
|
It returns everything stored for a run right now, in append order, then
|
|
@@ -695,11 +782,15 @@ Source: docs/sandbox/observability.md
|
|
|
695
782
|
### a. MEDIUM: Trying to modify StreamChunks in middleware
|
|
696
783
|
|
|
697
784
|
```typescript
|
|
785
|
+
import type { ChatMiddleware } from '@tanstack/ai'
|
|
786
|
+
|
|
698
787
|
// WRONG -- mutating the chunk object directly
|
|
699
788
|
const broken: ChatMiddleware = {
|
|
700
789
|
name: 'broken',
|
|
701
790
|
onChunk: (ctx, chunk) => {
|
|
702
|
-
chunk.
|
|
791
|
+
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
|
|
792
|
+
chunk.delta = 'modified' // Mutation does nothing; chunk is not modified in-place
|
|
793
|
+
}
|
|
703
794
|
},
|
|
704
795
|
}
|
|
705
796
|
|
|
@@ -736,6 +827,9 @@ middleware had decided to reject. A throw from either fails the whole stream. Th
|
|
|
736
827
|
is where an unhandled error actually costs you a response:
|
|
737
828
|
|
|
738
829
|
```typescript
|
|
830
|
+
import type { ChatMiddleware } from '@tanstack/ai'
|
|
831
|
+
import { logChunk, requireEnv } from './logging'
|
|
832
|
+
|
|
739
833
|
// WRONG -- an unhandled error in onChunk kills the entire streaming response
|
|
740
834
|
const fragile: ChatMiddleware = {
|
|
741
835
|
name: 'fragile-chunk-logger',
|
|
@@ -745,7 +839,12 @@ const fragile: ChatMiddleware = {
|
|
|
745
839
|
},
|
|
746
840
|
onConfig: (ctx, config) => {
|
|
747
841
|
// Same for a config transform that reads an env var that is not set
|
|
748
|
-
return {
|
|
842
|
+
return {
|
|
843
|
+
modelOptions: {
|
|
844
|
+
...config.modelOptions,
|
|
845
|
+
temperature: Number(requireEnv('TEMPERATURE')),
|
|
846
|
+
},
|
|
847
|
+
}
|
|
749
848
|
},
|
|
750
849
|
}
|
|
751
850
|
|
|
@@ -761,9 +860,15 @@ const resilient: ChatMiddleware = {
|
|
|
761
860
|
// Return void to pass through
|
|
762
861
|
},
|
|
763
862
|
onConfig: (ctx, config) => {
|
|
764
|
-
const
|
|
863
|
+
const temperature = process.env.TEMPERATURE
|
|
765
864
|
// Decide, do not throw: no override means no transform.
|
|
766
|
-
|
|
865
|
+
if (temperature === undefined) return undefined
|
|
866
|
+
return {
|
|
867
|
+
modelOptions: {
|
|
868
|
+
...config.modelOptions,
|
|
869
|
+
temperature: Number(temperature),
|
|
870
|
+
},
|
|
871
|
+
}
|
|
767
872
|
},
|
|
768
873
|
onFinish: (ctx, info) => {
|
|
769
874
|
// Already guarded by core — but prefer ctx.defer() anyway, so a slow
|
|
@@ -139,7 +139,7 @@ const company = await chat({
|
|
|
139
139
|
|
|
140
140
|
// Full type safety on nested properties
|
|
141
141
|
console.log(company.headquarters.city)
|
|
142
|
-
console.log(company.employees[0]
|
|
142
|
+
console.log(company.employees[0]?.role)
|
|
143
143
|
console.log(company.financials?.revenue)
|
|
144
144
|
```
|
|
145
145
|
|
|
@@ -147,7 +147,7 @@ console.log(company.financials?.revenue)
|
|
|
147
147
|
|
|
148
148
|
Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a completed typed object. Use this when you're a single process end-to-end — Node script, CLI, test, or a server endpoint that responds with one JSON blob. For the in-browser progressive-UI case, jump to Pattern 4 instead.
|
|
149
149
|
|
|
150
|
-
```typescript
|
|
150
|
+
```typescript group=person-stream
|
|
151
151
|
import { chat } from '@tanstack/ai'
|
|
152
152
|
import { openaiText } from '@tanstack/ai-openai'
|
|
153
153
|
import { z } from 'zod'
|
|
@@ -316,7 +316,7 @@ function RecipeBuilder() {
|
|
|
316
316
|
.filter((p) => p.type === 'text')
|
|
317
317
|
.map((p) => p.content)
|
|
318
318
|
.join('')
|
|
319
|
-
return <
|
|
319
|
+
return <p key={m.id}>{text}</p>
|
|
320
320
|
}
|
|
321
321
|
if (m.role === 'assistant') {
|
|
322
322
|
// `data` is `Recipe` because the schema generic flows from
|
|
@@ -338,8 +338,8 @@ function RecipeBuilder() {
|
|
|
338
338
|
function RecipeCard({ part }: { part: RecipePart }) {
|
|
339
339
|
// `data` lands on complete, `partial` fills in while streaming.
|
|
340
340
|
// Both are typed against the schema. No casts.
|
|
341
|
-
const recipe = part.data ?? part.partial
|
|
342
|
-
return <h3>{recipe
|
|
341
|
+
const recipe = part.data ?? part.partial
|
|
342
|
+
return <h3>{recipe?.title ?? 'Plating up…'}</h3>
|
|
343
343
|
}
|
|
344
344
|
```
|
|
345
345
|
|
|
@@ -398,12 +398,19 @@ const ReportSchema = z.object({
|
|
|
398
398
|
oneLiner: z.string(),
|
|
399
399
|
})
|
|
400
400
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
401
|
+
function RepoReport() {
|
|
402
|
+
const { final, sendMessage } = useChat({
|
|
403
|
+
connection: fetchServerSentEvents('/api/repo-report'),
|
|
404
|
+
outputSchema: ReportSchema,
|
|
405
|
+
})
|
|
405
406
|
|
|
406
|
-
|
|
407
|
+
return (
|
|
408
|
+
<div>
|
|
409
|
+
<button onClick={() => sendMessage('Describe this repo')}>Report</button>
|
|
410
|
+
{final && <h2>{final.name}</h2>}
|
|
411
|
+
</div>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
407
414
|
```
|
|
408
415
|
|
|
409
416
|
- Claude Code: `--json-schema`. Codex: `--output-schema`. OpenCode, Grok Build, and `acpCompatible`: prompt-and-parse.
|
|
@@ -419,28 +426,51 @@ final?.name
|
|
|
419
426
|
|
|
420
427
|
Earlier versions of the library routed structured-output JSON deltas through `TextPart`, so renderers had to filter them out:
|
|
421
428
|
|
|
422
|
-
```tsx
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
429
|
+
```tsx group=recipe-renderer
|
|
430
|
+
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
|
|
431
|
+
import { z } from 'zod'
|
|
432
|
+
import { ReasoningView, ToolCallView, RecipeCard } from './views'
|
|
433
|
+
|
|
434
|
+
const RecipeSchema = z.object({
|
|
435
|
+
title: z.string(),
|
|
436
|
+
steps: z.array(z.string()),
|
|
428
437
|
})
|
|
438
|
+
|
|
439
|
+
function useRecipeChat() {
|
|
440
|
+
return useChat({
|
|
441
|
+
connection: fetchServerSentEvents('/api/recipes'),
|
|
442
|
+
outputSchema: RecipeSchema,
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function ObsoleteRenderer() {
|
|
447
|
+
const { messages } = useRecipeChat()
|
|
448
|
+
const last = messages.at(-1)
|
|
449
|
+
// OBSOLETE — this guard was needed only because JSON used to land in a TextPart
|
|
450
|
+
return last?.parts.map((part, i) => {
|
|
451
|
+
if (part.type === 'text') return null // ❌ hides the structured JSON
|
|
452
|
+
return <pre key={i}>{JSON.stringify(part)}</pre>
|
|
453
|
+
})
|
|
454
|
+
}
|
|
429
455
|
```
|
|
430
456
|
|
|
431
457
|
That hack is **gone**. With `outputSchema` set, `TEXT_MESSAGE_CONTENT` deltas now route into a dedicated `StructuredOutputPart` (with `raw`, `partial`, `data`, `status`, optional `errorMessage`). Render the structured part directly; let real `TextPart`s through.
|
|
432
458
|
|
|
433
|
-
```tsx
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
}
|
|
459
|
+
```tsx group=recipe-renderer
|
|
460
|
+
function RecipeRenderer() {
|
|
461
|
+
const { messages } = useRecipeChat()
|
|
462
|
+
const last = messages.at(-1)
|
|
463
|
+
// CORRECT — find the structured-output part directly; let actual TextParts render
|
|
464
|
+
return last?.parts.map((part, i) => {
|
|
465
|
+
if (part.type === 'thinking')
|
|
466
|
+
return <ReasoningView key={i} text={part.content} />
|
|
467
|
+
if (part.type === 'tool-call') return <ToolCallView key={i} part={part} />
|
|
468
|
+
if (part.type === 'structured-output')
|
|
469
|
+
return <RecipeCard key={i} part={part} />
|
|
470
|
+
if (part.type === 'text') return <p key={i}>{part.content}</p> // ← real text, not JSON
|
|
471
|
+
return null
|
|
472
|
+
})
|
|
473
|
+
}
|
|
444
474
|
```
|
|
445
475
|
|
|
446
476
|
If you still have an `if (part.type === 'text') return null` line in a structured-output renderer specifically for "hiding the JSON," delete it.
|
|
@@ -456,18 +486,24 @@ Source: PR #577 — structured-output became a typed UIMessage part.
|
|
|
456
486
|
|
|
457
487
|
To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only.
|
|
458
488
|
|
|
459
|
-
```tsx
|
|
460
|
-
|
|
461
|
-
{
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
)
|
|
489
|
+
```tsx group=recipe-renderer
|
|
490
|
+
function RecipeHistory() {
|
|
491
|
+
const { messages, final } = useRecipeChat()
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<>
|
|
495
|
+
{/* WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view */}
|
|
496
|
+
{final && <h3>{final.title}</h3>}
|
|
497
|
+
|
|
498
|
+
{/* CORRECT for history — walk messages, render each structured-output part */}
|
|
499
|
+
{messages.map((m) => {
|
|
500
|
+
if (m.role !== 'assistant') return null
|
|
501
|
+
const part = m.parts.find((p) => p.type === 'structured-output')
|
|
502
|
+
return part ? <RecipeCard key={m.id} part={part} /> : null
|
|
503
|
+
})}
|
|
504
|
+
</>
|
|
505
|
+
)
|
|
506
|
+
}
|
|
471
507
|
```
|
|
472
508
|
|
|
473
509
|
Source: PR #577 — partial/final derive from the most recent structured-output part after the latest user message.
|
|
@@ -476,7 +512,7 @@ Source: PR #577 — partial/final derive from the most recent structured-output
|
|
|
476
512
|
|
|
477
513
|
When iterating `chat({ outputSchema, stream: true })` directly (Pattern 3), the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Read the completed typed object from the terminal `structured-output.complete` event. Standard Schema validation remains the consumer's responsibility.
|
|
478
514
|
|
|
479
|
-
```typescript
|
|
515
|
+
```typescript group=person-stream
|
|
480
516
|
// WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
|
|
481
517
|
for await (const chunk of stream) {
|
|
482
518
|
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
|
|
@@ -500,8 +536,9 @@ Source: maintainer interview
|
|
|
500
536
|
|
|
501
537
|
The adapter already handles provider differences (OpenAI uses `response_format`, Anthropic uses tool-based extraction, Gemini uses `responseSchema`). Never configure this yourself.
|
|
502
538
|
|
|
503
|
-
```typescript
|
|
539
|
+
```typescript ignore
|
|
504
540
|
// WRONG -- do not set provider-specific response format
|
|
541
|
+
// (this does not compile: modelOptions has no response-format field)
|
|
505
542
|
chat({
|
|
506
543
|
adapter,
|
|
507
544
|
messages,
|
|
@@ -509,11 +546,17 @@ chat({
|
|
|
509
546
|
responseFormat: { type: 'json_schema', json_schema: mySchema },
|
|
510
547
|
},
|
|
511
548
|
})
|
|
549
|
+
```
|
|
512
550
|
|
|
551
|
+
```typescript
|
|
513
552
|
// CORRECT -- just pass outputSchema, the adapter handles the rest
|
|
514
|
-
chat
|
|
515
|
-
|
|
516
|
-
|
|
553
|
+
import { chat } from '@tanstack/ai'
|
|
554
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
555
|
+
import { z } from 'zod'
|
|
556
|
+
|
|
557
|
+
const person = await chat({
|
|
558
|
+
adapter: openaiText('gpt-5.2'),
|
|
559
|
+
messages: [{ role: 'user', content: 'John Doe, 30' }],
|
|
517
560
|
outputSchema: z.object({ name: z.string(), age: z.number() }),
|
|
518
561
|
})
|
|
519
562
|
```
|
|
@@ -529,8 +572,15 @@ of using the schema validation library already in the project (Zod, ArkType,
|
|
|
529
572
|
Valibot). Always check what the project uses and match it.
|
|
530
573
|
|
|
531
574
|
```typescript
|
|
532
|
-
|
|
533
|
-
|
|
575
|
+
import { chat } from '@tanstack/ai'
|
|
576
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
577
|
+
import { z } from 'zod'
|
|
578
|
+
|
|
579
|
+
const adapter = openaiText('gpt-5.2')
|
|
580
|
+
const messages = [{ role: 'user' as const, content: 'John Doe, 30' }]
|
|
581
|
+
|
|
582
|
+
// WRONG -- raw schema object, no schema-library type inference (result is unknown)
|
|
583
|
+
const untyped = await chat({
|
|
534
584
|
adapter,
|
|
535
585
|
messages,
|
|
536
586
|
outputSchema: {
|
|
@@ -545,9 +595,7 @@ chat({
|
|
|
545
595
|
})
|
|
546
596
|
|
|
547
597
|
// CORRECT -- use the project's schema library (e.g. Zod)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
chat({
|
|
598
|
+
const person = await chat({
|
|
551
599
|
adapter,
|
|
552
600
|
messages,
|
|
553
601
|
outputSchema: z.object({
|
|
@@ -555,6 +603,7 @@ chat({
|
|
|
555
603
|
age: z.number(),
|
|
556
604
|
}),
|
|
557
605
|
})
|
|
606
|
+
person.name // string
|
|
558
607
|
```
|
|
559
608
|
|
|
560
609
|
Using the project's schema library gives you TypeScript type inference and
|