From ddf89c14852117a955984188dd201e6d50967cbd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Jan 2026 01:38:36 +0000 Subject: [PATCH 1/3] feat: Add MDX tree streaming example This commit introduces an example demonstrating how to stream MDX tree data and patches to the client for real-time rendering. Co-authored-by: jcourson8 --- .../api/use-chat-mdx-tree-patches/route.ts | 115 +++++++++++++ .../app/use-chat-mdx-tree-patches/page.tsx | 154 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts create mode 100644 examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx diff --git a/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts b/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts new file mode 100644 index 000000000000..2d890d89bacf --- /dev/null +++ b/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts @@ -0,0 +1,115 @@ +import { openai } from '@ai-sdk/openai'; +import { + convertToModelMessages, + createUIMessageStream, + createUIMessageStreamResponse, + stepCountIs, + streamText, +} from 'ai'; +import type { UIMessageChunk } from 'ai'; + +type MdxLikeNode = + | { type: 'root'; children: MdxLikeNode[] } + | { type: 'paragraph'; children: MdxLikeNode[] } + | { type: 'text'; value: string }; + +type MdxTreeData = { rootId: string; tree: MdxLikeNode }; +type MdxAppendPatchData = { + rootId: string; + path: string; + append: string; +}; + +const ROOT_ID = 'mdx-root'; +const TEXT_VALUE_PATH = '/children/0/children/0/value'; + +function createInitialTree(): MdxLikeNode { + return { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: '' }], + }, + ], + }; +} + +function setTextValue(tree: MdxLikeNode, value: string): MdxLikeNode { + if ( + tree.type !== 'root' || + tree.children[0]?.type !== 'paragraph' || + tree.children[0].children[0]?.type !== 'text' + ) { + return tree; + } + + return { + ...tree, + children: [ + { + ...tree.children[0], + children: [{ type: 'text', value }], + }, + ], + }; +} + +export async function POST(req: Request) { + const { messages } = (await req.json()) as { messages: unknown[] }; + const modelMessages = await convertToModelMessages(messages); + + const stream = createUIMessageStream({ + execute: ({ writer }) => { + // Send an initial "document tree" snapshot. + writer.write({ + type: 'data-mdxTree', + id: ROOT_ID, + data: { rootId: ROOT_ID, tree: createInitialTree() } satisfies MdxTreeData, + } satisfies UIMessageChunk); + + let fullText = ''; + + const result = streamText({ + model: openai('gpt-4o'), + stopWhen: stepCountIs(1), + messages: modelMessages, + onChunk: ({ chunk }) => { + if (chunk.type !== 'text-delta' || chunk.text.length === 0) { + return; + } + + fullText += chunk.text; + + // Stream an incremental "append patch" that the client can apply to its local tree. + // Marked as transient so it doesn't bloat message history, but still triggers onData(). + writer.write({ + type: 'data-mdxPatch', + data: { + rootId: ROOT_ID, + path: TEXT_VALUE_PATH, + append: chunk.text, + } satisfies MdxAppendPatchData, + transient: true, + } satisfies UIMessageChunk); + }, + onFinish: () => { + // Send a final snapshot for recovery / persistence. + writer.write({ + type: 'data-mdxTree', + id: ROOT_ID, + data: { + rootId: ROOT_ID, + tree: setTextValue(createInitialTree(), fullText), + } satisfies MdxTreeData, + } satisfies UIMessageChunk); + }, + }); + + writer.merge(result.toUIMessageStream()); + }, + }); + + return createUIMessageStreamResponse({ stream }); +} + diff --git a/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx b/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx new file mode 100644 index 000000000000..5872c17598e2 --- /dev/null +++ b/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx @@ -0,0 +1,154 @@ +'use client'; + +import ChatInput from '@/components/chat-input'; +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport, UIMessage, type FinishReason } from 'ai'; +import { useMemo, useState } from 'react'; + +type MdxLikeNode = + | { type: 'root'; children: MdxLikeNode[] } + | { type: 'paragraph'; children: MdxLikeNode[] } + | { type: 'text'; value: string }; + +type MdxTreeData = { rootId: string; tree: MdxLikeNode }; +type MdxAppendPatchData = { rootId: string; path: string; append: string }; + +type MyMessage = UIMessage< + never, + { + mdxTree: MdxTreeData; + mdxPatch: MdxAppendPatchData; + } +>; + +function getTextValue(tree: MdxLikeNode | null): string { + if ( + tree?.type !== 'root' || + tree.children[0]?.type !== 'paragraph' || + tree.children[0].children[0]?.type !== 'text' + ) { + return ''; + } + + return tree.children[0].children[0].value; +} + +function applyAppendPatch(tree: MdxLikeNode | null, patch: MdxAppendPatchData) { + // Demo patcher: expects the server to patch the single text node. + // In a real app, you'd implement a proper JSON Pointer patcher + // (or jsondiffpatch/JSON Patch) and handle multiple nodes. + if (tree == null) return tree; + + if (patch.path !== '/children/0/children/0/value') return tree; + + if ( + tree.type !== 'root' || + tree.children[0]?.type !== 'paragraph' || + tree.children[0].children[0]?.type !== 'text' + ) { + return tree; + } + + const currentValue = tree.children[0].children[0].value; + + return { + ...tree, + children: [ + { + ...tree.children[0], + children: [{ type: 'text', value: currentValue + patch.append }], + }, + ], + } satisfies MdxLikeNode; +} + +export default function Chat() { + const [lastFinishReason, setLastFinishReason] = useState< + FinishReason | undefined + >(undefined); + const [tree, setTree] = useState(null); + + const { error, status, sendMessage, messages, regenerate, stop } = + useChat({ + transport: new DefaultChatTransport({ + api: '/api/use-chat-mdx-tree-patches', + }), + onData: dataPart => { + if (dataPart.type === 'data-mdxTree') { + setTree(dataPart.data.tree); + } + + if (dataPart.type === 'data-mdxPatch') { + setTree(prev => applyAppendPatch(prev, dataPart.data)); + } + }, + onFinish: ({ finishReason }) => { + setLastFinishReason(finishReason); + }, + }); + + const treeText = useMemo(() => getTextValue(tree), [tree]); + + return ( +
+
+
Rendered from streamed “MDX tree”
+
{treeText}
+
+ + {messages.map(message => ( +
+ {message.role === 'user' ? 'User: ' : 'AI: '} + {message.parts.map((part, index) => { + if (part.type === 'text') { + return {part.text}; + } + if (part.type === 'data-mdxTree') { + return ( +
+                  {JSON.stringify(part.data.tree, null, 2)}
+                
+ ); + } + return null; + })} +
+ ))} + + {(status === 'submitted' || status === 'streaming') && ( +
+ {status === 'submitted' &&
Loading...
} + +
+ )} + + {error && ( +
+
An error occurred.
+ +
+ )} + + {messages.length > 0 && ( +
+ Finish reason: {String(lastFinishReason)} +
+ )} + + sendMessage({ text })} /> +
+ ); +} + From e1ba1f5f2f4d2a86e5790841828974306c1e7dea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Jan 2026 04:07:09 +0000 Subject: [PATCH 2/3] feat: Add streaming JSON document tree + patches Co-authored-by: jcourson8 --- content/docs/02-getting-started/07-expo.mdx | 8 ++ .../docs/04-ai-sdk-ui/20-streaming-data.mdx | 107 ++++++++++++++++++ examples/next-openai/README.md | 7 ++ .../api/use-chat-mdx-tree-patches/route.ts | 6 +- .../app/use-chat-mdx-tree-patches/page.tsx | 1 - 5 files changed, 126 insertions(+), 3 deletions(-) diff --git a/content/docs/02-getting-started/07-expo.mdx b/content/docs/02-getting-started/07-expo.mdx index 14c109ccd19b..095c22e5f39d 100644 --- a/content/docs/02-getting-started/07-expo.mdx +++ b/content/docs/02-getting-started/07-expo.mdx @@ -172,6 +172,14 @@ Pick the approach that best matches how you want to manage providers across your Now that you have an API route that can query an LLM, it's time to setup your frontend. The AI SDK's [ UI ](/docs/ai-sdk-ui) package abstracts the complexity of a chat interface into one hook, [`useChat`](/docs/reference/ai-sdk-ui/use-chat). + + If you need high-performance rich rendering on-device (e.g. markdown/MDX in + React Native), you can stream a pre-parsed JSON “document tree” and + incremental patches alongside the model output using [`data-*` UI + parts](/docs/ai-sdk-ui/streaming-data#streaming-a-json-document-tree--patches-great-for-react-native). + This keeps the `useChat` DX while doing less work on the client. + + Update your root page (`app/(tabs)/index.tsx`) with the following code to show a list of chat messages and provide a user message input: ```tsx filename="app/(tabs)/index.tsx" diff --git a/content/docs/04-ai-sdk-ui/20-streaming-data.mdx b/content/docs/04-ai-sdk-ui/20-streaming-data.mdx index c85e678c29f6..a5832db53a40 100644 --- a/content/docs/04-ai-sdk-ui/20-streaming-data.mdx +++ b/content/docs/04-ai-sdk-ui/20-streaming-data.mdx @@ -192,6 +192,113 @@ When you write to a data part with the same ID, the client automatically reconci The reconciliation happens automatically - simply use the same `id` when writing to the stream. +## Streaming a JSON “document tree” + patches (great for React Native) + +If markdown/MDX parsing is expensive on the client (common in React Native), you can do **less work on-device** by streaming a **pre-parsed JSON tree** (AST) from the server and then streaming **patches** as the model response streams. + +This pattern keeps the `useChat` DX (tools, status, stop/regenerate, persistence) but lets the UI render from structured data instead of parsing markdown strings. + +### Recommended shape + +- **`data-mdxTree` (persistent)**: occasional full snapshots of the current tree (use `id` so it updates in place) +- **`data-mdxPatch` (transient)**: frequent incremental patches (set `transient: true` so message history does not grow) + +You can use any patch protocol you want (JSON Patch, jsondiffpatch, a custom protocol). The SDK will deliver these `data-*` chunks through `onData`. + +### Server example + +```ts filename="app/api/chat/route.ts" +import { streamText, convertToModelMessages } from 'ai'; +import { createUIMessageStream, createUIMessageStreamResponse } from 'ai'; +import { openai } from '@ai-sdk/openai'; + +type MdxTree = unknown; // your AST type +type Patch = unknown; // your patch protocol + +export async function POST(req: Request) { + const { messages } = await req.json(); + const modelMessages = await convertToModelMessages(messages); + + const stream = createUIMessageStream({ + execute: ({ writer }) => { + // 1) Initial tree snapshot (stored on the message) + writer.write({ + type: 'data-mdxTree', + id: 'root', + data: { tree: /* initial AST */ null as unknown as MdxTree }, + }); + + const result = streamText({ + model: openai('gpt-4o'), + messages: modelMessages, + onChunk: ({ chunk }) => { + if (chunk.type !== 'text-delta' || chunk.text.length === 0) return; + + // 2) Send a patch (transient: doesn't bloat message history) + writer.write({ + type: 'data-mdxPatch', + data: { + patch: /* derived from chunk.text */ null as unknown as Patch, + }, + transient: true, + }); + }, + onFinish: () => { + // 3) Optional final snapshot for recovery/persistence + writer.write({ + type: 'data-mdxTree', + id: 'root', + data: { tree: /* final AST */ null as unknown as MdxTree }, + }); + }, + }); + + writer.merge(result.toUIMessageStream()); + }, + }); + + return createUIMessageStreamResponse({ stream }); +} +``` + +### React Native / Expo client example + +In React Native you typically keep the “tree cache” in your own state/store and render from it. The key integration point is `onData`, and if you use Expo you can provide `expo/fetch` to the transport: + +```tsx filename="app/(tabs)/index.tsx" +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; +import { fetch as expoFetch } from 'expo/fetch'; +import { useState } from 'react'; + +type MdxTree = unknown; +type Patch = unknown; + +export default function ChatScreen() { + const [tree, setTree] = useState(null); + + useChat({ + transport: new DefaultChatTransport({ + api: 'https://your-server.example.com/api/chat', + fetch: expoFetch as unknown as typeof globalThis.fetch, + }), + onData: part => { + if (part.type === 'data-mdxTree') { + setTree(part.data.tree); + } + + if (part.type === 'data-mdxPatch') { + // Apply patch to your local cache: + // setTree(prev => applyPatch(prev, part.data.patch)) + } + }, + }); + + // Render from `tree` (no markdown parsing required) + return null; +} +``` + ## Processing Data on the Client ### Using the onData Callback diff --git a/examples/next-openai/README.md b/examples/next-openai/README.md index 96daa3e2131a..040536522564 100644 --- a/examples/next-openai/README.md +++ b/examples/next-openai/README.md @@ -41,3 +41,10 @@ To learn more about OpenAI, Next.js, and the AI SDK take a look at the following - [Vercel AI Playground](https://ai-sdk.dev/playground) - [OpenAI Documentation](https://platform.openai.com/docs) - learn about OpenAI features and API. - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. + +## Included demos + +This example app includes many routes under `app/` and `app/api/`. Two useful patterns related to streaming custom UI data: + +- **Streaming data UI parts**: `app/use-chat-data-ui-parts` and `app/api/use-chat-data-ui-parts` +- **Streaming a JSON “document tree” + patches** (markdown/MDX-friendly): `app/use-chat-mdx-tree-patches` and `app/api/use-chat-mdx-tree-patches` diff --git a/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts b/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts index 2d890d89bacf..d085051bdf4e 100644 --- a/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts +++ b/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts @@ -65,7 +65,10 @@ export async function POST(req: Request) { writer.write({ type: 'data-mdxTree', id: ROOT_ID, - data: { rootId: ROOT_ID, tree: createInitialTree() } satisfies MdxTreeData, + data: { + rootId: ROOT_ID, + tree: createInitialTree(), + } satisfies MdxTreeData, } satisfies UIMessageChunk); let fullText = ''; @@ -112,4 +115,3 @@ export async function POST(req: Request) { return createUIMessageStreamResponse({ stream }); } - diff --git a/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx b/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx index 5872c17598e2..230ad3a5c946 100644 --- a/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx +++ b/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx @@ -151,4 +151,3 @@ export default function Chat() { ); } - From 30605feb43db9992fd6355f7106857a1f0e3e58d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Jan 2026 04:32:42 +0000 Subject: [PATCH 3/3] feat: Add MDX tree streaming and rendering utilities Co-authored-by: jcourson8 --- .../docs/04-ai-sdk-ui/20-streaming-data.mdx | 18 +- .../api/use-chat-mdx-tree-patches/route.ts | 81 ++++---- .../app/use-chat-mdx-tree-patches/page.tsx | 77 +++---- .../src/ui/convert-remark-mdx-to-mdx-tree.ts | 190 +++++++++++++++++ packages/ai/src/ui/index.ts | 12 ++ packages/ai/src/ui/mdx-tree.test.ts | 47 +++++ packages/ai/src/ui/mdx-tree.ts | 191 ++++++++++++++++++ packages/react/src/index.ts | 1 + packages/react/src/render-mdx-tree.tsx | 52 +++++ 9 files changed, 573 insertions(+), 96 deletions(-) create mode 100644 packages/ai/src/ui/convert-remark-mdx-to-mdx-tree.ts create mode 100644 packages/ai/src/ui/mdx-tree.test.ts create mode 100644 packages/ai/src/ui/mdx-tree.ts create mode 100644 packages/react/src/render-mdx-tree.tsx diff --git a/content/docs/04-ai-sdk-ui/20-streaming-data.mdx b/content/docs/04-ai-sdk-ui/20-streaming-data.mdx index a5832db53a40..a6693b8ae044 100644 --- a/content/docs/04-ai-sdk-ui/20-streaming-data.mdx +++ b/content/docs/04-ai-sdk-ui/20-streaming-data.mdx @@ -205,6 +205,20 @@ This pattern keeps the `useChat` DX (tools, status, stop/regenerate, persistence You can use any patch protocol you want (JSON Patch, jsondiffpatch, a custom protocol). The SDK will deliver these `data-*` chunks through `onData`. +To make this easier, the `ai` package ships a minimal, JSON-serializable tree format and patch helpers: + +- `MdxTree` / `MdxTreeNode` +- `MdxTreePatch` +- `applyMdxTreePatch()` / `applyMdxTreePatches()` + +And `@ai-sdk/react` provides a renderer: + +- `renderMdxTree()` + +If you parse MDX with remark/unified on the server, you can also use: + +- `convertRemarkMdxToMdxTree()` + ### Server example ```ts filename="app/api/chat/route.ts" @@ -212,8 +226,8 @@ import { streamText, convertToModelMessages } from 'ai'; import { createUIMessageStream, createUIMessageStreamResponse } from 'ai'; import { openai } from '@ai-sdk/openai'; -type MdxTree = unknown; // your AST type -type Patch = unknown; // your patch protocol +type MdxTree = unknown; // your AST type (or use `MdxTree` from 'ai') +type Patch = unknown; // your patch protocol (or use `MdxTreePatch` from 'ai') export async function POST(req: Request) { const { messages } = await req.json(); diff --git a/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts b/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts index d085051bdf4e..9061d8961937 100644 --- a/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts +++ b/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts @@ -3,58 +3,33 @@ import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, + type MdxTree, + type MdxTreePatch, stepCountIs, streamText, } from 'ai'; import type { UIMessageChunk } from 'ai'; -type MdxLikeNode = - | { type: 'root'; children: MdxLikeNode[] } - | { type: 'paragraph'; children: MdxLikeNode[] } - | { type: 'text'; value: string }; - -type MdxTreeData = { rootId: string; tree: MdxLikeNode }; -type MdxAppendPatchData = { - rootId: string; - path: string; - append: string; -}; +type MdxTreeData = { rootId: string; tree: MdxTree }; +type MdxPatchData = { rootId: string; patch: MdxTreePatch }; const ROOT_ID = 'mdx-root'; const TEXT_VALUE_PATH = '/children/0/children/0/value'; -function createInitialTree(): MdxLikeNode { +function createInitialTree(): MdxTree { return { - type: 'root', + type: 'element', + name: 'root', children: [ { - type: 'paragraph', + type: 'element', + name: 'p', children: [{ type: 'text', value: '' }], }, ], }; } -function setTextValue(tree: MdxLikeNode, value: string): MdxLikeNode { - if ( - tree.type !== 'root' || - tree.children[0]?.type !== 'paragraph' || - tree.children[0].children[0]?.type !== 'text' - ) { - return tree; - } - - return { - ...tree, - children: [ - { - ...tree.children[0], - children: [{ type: 'text', value }], - }, - ], - }; -} - export async function POST(req: Request) { const { messages } = (await req.json()) as { messages: unknown[] }; const modelMessages = await convertToModelMessages(messages); @@ -84,17 +59,20 @@ export async function POST(req: Request) { fullText += chunk.text; - // Stream an incremental "append patch" that the client can apply to its local tree. + // Stream an incremental "tree patch" that the client can apply to its local tree. // Marked as transient so it doesn't bloat message history, but still triggers onData(). writer.write({ type: 'data-mdxPatch', data: { rootId: ROOT_ID, - path: TEXT_VALUE_PATH, - append: chunk.text, - } satisfies MdxAppendPatchData, + patch: { + op: 'append-text', + path: TEXT_VALUE_PATH, + text: chunk.text, + } satisfies MdxTreePatch, + } satisfies MdxPatchData, transient: true, - } satisfies UIMessageChunk); + } satisfies UIMessageChunk); }, onFinish: () => { // Send a final snapshot for recovery / persistence. @@ -103,7 +81,7 @@ export async function POST(req: Request) { id: ROOT_ID, data: { rootId: ROOT_ID, - tree: setTextValue(createInitialTree(), fullText), + tree: applyTextValue(createInitialTree(), fullText), } satisfies MdxTreeData, } satisfies UIMessageChunk); }, @@ -115,3 +93,26 @@ export async function POST(req: Request) { return createUIMessageStreamResponse({ stream }); } + +function applyTextValue(tree: MdxTree, value: string): MdxTree { + // Demo helper: this route uses a fixed document shape with one text node. + // Consumers should use the exported `applyMdxTreePatch` helper with JSON pointers. + if ( + tree.type !== 'element' || + tree.name !== 'root' || + tree.children?.[0]?.type !== 'element' || + tree.children[0].children?.[0]?.type !== 'text' + ) { + return tree; + } + + return { + ...tree, + children: [ + { + ...tree.children[0], + children: [{ type: 'text', value }], + }, + ], + }; +} diff --git a/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx b/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx index 230ad3a5c946..56b33f42067f 100644 --- a/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx +++ b/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx @@ -2,71 +2,33 @@ import ChatInput from '@/components/chat-input'; import { useChat } from '@ai-sdk/react'; -import { DefaultChatTransport, UIMessage, type FinishReason } from 'ai'; +import { renderMdxTree } from '@ai-sdk/react'; +import { + applyMdxTreePatch, + DefaultChatTransport, + type FinishReason, + type MdxTree, + type MdxTreePatch, + UIMessage, +} from 'ai'; import { useMemo, useState } from 'react'; -type MdxLikeNode = - | { type: 'root'; children: MdxLikeNode[] } - | { type: 'paragraph'; children: MdxLikeNode[] } - | { type: 'text'; value: string }; - -type MdxTreeData = { rootId: string; tree: MdxLikeNode }; -type MdxAppendPatchData = { rootId: string; path: string; append: string }; +type MdxTreeData = { rootId: string; tree: MdxTree }; +type MdxPatchData = { rootId: string; patch: MdxTreePatch }; type MyMessage = UIMessage< never, { mdxTree: MdxTreeData; - mdxPatch: MdxAppendPatchData; + mdxPatch: MdxPatchData; } >; -function getTextValue(tree: MdxLikeNode | null): string { - if ( - tree?.type !== 'root' || - tree.children[0]?.type !== 'paragraph' || - tree.children[0].children[0]?.type !== 'text' - ) { - return ''; - } - - return tree.children[0].children[0].value; -} - -function applyAppendPatch(tree: MdxLikeNode | null, patch: MdxAppendPatchData) { - // Demo patcher: expects the server to patch the single text node. - // In a real app, you'd implement a proper JSON Pointer patcher - // (or jsondiffpatch/JSON Patch) and handle multiple nodes. - if (tree == null) return tree; - - if (patch.path !== '/children/0/children/0/value') return tree; - - if ( - tree.type !== 'root' || - tree.children[0]?.type !== 'paragraph' || - tree.children[0].children[0]?.type !== 'text' - ) { - return tree; - } - - const currentValue = tree.children[0].children[0].value; - - return { - ...tree, - children: [ - { - ...tree.children[0], - children: [{ type: 'text', value: currentValue + patch.append }], - }, - ], - } satisfies MdxLikeNode; -} - export default function Chat() { const [lastFinishReason, setLastFinishReason] = useState< FinishReason | undefined >(undefined); - const [tree, setTree] = useState(null); + const [tree, setTree] = useState(null); const { error, status, sendMessage, messages, regenerate, stop } = useChat({ @@ -79,7 +41,9 @@ export default function Chat() { } if (dataPart.type === 'data-mdxPatch') { - setTree(prev => applyAppendPatch(prev, dataPart.data)); + setTree(prev => + prev != null ? applyMdxTreePatch(prev, dataPart.data.patch) : prev, + ); } }, onFinish: ({ finishReason }) => { @@ -87,13 +51,18 @@ export default function Chat() { }, }); - const treeText = useMemo(() => getTextValue(tree), [tree]); + const rendered = useMemo(() => { + if (tree == null) return null; + + // Web demo: uses tag names directly. In React Native, you would map 'p', 'strong', etc. + return renderMdxTree(tree); + }, [tree]); return (
Rendered from streamed “MDX tree”
-
{treeText}
+
{rendered}
{messages.map(message => ( diff --git a/packages/ai/src/ui/convert-remark-mdx-to-mdx-tree.ts b/packages/ai/src/ui/convert-remark-mdx-to-mdx-tree.ts new file mode 100644 index 000000000000..f827a5fd9b30 --- /dev/null +++ b/packages/ai/src/ui/convert-remark-mdx-to-mdx-tree.ts @@ -0,0 +1,190 @@ +import type { MdxTree, MdxTreeElementNode, MdxTreeNode } from './mdx-tree'; + +/** + * Converts a remark/MDX AST (mdast) into the AI SDK's JSON-serializable `MdxTree`. + * + * This helper intentionally does **not** depend on remark/unified packages. + * It accepts `unknown` input and converts common mdast node shapes by convention. + * + * Typical server usage: + * + * - Parse markdown/MDX with your preferred remark pipeline (e.g. unified + remark-parse + remark-mdx) + * - Convert the resulting mdast into `MdxTree` with this function + * - Stream `data-mdxTree` snapshots / `data-mdxPatch` patches to clients + */ +export function convertRemarkMdxToMdxTree(input: unknown): MdxTree { + return convertNode(input); +} + +type AnyRecord = Record; + +function isRecord(value: unknown): value is AnyRecord { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function convertChildren(node: AnyRecord): MdxTreeNode[] { + const children = asArray(node.children); + return children.map(convertNode).filter((x): x is MdxTreeNode => x != null); +} + +function element( + name: string, + options: { props?: Record; children?: MdxTreeNode[] } = {}, +): MdxTreeElementNode { + return { + type: 'element', + name, + ...(options.props != null ? { props: options.props } : {}), + ...(options.children != null ? { children: options.children } : {}), + }; +} + +function convertNode(node: unknown): MdxTreeNode { + if (!isRecord(node)) { + // Non-object nodes (or null/undefined) are not part of mdast; treat as empty text. + return { type: 'text', value: '' }; + } + + const type = node.type; + if (typeof type !== 'string') { + return { type: 'text', value: '' }; + } + + switch (type) { + case 'root': + return element('root', { children: convertChildren(node) }); + + case 'paragraph': + return element('p', { children: convertChildren(node) }); + + case 'heading': { + const depth = typeof node.depth === 'number' ? node.depth : 1; + const d = Math.min(6, Math.max(1, depth)); + return element(`h${d}`, { children: convertChildren(node) }); + } + + case 'text': + return { + type: 'text', + value: typeof node.value === 'string' ? node.value : '', + }; + + case 'emphasis': + return element('em', { children: convertChildren(node) }); + + case 'strong': + return element('strong', { children: convertChildren(node) }); + + case 'delete': + return element('del', { children: convertChildren(node) }); + + case 'inlineCode': + return element('code', { + props: {}, + children: [ + { + type: 'text', + value: typeof node.value === 'string' ? node.value : '', + }, + ], + }); + + case 'code': { + const value = typeof node.value === 'string' ? node.value : ''; + const lang = typeof node.lang === 'string' ? node.lang : undefined; + return element('pre', { + children: [ + element('code', { + props: lang != null ? { lang } : {}, + children: [{ type: 'text', value }], + }), + ], + }); + } + + case 'link': { + const href = typeof node.url === 'string' ? node.url : undefined; + const title = typeof node.title === 'string' ? node.title : undefined; + return element('a', { + props: { + ...(href != null ? { href } : {}), + ...(title != null ? { title } : {}), + }, + children: convertChildren(node), + }); + } + + case 'list': { + const ordered = node.ordered === true; + const start = typeof node.start === 'number' ? node.start : undefined; + return element(ordered ? 'ol' : 'ul', { + props: ordered && start != null ? { start } : {}, + children: convertChildren(node), + }); + } + + case 'listItem': + return element('li', { children: convertChildren(node) }); + + case 'blockquote': + return element('blockquote', { children: convertChildren(node) }); + + case 'thematicBreak': + return element('hr'); + + case 'break': + return element('br'); + + case 'image': { + const src = typeof node.url === 'string' ? node.url : undefined; + const alt = typeof node.alt === 'string' ? node.alt : undefined; + const title = typeof node.title === 'string' ? node.title : undefined; + return element('img', { + props: { + ...(src != null ? { src } : {}), + ...(alt != null ? { alt } : {}), + ...(title != null ? { title } : {}), + }, + }); + } + + // MDX JSX components: + case 'mdxJsxFlowElement': + case 'mdxJsxTextElement': { + const name = typeof node.name === 'string' ? node.name : 'Component'; + const props = convertMdxJsxAttributes(node.attributes); + return element(name, { props, children: convertChildren(node) }); + } + + // Fallback: preserve structure by mapping unknown nodes to a span. + default: + return element('span', { children: convertChildren(node) }); + } +} + +function convertMdxJsxAttributes(attributes: unknown): Record { + const out: Record = {}; + + for (const attr of asArray(attributes)) { + if (!isRecord(attr)) continue; + if (attr.type !== 'mdxJsxAttribute') continue; + const name = attr.name; + if (typeof name !== 'string') continue; + + const value = attr.value; + // - `` => value null => true + // - `` => string + // - expression values are not evaluated here + if (value == null) { + out[name] = true; + } else if (typeof value === 'string') { + out[name] = value; + } + } + + return out; +} diff --git a/packages/ai/src/ui/index.ts b/packages/ai/src/ui/index.ts index 2727caf48aba..d73eb19ffe2b 100644 --- a/packages/ai/src/ui/index.ts +++ b/packages/ai/src/ui/index.ts @@ -17,6 +17,7 @@ export { export { type ChatTransport } from './chat-transport'; export { convertFileListToFileUIParts } from './convert-file-list-to-file-ui-parts'; export { convertToModelMessages } from './convert-to-model-messages'; +export { convertRemarkMdxToMdxTree } from './convert-remark-mdx-to-mdx-tree'; export { DefaultChatTransport } from './default-chat-transport'; export { DirectChatTransport, @@ -69,3 +70,14 @@ export { validateUIMessages, type SafeValidateUIMessagesResult, } from './validate-ui-messages'; + +// Experimental: JSON-serializable MDX tree + patch utilities. +export { + applyMdxTreePatch, + applyMdxTreePatches, + type MdxTree, + type MdxTreeElementNode, + type MdxTreeNode, + type MdxTreePatch, + type MdxTreeTextNode, +} from './mdx-tree'; diff --git a/packages/ai/src/ui/mdx-tree.test.ts b/packages/ai/src/ui/mdx-tree.test.ts new file mode 100644 index 000000000000..c70a8e1e54cd --- /dev/null +++ b/packages/ai/src/ui/mdx-tree.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { applyMdxTreePatch, type MdxTree } from './mdx-tree'; + +describe('mdx-tree', () => { + it('append-text updates a nested string value', () => { + const tree: MdxTree = { + type: 'element', + name: 'root', + children: [ + { + type: 'element', + name: 'p', + children: [{ type: 'text', value: 'hi' }], + }, + ], + }; + + const updated = applyMdxTreePatch(tree, { + op: 'append-text', + path: '/children/0/children/0/value', + text: ' there', + }); + + expect(updated).toEqual({ + type: 'element', + name: 'root', + children: [ + { + type: 'element', + name: 'p', + children: [{ type: 'text', value: 'hi there' }], + }, + ], + }); + }); + + it('replace can replace the root', () => { + const tree: MdxTree = { type: 'text', value: 'a' }; + const updated = applyMdxTreePatch(tree, { + op: 'replace', + path: '', + value: { type: 'text', value: 'b' }, + }); + + expect(updated).toEqual({ type: 'text', value: 'b' }); + }); +}); diff --git a/packages/ai/src/ui/mdx-tree.ts b/packages/ai/src/ui/mdx-tree.ts new file mode 100644 index 000000000000..ae5a6bc6f661 --- /dev/null +++ b/packages/ai/src/ui/mdx-tree.ts @@ -0,0 +1,191 @@ +export type MdxTreeTextNode = { + type: 'text'; + value: string; +}; + +export type MdxTreeElementNode = { + type: 'element'; + /** + * Tag/component name. + * + * - In web React this can be a tag name like 'p' or 'h1' + * - In React Native you typically map these names to custom components + */ + name: string; + props?: Record; + children?: MdxTreeNode[]; +}; + +/** + * A minimal, JSON-serializable “MDX tree” format that is suitable for streaming + * to clients (including React Native). + * + * The intention is to parse markdown/MDX on the server into this tree and then + * render it with a platform-specific component map on the client. + */ +export type MdxTreeNode = MdxTreeTextNode | MdxTreeElementNode; + +export type MdxTree = MdxTreeNode; + +/** + * A minimal patch format that works well with streaming. + * Uses JSON Pointer paths (RFC 6901). + * + * `append-text` is included because it is very common to incrementally extend + * a string field during streaming (e.g. text nodes) without re-sending the whole tree. + */ +export type MdxTreePatch = + | { op: 'replace'; path: string; value: unknown } + | { op: 'append-text'; path: string; text: string }; + +export function applyMdxTreePatch( + tree: T, + patch: MdxTreePatch, +): T { + switch (patch.op) { + case 'replace': { + return setJsonPointerValue(tree, patch.path, patch.value) as T; + } + case 'append-text': { + return appendJsonPointerText(tree, patch.path, patch.text) as T; + } + default: { + const exhaustiveCheck: never = patch; + throw new Error(`Unknown patch op: ${(exhaustiveCheck as any).op}`); + } + } +} + +export function applyMdxTreePatches( + tree: T, + patches: readonly MdxTreePatch[], +): T { + let current = tree; + for (const patch of patches) { + current = applyMdxTreePatch(current, patch); + } + return current; +} + +function parseJsonPointer(path: string): string[] { + if (path === '') return []; + if (path === '/') return ['']; + + if (!path.startsWith('/')) { + throw new Error(`Invalid JSON pointer "${path}". Must start with "/".`); + } + + return path + .slice(1) + .split('/') + .map(token => token.replaceAll('~1', '/').replaceAll('~0', '~')); +} + +function isIndexToken(token: string): boolean { + // Disallow '-' (JSON Patch "append") because this helper is JSON pointer based. + return token !== '' && /^[0-9]+$/.test(token); +} + +function setJsonPointerValue( + root: unknown, + path: string, + value: unknown, +): unknown { + const tokens = parseJsonPointer(path); + if (tokens.length === 0) return value; + + function update(current: unknown, i: number): unknown { + const token = tokens[i]!; + const isLast = i === tokens.length - 1; + + if (Array.isArray(current)) { + if (!isIndexToken(token)) { + throw new Error( + `Invalid array index token "${token}" for path "${path}".`, + ); + } + const index = Number(token); + const next = current[index]; + + const updatedValue = isLast ? value : update(next, i + 1); + const copy = current.slice(); + copy[index] = updatedValue; + return copy; + } + + if (current != null && typeof current === 'object') { + const obj = current as Record; + const next = obj[token]; + const updatedValue = isLast ? value : update(next, i + 1); + return { ...obj, [token]: updatedValue }; + } + + // We could auto-create containers here, but it’s safer to require the path to exist. + throw new Error( + `Cannot set path "${path}" through non-container value at "${tokens + .slice(0, i) + .join('/')}".`, + ); + } + + return update(root, 0); +} + +function appendJsonPointerText( + root: unknown, + path: string, + text: string, +): unknown { + const tokens = parseJsonPointer(path); + if (tokens.length === 0) { + if (typeof root !== 'string') { + throw new Error(`append-text requires string target at "${path}".`); + } + return root + text; + } + + function update(current: unknown, i: number): unknown { + const token = tokens[i]!; + const isLast = i === tokens.length - 1; + + if (Array.isArray(current)) { + if (!isIndexToken(token)) { + throw new Error( + `Invalid array index token "${token}" for path "${path}".`, + ); + } + const index = Number(token); + const next = current[index]; + + const updatedValue = isLast ? appendLeaf(next) : update(next, i + 1); + const copy = current.slice(); + copy[index] = updatedValue; + return copy; + } + + if (current != null && typeof current === 'object') { + const obj = current as Record; + const next = obj[token]; + const updatedValue = isLast ? appendLeaf(next) : update(next, i + 1); + return { ...obj, [token]: updatedValue }; + } + + throw new Error( + `Cannot append at path "${path}" through non-container value at "${tokens + .slice(0, i) + .join('/')}".`, + ); + } + + function appendLeaf(value: unknown) { + if (value == null) { + return text; + } + if (typeof value !== 'string') { + throw new Error(`append-text requires string target at "${path}".`); + } + return value + text; + } + + return update(root, 0); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 1a267bffe738..82f9d175bac1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -2,3 +2,4 @@ export * from './use-chat'; export { Chat } from './chat.react'; export * from './use-completion'; export * from './use-object'; +export * from './render-mdx-tree'; diff --git a/packages/react/src/render-mdx-tree.tsx b/packages/react/src/render-mdx-tree.tsx new file mode 100644 index 000000000000..1baddfc31a4f --- /dev/null +++ b/packages/react/src/render-mdx-tree.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import type { MdxTreeNode } from 'ai'; + +export type MdxComponentMap = Record< + string, + React.ComponentType | keyof JSX.IntrinsicElements +>; + +/** + * Render a JSON-serializable MDX tree (from `ai`) into React elements. + * + * For React Native, provide a `components` map for tags like `p`, `strong`, `code`, etc. + * (React Native does not support DOM tag strings like 'p'.) + */ +export function renderMdxTree( + node: MdxTreeNode | null | undefined, + options: { + components?: MdxComponentMap; + /** + * Optional wrapper used for the document root. + * In React Native, a common choice is `Text` for purely inline output + * or `View` for block layouts. + */ + Root?: React.ComponentType<{ children: React.ReactNode }>; + } = {}, +): React.ReactNode { + if (node == null) return null; + + const { components = {}, Root } = options; + + function renderNode(current: MdxTreeNode, key: string): React.ReactNode { + if (current.type === 'text') { + return current.value; + } + + const Comp = components[current.name] ?? current.name; + const children = (current.children ?? []).map((child, index) => + renderNode(child, `${key}.${index}`), + ); + + return React.createElement( + Comp as any, + { key, ...(current.props ?? {}) }, + children, + ); + } + + const rendered = renderNode(node, 'mdx'); + + // If a Root wrapper is provided, treat the passed node as a document root: + return Root != null ? {rendered} : rendered; +}