-
-
Notifications
You must be signed in to change notification settings - Fork 274
feat: structured-output as a typed UIMessage part #577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
03c093a
feat(ai-react,ai-client,ai): structured-output as a typed UIMessage part
tombeckenham 640c3b4
fix(ai,ai-client,ai-react): address PR-review findings on structured-…
tombeckenham 69bceb1
fix: address CodeRabbit findings + e2e structured-output renderer
tombeckenham 08bea6c
fix(example): make structured-chat schema OpenAI-strict-compatible
tombeckenham 30a0391
chore(example): add tiles for Structured Chat, Guitar Demo, and Realt…
tombeckenham ca93e3f
chore(example): drop Guitar Demo / Realtime tiles from this PR
tombeckenham 3fdcf7c
fix(example): harden api.structured-chat abort race and 500 sanitization
AlemTuzlak 64c80cb
fix(ai,ai-react): address CR-loop findings on structured-output parts
AlemTuzlak 7f087cb
feat(ai,ai-client,ai-react,example): thread schema generic through UI…
AlemTuzlak 9cd2292
docs(structured-outputs): split into top-level section with per-journ…
AlemTuzlak 86667c9
docs(structured-outputs): fact-check pass — fix hallucinated API claims
AlemTuzlak 3aacc76
test(e2e): cover multi-turn structured chat across every provider
AlemTuzlak fe40b95
ci: apply automated fixes
autofix-ci[bot] 6a49113
skill(ai-core/structured-outputs): cover useChat + multi-turn patterns
AlemTuzlak b716861
ci: apply automated fixes
autofix-ci[bot] d6cab5a
skill(ai-core/structured-outputs): fact-check correction — fallback p…
AlemTuzlak a8dac53
chore(self-learning): codify "skills + docs + e2e" rules after PR #57…
AlemTuzlak bed147c
feat(ai-vue,ai-solid,ai-svelte): bring structured-output parity from …
AlemTuzlak ad99c76
chore(changeset): include all six framework packages bumped by PR #577
AlemTuzlak File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| --- | ||
| '@tanstack/ai': minor | ||
| '@tanstack/ai-client': minor | ||
| '@tanstack/ai-react': minor | ||
| --- | ||
|
|
||
| feat: structured-output as a typed MessagePart on each assistant UIMessage | ||
|
|
||
| `useChat({ outputSchema })` previously kept a single hook-level `partial`/`final` | ||
| slot, so multi-turn structured chats lost every prior turn's response as soon | ||
| as a new one streamed in. Each assistant turn now carries its own typed | ||
| `structured-output` MessagePart on the UIMessage it belongs to. History walks | ||
| `messages` and finds the typed part on each turn; the hook-level `partial` | ||
| and `final` are derived from the latest assistant message's part and continue | ||
| to work as before. | ||
|
|
||
| Server-side `chat({ outputSchema, stream: true })` emits a new | ||
| `structured-output.start` CUSTOM event before the JSON deltas so the client | ||
| processor can route them into the StructuredOutputPart instead of building a | ||
| TextPart. The wire converter serializes the part's raw JSON back as assistant | ||
| content, so multi-turn structured chats stay coherent (the LLM sees its own | ||
| prior structured responses on follow-up turns). | ||
|
|
||
| A new example route demonstrating this pattern is at | ||
| `/generations/structured-chat` in the `ts-react-chat` example. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { createFileRoute } from '@tanstack/react-router' | ||
| import { | ||
| chat, | ||
| chatParamsFromRequestBody, | ||
| toServerSentEventsResponse, | ||
| } from '@tanstack/ai' | ||
| import { openaiText } from '@tanstack/ai-openai' | ||
| import { z } from 'zod' | ||
| import type { StreamChunk } from '@tanstack/ai' | ||
|
|
||
| // Schema shared by every turn in this conversation. The point of this example | ||
| // is to demonstrate that *every* assistant message carries its own typed | ||
| // structured-output part — old turns don't get blown away by new ones. | ||
| export const RecipeSchema = z.object({ | ||
| title: z.string().describe('A short title for the recipe'), | ||
| cuisine: z.string().describe('Cuisine label, e.g. "Italian", "Mexican"'), | ||
| servings: z.number().int().min(1).describe('Number of servings'), | ||
| estimatedCostUsd: z | ||
| .number() | ||
| .min(0) | ||
| .describe('Rough total grocery cost in USD'), | ||
| ingredients: z | ||
| .array( | ||
| z.object({ | ||
| item: z.string(), | ||
| amount: z.string().describe('Quantity with unit, e.g. "200 g"'), | ||
| }), | ||
| ) | ||
| .min(1), | ||
| steps: z.array(z.string()).min(1).describe('Numbered cooking steps'), | ||
| tips: z.array(z.string()).default([]), | ||
| }) | ||
|
|
||
| export type Recipe = z.infer<typeof RecipeSchema> | ||
|
|
||
| const SYSTEM_PROMPT = `You are a chef assistant that always responds with a single recipe matching the provided JSON schema. When the user asks for modifications, produce a new recipe in the same shape that reflects the change. Stay terse — short titles, short steps.` | ||
|
|
||
| export const Route = createFileRoute('/api/structured-chat')({ | ||
| server: { | ||
| handlers: { | ||
| POST: async ({ request }) => { | ||
| if (request.signal.aborted) { | ||
| return new Response(null, { status: 499 }) | ||
| } | ||
|
|
||
| const abortController = new AbortController() | ||
| request.signal.addEventListener('abort', () => abortController.abort()) | ||
|
|
||
| let params | ||
| try { | ||
| params = await chatParamsFromRequestBody(await request.json()) | ||
| } catch (error) { | ||
| return new Response( | ||
| error instanceof Error ? error.message : 'Bad request', | ||
| { status: 400 }, | ||
| ) | ||
| } | ||
|
|
||
| try { | ||
| // Cast to AsyncIterable<StreamChunk> for the SSE serializer; the | ||
| // structured-output stream variant carries the extra `start` / | ||
| // `complete` custom events that don't appear in the AGUIEvent | ||
| // union but are valid StreamChunks at runtime. | ||
| const stream = chat({ | ||
| adapter: openaiText('gpt-4o'), | ||
| messages: params.messages, | ||
| systemPrompts: [SYSTEM_PROMPT], | ||
| outputSchema: RecipeSchema, | ||
| stream: true, | ||
| threadId: params.threadId, | ||
| runId: params.runId, | ||
| abortController, | ||
| }) as AsyncIterable<StreamChunk> | ||
| return toServerSentEventsResponse(stream, { abortController }) | ||
| } catch (error) { | ||
| const message = | ||
| error instanceof Error ? error.message : 'An error occurred' | ||
| console.error('[api/structured-chat] Error:', error) | ||
| return new Response(JSON.stringify({ error: message }), { | ||
| status: 500, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| }) | ||
| } | ||
| }, | ||
| }, | ||
| }, | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.