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..a6693b8ae044 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,127 @@ 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`. + +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" +import { streamText, convertToModelMessages } from 'ai'; +import { createUIMessageStream, createUIMessageStreamResponse } from 'ai'; +import { openai } from '@ai-sdk/openai'; + +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(); + 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 new file mode 100644 index 000000000000..9061d8961937 --- /dev/null +++ b/examples/next-openai/app/api/use-chat-mdx-tree-patches/route.ts @@ -0,0 +1,118 @@ +import { openai } from '@ai-sdk/openai'; +import { + convertToModelMessages, + createUIMessageStream, + createUIMessageStreamResponse, + type MdxTree, + type MdxTreePatch, + stepCountIs, + streamText, +} from 'ai'; +import type { UIMessageChunk } from 'ai'; + +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(): MdxTree { + return { + type: 'element', + name: 'root', + children: [ + { + type: 'element', + name: 'p', + 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 "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, + patch: { + op: 'append-text', + path: TEXT_VALUE_PATH, + text: chunk.text, + } satisfies MdxTreePatch, + } satisfies MdxPatchData, + 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: applyTextValue(createInitialTree(), fullText), + } satisfies MdxTreeData, + } satisfies UIMessageChunk); + }, + }); + + writer.merge(result.toUIMessageStream()); + }, + }); + + 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 new file mode 100644 index 000000000000..56b33f42067f --- /dev/null +++ b/examples/next-openai/app/use-chat-mdx-tree-patches/page.tsx @@ -0,0 +1,122 @@ +'use client'; + +import ChatInput from '@/components/chat-input'; +import { useChat } from '@ai-sdk/react'; +import { renderMdxTree } from '@ai-sdk/react'; +import { + applyMdxTreePatch, + DefaultChatTransport, + type FinishReason, + type MdxTree, + type MdxTreePatch, + UIMessage, +} from 'ai'; +import { useMemo, useState } from 'react'; + +type MdxTreeData = { rootId: string; tree: MdxTree }; +type MdxPatchData = { rootId: string; patch: MdxTreePatch }; + +type MyMessage = UIMessage< + never, + { + mdxTree: MdxTreeData; + mdxPatch: MdxPatchData; + } +>; + +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 => + prev != null ? applyMdxTreePatch(prev, dataPart.data.patch) : prev, + ); + } + }, + onFinish: ({ finishReason }) => { + setLastFinishReason(finishReason); + }, + }); + + 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”
+
{rendered}
+
+ + {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 })} /> +
+ ); +} 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; +}