From 31e993b14f6cea0d114dda441734b26360da12b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Jan 2026 21:58:04 +0000 Subject: [PATCH 1/4] feat: Add @ai-sdk/react-native package This commit introduces the @ai-sdk/react-native package, enabling optimized markdown streaming for React Native applications. It includes client-side hooks and server-side utilities for efficient markdown parsing and rendering. Co-authored-by: jcourson8 --- packages/react-native/.eslintrc.js | 3 + packages/react-native/CHANGELOG.md | 11 + packages/react-native/README.md | 171 +++++ packages/react-native/package.json | 77 +++ packages/react-native/server.d.ts | 1 + packages/react-native/src/apply-patch.ts | 139 ++++ packages/react-native/src/index.ts | 55 ++ .../react-native/src/markdown-renderer.tsx | 523 ++++++++++++++ packages/react-native/src/server/index.ts | 53 ++ .../src/server/markdown-parser.test.ts | 426 ++++++++++++ .../src/server/markdown-parser.ts | 645 ++++++++++++++++++ .../src/server/markdown-stream.ts | 370 ++++++++++ .../src/server/markdown-tree-diff.test.ts | 572 ++++++++++++++++ .../src/server/markdown-tree-diff.ts | 318 +++++++++ packages/react-native/src/types.ts | 349 ++++++++++ .../react-native/src/use-markdown-chat.ts | 545 +++++++++++++++ packages/react-native/tsconfig.build.json | 6 + packages/react-native/tsconfig.json | 12 + packages/react-native/tsup.config.ts | 12 + packages/react-native/turbo.json | 8 + packages/react-native/vitest.node.config.js | 9 + pnpm-lock.yaml | 82 ++- tsconfig.json | 1 + 23 files changed, 4364 insertions(+), 24 deletions(-) create mode 100644 packages/react-native/.eslintrc.js create mode 100644 packages/react-native/CHANGELOG.md create mode 100644 packages/react-native/README.md create mode 100644 packages/react-native/package.json create mode 100644 packages/react-native/server.d.ts create mode 100644 packages/react-native/src/apply-patch.ts create mode 100644 packages/react-native/src/index.ts create mode 100644 packages/react-native/src/markdown-renderer.tsx create mode 100644 packages/react-native/src/server/index.ts create mode 100644 packages/react-native/src/server/markdown-parser.test.ts create mode 100644 packages/react-native/src/server/markdown-parser.ts create mode 100644 packages/react-native/src/server/markdown-stream.ts create mode 100644 packages/react-native/src/server/markdown-tree-diff.test.ts create mode 100644 packages/react-native/src/server/markdown-tree-diff.ts create mode 100644 packages/react-native/src/types.ts create mode 100644 packages/react-native/src/use-markdown-chat.ts create mode 100644 packages/react-native/tsconfig.build.json create mode 100644 packages/react-native/tsconfig.json create mode 100644 packages/react-native/tsup.config.ts create mode 100644 packages/react-native/turbo.json create mode 100644 packages/react-native/vitest.node.config.js diff --git a/packages/react-native/.eslintrc.js b/packages/react-native/.eslintrc.js new file mode 100644 index 000000000000..a7f4ef45f804 --- /dev/null +++ b/packages/react-native/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['eslint-config-vercel-ai'], +}; diff --git a/packages/react-native/CHANGELOG.md b/packages/react-native/CHANGELOG.md new file mode 100644 index 000000000000..0415c3b3464c --- /dev/null +++ b/packages/react-native/CHANGELOG.md @@ -0,0 +1,11 @@ +# @ai-sdk/react-native + +## 0.0.1 + +### Features + +- Initial release with markdown tree streaming support +- `useMarkdownChat` hook for React Native +- `MarkdownRenderer` component +- Server-side markdown parsing utilities +- JSON patch-based efficient updates diff --git a/packages/react-native/README.md b/packages/react-native/README.md new file mode 100644 index 000000000000..21908d0fbeaa --- /dev/null +++ b/packages/react-native/README.md @@ -0,0 +1,171 @@ +# AI SDK React Native + +React Native integration for the [AI SDK](https://ai-sdk.dev/docs) with optimized markdown streaming. + +## Overview + +This package provides React Native-optimized hooks and utilities for building AI-powered chat applications. The key innovation is **server-side markdown parsing with JSON tree streaming**, which dramatically improves rendering performance on React Native. + +### The Problem + +Parsing markdown in React Native is expensive. Traditional approaches send raw text to the client, where it must be parsed on every render. This creates: + +- High CPU usage during streaming +- Janky animations and scrolling +- Battery drain on mobile devices + +### The Solution + +Instead of streaming raw markdown text, this package: + +1. **Parses markdown on the server** into a JSON tree structure +2. **Streams JSON patches** to the client for efficient updates +3. **Renders native components** directly from the tree + +This approach, inspired by v0's mobile app, shifts the expensive parsing work to the server where it belongs. + +## Installation + +```bash +npm install @ai-sdk/react-native +``` + +## Usage + +### Client Side (React Native) + +```tsx +import { useMarkdownChat, MarkdownRenderer } from '@ai-sdk/react-native'; +import { View, Text, ScrollView } from 'react-native'; + +function ChatScreen() { + const { messages, sendMessage, status } = useMarkdownChat({ + api: '/api/chat', + }); + + return ( + + {messages.map(message => ( + + {message.role === 'assistant' ? ( + ( + {children} + ), + heading: ({ level, children }) => ( + {children} + ), + code: ({ language, value }) => ( + {value} + ), + // ... more components + }} + /> + ) : ( + {message.content} + )} + + ))} + + ); +} +``` + +### Server Side (Next.js/Express/etc.) + +```typescript +import { streamText } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { createMarkdownStreamResponse } from '@ai-sdk/react-native/server'; + +export async function POST(req: Request) { + const { messages } = await req.json(); + + const result = streamText({ + model: openai('gpt-4o'), + messages, + }); + + // Automatically parses markdown and streams JSON tree patches + return createMarkdownStreamResponse(result); +} +``` + +## API Reference + +### Client Hooks + +#### `useMarkdownChat(options)` + +A React hook for chat interfaces with optimized markdown streaming. + +Options: + +- `api` - The API endpoint URL +- `id` - Optional chat ID +- `initialMessages` - Initial messages array +- `onError` - Error callback +- `onFinish` - Completion callback + +Returns: + +- `messages` - Array of messages with `markdownTree` for assistant messages +- `sendMessage` - Function to send a new message +- `status` - Current status ('ready' | 'streaming' | 'error') +- `stop` - Function to stop streaming +- `error` - Current error if any + +### Server Utilities + +#### `createMarkdownStreamResponse(result, options?)` + +Creates a streaming response that parses markdown and sends JSON tree patches. + +Options: + +- `onChunk` - Callback for each chunk +- `onFinish` - Callback when streaming completes + +#### `parseMarkdownToTree(markdown)` + +Parses a markdown string into a JSON tree structure. + +#### `createMarkdownTreeDiff(oldTree, newTree)` + +Creates a minimal diff/patch between two markdown trees. + +## Markdown Tree Structure + +The JSON tree uses the following node types: + +```typescript +type MarkdownNode = + | { type: 'root'; children: MarkdownNode[] } + | { type: 'paragraph'; children: MarkdownNode[] } + | { type: 'heading'; depth: 1 | 2 | 3 | 4 | 5 | 6; children: MarkdownNode[] } + | { type: 'text'; value: string } + | { type: 'strong'; children: MarkdownNode[] } + | { type: 'emphasis'; children: MarkdownNode[] } + | { type: 'code'; lang?: string; value: string } + | { type: 'inlineCode'; value: string } + | { type: 'link'; url: string; title?: string; children: MarkdownNode[] } + | { type: 'image'; url: string; alt?: string; title?: string } + | { type: 'list'; ordered: boolean; start?: number; children: MarkdownNode[] } + | { type: 'listItem'; children: MarkdownNode[] } + | { type: 'blockquote'; children: MarkdownNode[] } + | { type: 'thematicBreak' } + | { type: 'break' }; +``` + +## Performance Tips + +1. **Memoize custom components** passed to `MarkdownRenderer` +2. **Use `React.memo`** on parent components to prevent unnecessary re-renders +3. **Consider virtualization** for long chat histories + +## License + +Apache-2.0 diff --git a/packages/react-native/package.json b/packages/react-native/package.json new file mode 100644 index 000000000000..382b9a478879 --- /dev/null +++ b/packages/react-native/package.json @@ -0,0 +1,77 @@ +{ + "name": "@ai-sdk/react-native", + "version": "0.0.1", + "description": "AI SDK React Native integration with optimized markdown streaming", + "license": "Apache-2.0", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "scripts": { + "build": "pnpm clean && tsup --tsconfig tsconfig.build.json", + "build:watch": "pnpm clean && tsup --watch --tsconfig tsconfig.build.json", + "clean": "del-cli dist *.tsbuildinfo", + "lint": "eslint \"./**/*.ts*\"", + "type-check": "tsc --build", + "prettier-check": "prettier --check \"./**/*.ts*\"", + "test": "pnpm test:node", + "test:update": "pnpm test:node -u", + "test:watch": "vitest --config vitest.node.config.js", + "test:node": "vitest --config vitest.node.config.js --run" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.mjs", + "require": "./dist/server/index.js" + } + }, + "files": [ + "dist/**/*", + "CHANGELOG.md", + "README.md", + "server.d.ts" + ], + "dependencies": { + "throttleit": "2.1.0" + }, + "devDependencies": { + "@types/node": "20.17.24", + "@types/react": "^18", + "@vercel/ai-tsconfig": "workspace:*", + "eslint": "8.57.1", + "eslint-config-vercel-ai": "workspace:*", + "tsup": "^7.2.0", + "typescript": "5.8.3", + "zod": "3.25.76" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + }, + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + }, + "homepage": "https://ai-sdk.dev/docs", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/ai.git" + }, + "bugs": { + "url": "https://github.com/vercel/ai/issues" + }, + "keywords": [ + "ai", + "react-native", + "markdown", + "streaming" + ] +} diff --git a/packages/react-native/server.d.ts b/packages/react-native/server.d.ts new file mode 100644 index 000000000000..267b702ec4f8 --- /dev/null +++ b/packages/react-native/server.d.ts @@ -0,0 +1 @@ +export * from './dist/server/index'; diff --git a/packages/react-native/src/apply-patch.ts b/packages/react-native/src/apply-patch.ts new file mode 100644 index 000000000000..77ce40cdaaef --- /dev/null +++ b/packages/react-native/src/apply-patch.ts @@ -0,0 +1,139 @@ +import type { + MarkdownRoot, + MarkdownTreePatch, + MarkdownTreePatchOp, +} from './types'; + +/** + * Apply a patch to a markdown tree, returning a new tree. + * + * This is a client-side implementation optimized for React Native. + * It ensures immutability for proper React re-renders. + */ +export function applyMarkdownTreePatch( + tree: MarkdownRoot | null, + patch: MarkdownTreePatch, +): MarkdownRoot { + // Start with a deep clone to ensure immutability + let result = tree + ? structuredClone(tree) + : ({ type: 'root', children: [] } as MarkdownRoot); + + for (const op of patch) { + result = applySingleOp(result, op); + } + + return result; +} + +type AnyObject = { [key: string]: unknown }; + +function applySingleOp( + tree: MarkdownRoot, + op: MarkdownTreePatchOp, +): MarkdownRoot { + if (op.path === '') { + // Replace entire tree + if (op.op === 'replace') { + return op.value as MarkdownRoot; + } + throw new Error(`Invalid operation on root: ${op.op}`); + } + + // Parse the path + const segments = op.path.split('/').filter(Boolean); + + // Navigate to the parent, creating a new path of references + let current: AnyObject | unknown[] = tree as unknown as AnyObject; + const pathToRoot: Array<{ + parent: AnyObject | unknown[]; + key: string | number; + }> = []; + + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + + if (Array.isArray(current)) { + const index = parseInt(segment, 10); + pathToRoot.push({ parent: current, key: index }); + current = current[index] as AnyObject; + } else { + pathToRoot.push({ parent: current, key: segment }); + current = current[segment] as AnyObject; + } + } + + const lastSegment = segments[segments.length - 1]; + + switch (op.op) { + case 'replace': + if (Array.isArray(current)) { + current[parseInt(lastSegment, 10)] = op.value; + } else { + current[lastSegment] = op.value; + } + break; + + case 'add': + if (Array.isArray(current)) { + const index = parseInt(lastSegment, 10); + if (index === current.length) { + current.push(op.value); + } else { + current.splice(index, 0, op.value); + } + } else { + current[lastSegment] = op.value; + } + break; + + case 'remove': + if (Array.isArray(current)) { + current.splice(parseInt(lastSegment, 10), 1); + } else { + delete current[lastSegment]; + } + break; + + case 'append-text': + if (Array.isArray(current)) { + const item = current[parseInt(lastSegment, 10)]; + if (typeof item === 'string') { + current[parseInt(lastSegment, 10)] = item + op.value; + } + } else if (typeof current[lastSegment] === 'string') { + current[lastSegment] = (current[lastSegment] as string) + op.value; + } + break; + } + + return tree; +} + +/** + * Check if a patch is small enough to be efficient. + * Large patches may indicate it's better to use a snapshot. + */ +export function isPatchEfficient( + patch: MarkdownTreePatch, + tree: MarkdownRoot | null, +): boolean { + if (!tree) return true; + + const patchSize = JSON.stringify(patch).length; + const treeSize = JSON.stringify(tree).length; + + // If patch is more than 50% of tree size, snapshot might be better + return patchSize < treeSize * 0.5; +} + +/** + * Merge multiple patches into one. + * Useful for batching rapid updates. + */ +export function mergePatchBatch( + patches: MarkdownTreePatch[], +): MarkdownTreePatch { + // Simple concatenation - could be optimized to remove redundant ops + return patches.flat(); +} diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts new file mode 100644 index 000000000000..3a0391307a5d --- /dev/null +++ b/packages/react-native/src/index.ts @@ -0,0 +1,55 @@ +// Client-side hooks and components +export { + useMarkdownChat, + type MarkdownChatStatus, + type SendMessageOptions, + type UseMarkdownChatHelpers, + type UseMarkdownChatOptions, +} from './use-markdown-chat'; + +export { + createMarkdownComponent, + defaultComponents, + MarkdownRenderer, + type MarkdownRendererProps, +} from './markdown-renderer'; + +export { + applyMarkdownTreePatch, + isPatchEfficient, + mergePatchBatch, +} from './apply-patch'; + +// Types +export type { + MarkdownBlockNode, + MarkdownBlockquote, + MarkdownBreak, + MarkdownCode, + MarkdownComponentProps, + MarkdownComponents, + MarkdownEmphasis, + MarkdownHeading, + MarkdownHtml, + MarkdownImage, + MarkdownInlineCode, + MarkdownInlineNode, + MarkdownLink, + MarkdownList, + MarkdownListItem, + MarkdownMessage, + MarkdownNode, + MarkdownNodeBase, + MarkdownParagraph, + MarkdownRoot, + MarkdownStrikethrough, + MarkdownStrong, + MarkdownTable, + MarkdownTableCell, + MarkdownTableRow, + MarkdownText, + MarkdownThematicBreak, + MarkdownTreeChunk, + MarkdownTreePatch, + MarkdownTreePatchOp, +} from './types'; diff --git a/packages/react-native/src/markdown-renderer.tsx b/packages/react-native/src/markdown-renderer.tsx new file mode 100644 index 000000000000..a0528b7cfb51 --- /dev/null +++ b/packages/react-native/src/markdown-renderer.tsx @@ -0,0 +1,523 @@ +import React, { memo, useMemo } from 'react'; +import type { + MarkdownBlockquote, + MarkdownBreak, + MarkdownCode, + MarkdownComponents, + MarkdownEmphasis, + MarkdownHeading, + MarkdownHtml, + MarkdownImage, + MarkdownInlineCode, + MarkdownLink, + MarkdownList, + MarkdownListItem, + MarkdownNode, + MarkdownParagraph, + MarkdownRoot, + MarkdownStrikethrough, + MarkdownStrong, + MarkdownTable, + MarkdownTableCell, + MarkdownTableRow, + MarkdownText, + MarkdownThematicBreak, +} from './types'; + +/** + * Props for the MarkdownRenderer component. + */ +export interface MarkdownRendererProps { + /** + * The markdown tree to render. + */ + tree: MarkdownRoot | null | undefined; + + /** + * Custom components for rendering markdown elements. + * If not provided, default components are used. + */ + components?: Partial; + + /** + * Additional props to pass to all rendered components. + */ + componentProps?: Record; + + /** + * Key prefix for rendered elements. + * Useful when rendering multiple trees. + */ + keyPrefix?: string; +} + +/** + * Default text component (fallback for React Native Text) + */ +const DefaultText: React.FC<{ + children?: React.ReactNode; + style?: unknown; +}> = ({ children, style }) => { + // This is a fallback - in a real React Native app, you'd use RN's Text + return React.createElement('span', { style }, children); +}; + +/** + * Default view component (fallback for React Native View) + */ +const DefaultView: React.FC<{ + children?: React.ReactNode; + style?: unknown; +}> = ({ children, style }) => { + // This is a fallback - in a real React Native app, you'd use RN's View + return React.createElement('div', { style }, children); +}; + +/** + * Default components for rendering markdown. + * These are designed to work in both web and React Native environments. + */ +const defaultComponents: MarkdownComponents = { + root: ({ children }) => {children}, + + paragraph: ({ children }) => ( + {children} + ), + + heading: ({ level, children }) => { + const sizes: Record = { + 1: 32, + 2: 28, + 3: 24, + 4: 20, + 5: 18, + 6: 16, + }; + return ( + + {children} + + ); + }, + + text: ({ node }) => <>{node.value}, + + strong: ({ children }) => ( + {children} + ), + + emphasis: ({ children }) => ( + {children} + ), + + strikethrough: ({ children }) => ( + + {children} + + ), + + code: ({ language, value }) => ( + + {language && ( + + {language} + + )} + + {value} + + + ), + + inlineCode: ({ value }) => ( + + {value} + + ), + + link: ({ href, children }) => ( + + {children} + + ), + + image: ({ src, alt }) => ( + + [Image: {alt || src}] + + ), + + list: ({ ordered, children }) => ( + {children} + ), + + listItem: ({ children }) => ( + + + {children} + + ), + + blockquote: ({ children }) => ( + + + {children} + + + ), + + thematicBreak: () => ( + + ), + + break: () => {'\n'}, + + table: ({ children }) => ( + {children} + ), + + tableRow: ({ children }) => ( + {children} + ), + + tableCell: ({ children }) => ( + + {children} + + ), + + html: ({ value }) => ( + {value} + ), +}; + +/** + * Render a single markdown node. + */ +function renderNode( + node: MarkdownNode, + components: MarkdownComponents, + keyPrefix: string, + index: number, +): React.ReactNode { + const key = `${keyPrefix}-${index}`; + + switch (node.type) { + case 'root': { + const RootComponent = components.root || defaultComponents.root!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-root`, i), + )} + + ); + } + + case 'paragraph': { + const ParagraphComponent = + components.paragraph || defaultComponents.paragraph!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-p`, i), + )} + + ); + } + + case 'heading': { + const HeadingComponent = components.heading || defaultComponents.heading!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-h`, i), + )} + + ); + } + + case 'text': { + const TextComponent = components.text || defaultComponents.text!; + return ; + } + + case 'strong': { + const StrongComponent = components.strong || defaultComponents.strong!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-strong`, i), + )} + + ); + } + + case 'emphasis': { + const EmphasisComponent = + components.emphasis || defaultComponents.emphasis!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-em`, i), + )} + + ); + } + + case 'strikethrough': { + const StrikethroughComponent = + components.strikethrough || defaultComponents.strikethrough!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-s`, i), + )} + + ); + } + + case 'code': { + const CodeComponent = components.code || defaultComponents.code!; + return ( + + ); + } + + case 'inlineCode': { + const InlineCodeComponent = + components.inlineCode || defaultComponents.inlineCode!; + return ; + } + + case 'link': { + const LinkComponent = components.link || defaultComponents.link!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-link`, i), + )} + + ); + } + + case 'image': { + const ImageComponent = components.image || defaultComponents.image!; + return ( + + ); + } + + case 'list': { + const ListComponent = components.list || defaultComponents.list!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-list`, i), + )} + + ); + } + + case 'listItem': { + const ListItemComponent = + components.listItem || defaultComponents.listItem!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-li`, i), + )} + + ); + } + + case 'blockquote': { + const BlockquoteComponent = + components.blockquote || defaultComponents.blockquote!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-bq`, i), + )} + + ); + } + + case 'thematicBreak': { + const ThematicBreakComponent = + components.thematicBreak || defaultComponents.thematicBreak!; + return ; + } + + case 'break': { + const BreakComponent = components.break || defaultComponents.break!; + return ; + } + + case 'table': { + const TableComponent = components.table || defaultComponents.table!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-table`, i), + )} + + ); + } + + case 'tableRow': { + const TableRowComponent = + components.tableRow || defaultComponents.tableRow!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-tr`, i), + )} + + ); + } + + case 'tableCell': { + const TableCellComponent = + components.tableCell || defaultComponents.tableCell!; + return ( + + {node.children.map((child, i) => + renderNode(child, components, `${key}-td`, i), + )} + + ); + } + + case 'html': { + const HtmlComponent = components.html || defaultComponents.html!; + return ; + } + + default: + console.warn(`Unknown node type: ${(node as MarkdownNode).type}`); + return null; + } +} + +/** + * Renders a markdown tree to React components. + * + * This component is optimized for efficient rendering in React Native. + * It renders the pre-parsed JSON tree directly to components, avoiding + * the need for client-side markdown parsing. + * + * @example + * ```tsx + * import { MarkdownRenderer } from '@ai-sdk/react-native'; + * import { Text, View } from 'react-native'; + * + * function ChatMessage({ message }) { + * return ( + * ( + * {children} + * ), + * code: ({ language, value }) => ( + * {value} + * ), + * }} + * /> + * ); + * } + * ``` + */ +export const MarkdownRenderer = memo(function MarkdownRenderer({ + tree, + components: customComponents = {}, + keyPrefix = 'md', +}: MarkdownRendererProps): React.ReactElement | null { + // Merge custom components with defaults + const components = useMemo( + () => ({ + ...defaultComponents, + ...customComponents, + }), + [customComponents], + ); + + if (!tree) { + return null; + } + + return <>{renderNode(tree, components, keyPrefix, 0)}; +}); + +/** + * HOC to create a memoized markdown component. + * Useful for creating custom renderers that only re-render when the tree changes. + */ +export function createMarkdownComponent

( + Component: React.ComponentType

, + components?: Partial, +): React.ComponentType

{ + return memo(function MarkdownComponent(props: P) { + const renderedTree = useMemo( + () => , + [props.tree], + ); + + return ; + }); +} + +export { defaultComponents }; diff --git a/packages/react-native/src/server/index.ts b/packages/react-native/src/server/index.ts new file mode 100644 index 000000000000..32743629f9bc --- /dev/null +++ b/packages/react-native/src/server/index.ts @@ -0,0 +1,53 @@ +// Server-side markdown parsing and streaming utilities +export { + parseMarkdownToTree, + StreamingMarkdownParser, +} from './markdown-parser'; + +export { + applyMarkdownTreePatch, + createMarkdownTreeDiff, + estimatePatchEfficiency, + isStreamingAppend, +} from './markdown-tree-diff'; + +export { + createMarkdownStreamFromStreamText, + createMarkdownStreamResponse, + createMarkdownStreamResponseFromStreamText, + createMarkdownTreeStream, + type CreateMarkdownStreamResponseOptions, + type MarkdownStreamOptions, + type MarkdownUIMessageChunk, + type MarkdownUIMessageStreamOptions, +} from './markdown-stream'; + +// Re-export types +export type { + MarkdownBlockNode, + MarkdownBlockquote, + MarkdownBreak, + MarkdownCode, + MarkdownEmphasis, + MarkdownHeading, + MarkdownImage, + MarkdownInlineCode, + MarkdownInlineNode, + MarkdownLink, + MarkdownList, + MarkdownListItem, + MarkdownNode, + MarkdownNodeBase, + MarkdownParagraph, + MarkdownRoot, + MarkdownStrikethrough, + MarkdownStrong, + MarkdownTable, + MarkdownTableCell, + MarkdownTableRow, + MarkdownText, + MarkdownThematicBreak, + MarkdownTreeChunk, + MarkdownTreePatch, + MarkdownTreePatchOp, +} from '../types'; diff --git a/packages/react-native/src/server/markdown-parser.test.ts b/packages/react-native/src/server/markdown-parser.test.ts new file mode 100644 index 000000000000..4aa875f24d22 --- /dev/null +++ b/packages/react-native/src/server/markdown-parser.test.ts @@ -0,0 +1,426 @@ +import { describe, it, expect } from 'vitest'; +import { + parseMarkdownToTree, + StreamingMarkdownParser, +} from './markdown-parser'; + +describe('parseMarkdownToTree', () => { + describe('basic elements', () => { + it('should parse plain text as a paragraph', () => { + const result = parseMarkdownToTree('Hello, world!'); + + expect(result).toEqual({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello, world!' }], + }, + ], + }); + }); + + it('should parse headings', () => { + const result = parseMarkdownToTree('# Heading 1\n## Heading 2'); + + expect(result.children).toHaveLength(2); + expect(result.children[0]).toEqual({ + type: 'heading', + depth: 1, + children: [{ type: 'text', value: 'Heading 1' }], + }); + expect(result.children[1]).toEqual({ + type: 'heading', + depth: 2, + children: [{ type: 'text', value: 'Heading 2' }], + }); + }); + + it('should parse all heading levels', () => { + const markdown = `# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6`; + + const result = parseMarkdownToTree(markdown); + + for (let i = 0; i < 6; i++) { + expect(result.children[i]).toMatchObject({ + type: 'heading', + depth: i + 1, + }); + } + }); + }); + + describe('inline formatting', () => { + it('should parse bold text with asterisks', () => { + const result = parseMarkdownToTree('This is **bold** text'); + + expect(result.children[0]).toEqual({ + type: 'paragraph', + children: [ + { type: 'text', value: 'This is ' }, + { + type: 'strong', + children: [{ type: 'text', value: 'bold' }], + }, + { type: 'text', value: ' text' }, + ], + }); + }); + + it('should parse italic text with asterisks', () => { + const result = parseMarkdownToTree('This is *italic* text'); + + expect(result.children[0]).toEqual({ + type: 'paragraph', + children: [ + { type: 'text', value: 'This is ' }, + { + type: 'emphasis', + children: [{ type: 'text', value: 'italic' }], + }, + { type: 'text', value: ' text' }, + ], + }); + }); + + it('should parse inline code', () => { + const result = parseMarkdownToTree('Use `console.log()` for debugging'); + + expect(result.children[0]).toEqual({ + type: 'paragraph', + children: [ + { type: 'text', value: 'Use ' }, + { type: 'inlineCode', value: 'console.log()' }, + { type: 'text', value: ' for debugging' }, + ], + }); + }); + + it('should parse strikethrough', () => { + const result = parseMarkdownToTree('This is ~~deleted~~ text'); + + expect(result.children[0]).toEqual({ + type: 'paragraph', + children: [ + { type: 'text', value: 'This is ' }, + { + type: 'strikethrough', + children: [{ type: 'text', value: 'deleted' }], + }, + { type: 'text', value: ' text' }, + ], + }); + }); + + it('should parse nested inline formatting with separate markers', () => { + // Using separate markers **_text_** for bold-italic + const result = parseMarkdownToTree('This is **_bold and italic_** text'); + + expect(result.children[0]).toMatchObject({ + type: 'paragraph', + children: [ + { type: 'text', value: 'This is ' }, + { + type: 'strong', + children: [ + { + type: 'emphasis', + children: [{ type: 'text', value: 'bold and italic' }], + }, + ], + }, + { type: 'text', value: ' text' }, + ], + }); + }); + }); + + describe('links and images', () => { + it('should parse links', () => { + const result = parseMarkdownToTree( + 'Visit [Google](https://google.com) for search', + ); + + expect(result.children[0]).toEqual({ + type: 'paragraph', + children: [ + { type: 'text', value: 'Visit ' }, + { + type: 'link', + url: 'https://google.com', + title: undefined, + children: [{ type: 'text', value: 'Google' }], + }, + { type: 'text', value: ' for search' }, + ], + }); + }); + + it('should parse links with titles', () => { + const result = parseMarkdownToTree( + '[Link](https://example.com "Example Site")', + ); + + expect(result.children[0].children[0]).toMatchObject({ + type: 'link', + url: 'https://example.com', + title: 'Example Site', + }); + }); + + it('should parse images', () => { + const result = parseMarkdownToTree('![Alt text](image.png "Title")'); + + expect(result.children[0].children[0]).toEqual({ + type: 'image', + url: 'image.png', + alt: 'Alt text', + title: 'Title', + }); + }); + }); + + describe('code blocks', () => { + it('should parse fenced code blocks', () => { + const markdown = `\`\`\`javascript +const x = 1; +console.log(x); +\`\`\``; + + const result = parseMarkdownToTree(markdown); + + expect(result.children[0]).toEqual({ + type: 'code', + lang: 'javascript', + meta: undefined, + value: 'const x = 1;\nconsole.log(x);', + }); + }); + + it('should parse code blocks without language', () => { + const markdown = `\`\`\` +plain code +\`\`\``; + + const result = parseMarkdownToTree(markdown); + + expect(result.children[0]).toEqual({ + type: 'code', + lang: undefined, + meta: undefined, + value: 'plain code', + }); + }); + + it('should handle unclosed code blocks', () => { + const markdown = `\`\`\`javascript +const x = 1; +// streaming...`; + + const result = parseMarkdownToTree(markdown); + + expect(result.children[0]).toMatchObject({ + type: 'code', + lang: 'javascript', + value: 'const x = 1;\n// streaming...', + }); + }); + }); + + describe('lists', () => { + it('should parse unordered lists', () => { + const markdown = `- Item 1 +- Item 2 +- Item 3`; + + const result = parseMarkdownToTree(markdown); + + expect(result.children[0]).toMatchObject({ + type: 'list', + ordered: false, + children: [ + { type: 'listItem' }, + { type: 'listItem' }, + { type: 'listItem' }, + ], + }); + }); + + it('should parse ordered lists', () => { + const markdown = `1. First +2. Second +3. Third`; + + const result = parseMarkdownToTree(markdown); + + expect(result.children[0]).toMatchObject({ + type: 'list', + ordered: true, + }); + }); + }); + + describe('blockquotes', () => { + it('should parse blockquotes', () => { + const markdown = `> This is a quote +> with multiple lines`; + + const result = parseMarkdownToTree(markdown); + + expect(result.children[0]).toMatchObject({ + type: 'blockquote', + }); + }); + }); + + describe('thematic breaks', () => { + it('should parse horizontal rules with dashes', () => { + const result = parseMarkdownToTree('---'); + + expect(result.children[0]).toEqual({ type: 'thematicBreak' }); + }); + + it('should parse horizontal rules with asterisks', () => { + const result = parseMarkdownToTree('***'); + + expect(result.children[0]).toEqual({ type: 'thematicBreak' }); + }); + + it('should parse horizontal rules with underscores', () => { + const result = parseMarkdownToTree('___'); + + expect(result.children[0]).toEqual({ type: 'thematicBreak' }); + }); + }); + + describe('tables', () => { + it('should parse simple tables', () => { + const markdown = `| Header 1 | Header 2 | +|----------|----------| +| Cell 1 | Cell 2 |`; + + const result = parseMarkdownToTree(markdown); + + expect(result.children[0]).toMatchObject({ + type: 'table', + children: [{ type: 'tableRow' }, { type: 'tableRow' }], + }); + }); + }); + + describe('complex documents', () => { + it('should parse a complex markdown document', () => { + const markdown = `# Welcome + +This is a **paragraph** with *formatting*. + +## Code Example + +\`\`\`typescript +function greet(name: string): string { + return \`Hello, \${name}!\`; +} +\`\`\` + +- Item 1 +- Item 2 + +> A blockquote + +--- + +The end.`; + + const result = parseMarkdownToTree(markdown); + + expect(result.children).toHaveLength(8); + expect(result.children[0].type).toBe('heading'); + expect(result.children[1].type).toBe('paragraph'); + expect(result.children[2].type).toBe('heading'); + expect(result.children[3].type).toBe('code'); + expect(result.children[4].type).toBe('list'); + expect(result.children[5].type).toBe('blockquote'); + expect(result.children[6].type).toBe('thematicBreak'); + expect(result.children[7].type).toBe('paragraph'); + }); + }); +}); + +describe('StreamingMarkdownParser', () => { + it('should parse content incrementally', () => { + const parser = new StreamingMarkdownParser(); + + // First chunk + let tree = parser.append('# Hello'); + expect(tree.children[0]).toMatchObject({ + type: 'heading', + depth: 1, + }); + + // Second chunk + tree = parser.append('\n\nThis is '); + expect(tree.children).toHaveLength(2); + + // Third chunk + tree = parser.append('**bold** text.'); + expect(tree.children[1]).toMatchObject({ + type: 'paragraph', + }); + }); + + it('should handle streaming code blocks', () => { + const parser = new StreamingMarkdownParser(); + + parser.append('```javascript\n'); + parser.append('const x = 1;\n'); + let tree = parser.append('console.log(x);'); + + // Code block is still "open" + expect(tree.children[0]).toMatchObject({ + type: 'code', + lang: 'javascript', + }); + + // Close the code block + tree = parser.append('\n```'); + expect(tree.children[0]).toMatchObject({ + type: 'code', + lang: 'javascript', + value: 'const x = 1;\nconsole.log(x);', + }); + }); + + it('should allow getting the tree without appending', () => { + const parser = new StreamingMarkdownParser(); + + parser.append('Hello'); + const tree1 = parser.getTree(); + const tree2 = parser.getTree(); + + expect(tree1).toEqual(tree2); + }); + + it('should track full content', () => { + const parser = new StreamingMarkdownParser(); + + parser.append('Hello, '); + parser.append('world!'); + + expect(parser.getContent()).toBe('Hello, world!'); + }); + + it('should reset properly', () => { + const parser = new StreamingMarkdownParser(); + + parser.append('Hello'); + parser.reset(); + + expect(parser.getContent()).toBe(''); + expect(parser.getTree()).toEqual({ type: 'root', children: [] }); + }); +}); diff --git a/packages/react-native/src/server/markdown-parser.ts b/packages/react-native/src/server/markdown-parser.ts new file mode 100644 index 000000000000..7ece0bed2082 --- /dev/null +++ b/packages/react-native/src/server/markdown-parser.ts @@ -0,0 +1,645 @@ +import type { + MarkdownBlockquote, + MarkdownBreak, + MarkdownCode, + MarkdownEmphasis, + MarkdownHeading, + MarkdownImage, + MarkdownInlineCode, + MarkdownInlineNode, + MarkdownLink, + MarkdownList, + MarkdownListItem, + MarkdownNode, + MarkdownParagraph, + MarkdownRoot, + MarkdownStrikethrough, + MarkdownStrong, + MarkdownTable, + MarkdownTableCell, + MarkdownTableRow, + MarkdownText, + MarkdownThematicBreak, +} from '../types'; + +/** + * A lightweight streaming-friendly markdown parser that produces a JSON tree. + * + * This parser is designed to work incrementally - it can parse partial + * markdown content and produce a valid tree structure that can be updated + * as more content streams in. + */ + +interface ParserState { + currentText: string; + inCodeBlock: boolean; + codeBlockLang?: string; + codeBlockMeta?: string; + codeBlockContent: string; + inBlockquote: boolean; + blockquoteContent: string[]; + listStack: Array<{ + ordered: boolean; + start?: number; + items: MarkdownListItem[]; + currentItemContent: string[]; + indent: number; + }>; +} + +function createInitialState(): ParserState { + return { + currentText: '', + inCodeBlock: false, + codeBlockLang: undefined, + codeBlockMeta: undefined, + codeBlockContent: '', + inBlockquote: false, + blockquoteContent: [], + listStack: [], + }; +} + +/** + * Parse inline markdown content (bold, italic, code, links, etc.) + */ +function parseInlineContent(text: string): MarkdownInlineNode[] { + const nodes: MarkdownInlineNode[] = []; + let remaining = text; + + while (remaining.length > 0) { + // Check for various inline patterns + let matched = false; + + // Inline code: `code` + const inlineCodeMatch = remaining.match(/^`([^`]+)`/); + if (inlineCodeMatch) { + nodes.push({ type: 'inlineCode', value: inlineCodeMatch[1] }); + remaining = remaining.slice(inlineCodeMatch[0].length); + matched = true; + continue; + } + + // Bold with asterisks: **text** + const boldMatch = remaining.match(/^\*\*(.+?)\*\*/s); + if (boldMatch) { + nodes.push({ + type: 'strong', + children: parseInlineContent(boldMatch[1]), + }); + remaining = remaining.slice(boldMatch[0].length); + matched = true; + continue; + } + + // Bold with underscores: __text__ + const boldUnderscoreMatch = remaining.match(/^__(.+?)__/s); + if (boldUnderscoreMatch) { + nodes.push({ + type: 'strong', + children: parseInlineContent(boldUnderscoreMatch[1]), + }); + remaining = remaining.slice(boldUnderscoreMatch[0].length); + matched = true; + continue; + } + + // Strikethrough: ~~text~~ + const strikethroughMatch = remaining.match(/^~~(.+?)~~/s); + if (strikethroughMatch) { + nodes.push({ + type: 'strikethrough', + children: parseInlineContent(strikethroughMatch[1]), + }); + remaining = remaining.slice(strikethroughMatch[0].length); + matched = true; + continue; + } + + // Italic with asterisks: *text* + const italicMatch = remaining.match(/^\*([^*]+)\*/); + if (italicMatch) { + nodes.push({ + type: 'emphasis', + children: parseInlineContent(italicMatch[1]), + }); + remaining = remaining.slice(italicMatch[0].length); + matched = true; + continue; + } + + // Italic with underscores: _text_ + const italicUnderscoreMatch = remaining.match(/^_([^_]+)_/); + if (italicUnderscoreMatch) { + nodes.push({ + type: 'emphasis', + children: parseInlineContent(italicUnderscoreMatch[1]), + }); + remaining = remaining.slice(italicUnderscoreMatch[0].length); + matched = true; + continue; + } + + // Images: ![alt](url "title") + const imageMatch = remaining.match( + /^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/, + ); + if (imageMatch) { + const image: MarkdownImage = { + type: 'image', + url: imageMatch[2], + alt: imageMatch[1] || undefined, + title: imageMatch[3] || undefined, + }; + nodes.push(image); + remaining = remaining.slice(imageMatch[0].length); + matched = true; + continue; + } + + // Links: [text](url "title") + const linkMatch = remaining.match( + /^\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/, + ); + if (linkMatch) { + const link: MarkdownLink = { + type: 'link', + url: linkMatch[2], + title: linkMatch[3] || undefined, + children: parseInlineContent(linkMatch[1]), + }; + nodes.push(link); + remaining = remaining.slice(linkMatch[0].length); + matched = true; + continue; + } + + // Auto-links: + const autoLinkMatch = remaining.match(/^<(https?:\/\/[^>]+)>/); + if (autoLinkMatch) { + const link: MarkdownLink = { + type: 'link', + url: autoLinkMatch[1], + children: [{ type: 'text', value: autoLinkMatch[1] }], + }; + nodes.push(link); + remaining = remaining.slice(autoLinkMatch[0].length); + matched = true; + continue; + } + + // Line breaks: two spaces followed by newline, or explicit
+ const breakMatch = remaining.match(/^( \n|)/i); + if (breakMatch) { + nodes.push({ type: 'break' } as MarkdownBreak); + remaining = remaining.slice(breakMatch[0].length); + matched = true; + continue; + } + + // If nothing matched, consume one character as text + if (!matched) { + // Find the next special character + const nextSpecial = remaining.search(/[`*_~[\] } { + // Code block start/end + const codeBlockMatch = line.match(/^```(\w+)?(?:\s+(.+))?$/); + if (codeBlockMatch) { + if (state.inCodeBlock) { + // End of code block + const code: MarkdownCode = { + type: 'code', + lang: state.codeBlockLang, + meta: state.codeBlockMeta, + value: state.codeBlockContent.replace(/\n$/, ''), // Remove trailing newline + }; + return { + type: 'code-block-end', + node: code, + updateState: { + inCodeBlock: false, + codeBlockLang: undefined, + codeBlockMeta: undefined, + codeBlockContent: '', + }, + }; + } else { + // Start of code block + return { + type: 'code-block-start', + updateState: { + inCodeBlock: true, + codeBlockLang: codeBlockMatch[1] || undefined, + codeBlockMeta: codeBlockMatch[2] || undefined, + codeBlockContent: '', + }, + }; + } + } + + // Inside code block + if (state.inCodeBlock) { + return { + type: 'code-block-content', + updateState: { + codeBlockContent: state.codeBlockContent + line + '\n', + }, + }; + } + + // Thematic break + if (/^(---+|___+|\*\*\*+)\s*$/.test(line)) { + return { + type: 'thematic-break', + node: { type: 'thematicBreak' } as MarkdownThematicBreak, + }; + } + + // Heading with # syntax + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + const heading: MarkdownHeading = { + type: 'heading', + depth: headingMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6, + children: parseInlineContent(headingMatch[2].trim()), + }; + return { type: 'heading', node: heading }; + } + + // Blockquote + const blockquoteMatch = line.match(/^>\s?(.*)$/); + if (blockquoteMatch) { + return { + type: 'blockquote', + updateState: { + inBlockquote: true, + blockquoteContent: [...state.blockquoteContent, blockquoteMatch[1]], + }, + }; + } + + // End blockquote if we were in one + if (state.inBlockquote && !blockquoteMatch) { + const blockquote: MarkdownBlockquote = { + type: 'blockquote', + children: parseMarkdownToTree(state.blockquoteContent.join('\n')) + .children as MarkdownNode[], + }; + return { + type: 'blockquote-end', + node: blockquote, + updateState: { + inBlockquote: false, + blockquoteContent: [], + }, + }; + } + + // Ordered list item (check this first to avoid matching with unordered) + const orderedListMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/); + if (orderedListMatch) { + const indent = orderedListMatch[1].length; + const start = parseInt(orderedListMatch[2], 10); + const content = orderedListMatch[3]; + return { + type: 'ordered-list-item', + node: createListItem(content, true, start, indent), + }; + } + + // Unordered list item + const unorderedListMatch = line.match(/^(\s*)([-*+])\s+(.*)$/); + if (unorderedListMatch) { + const indent = unorderedListMatch[1].length; + const content = unorderedListMatch[3]; + return { + type: 'unordered-list-item', + node: createListItem(content, false, undefined, indent), + }; + } + + // Task list item + const taskListMatch = line.match(/^(\s*)([-*+])\s+\[([ xX])\]\s+(.*)$/); + if (taskListMatch) { + const indent = taskListMatch[1].length; + const checked = taskListMatch[3].toLowerCase() === 'x'; + const content = taskListMatch[4]; + const item = createListItem(content, false, undefined, indent); + (item as MarkdownListItem & { checked: boolean }).checked = checked; + return { type: 'list-item', node: item }; + } + + // Empty line + if (line.trim() === '') { + return { type: 'empty' }; + } + + // Table row + const tableMatch = line.match(/^\|(.+)\|$/); + if (tableMatch) { + const cells = tableMatch[1].split('|').map(cell => cell.trim()); + // Check if this is a separator row + if (cells.every(cell => /^:?-+:?$/.test(cell))) { + return { type: 'table-separator', node: undefined }; + } + const row: MarkdownTableRow = { + type: 'tableRow', + children: cells.map(cell => ({ + type: 'tableCell', + children: parseInlineContent(cell), + })) as MarkdownTableCell[], + }; + return { type: 'table-row', node: row }; + } + + // Regular paragraph + return { + type: 'paragraph', + node: { + type: 'paragraph', + children: parseInlineContent(line), + } as MarkdownParagraph, + }; +} + +function createListItem( + content: string, + _ordered: boolean, + _start: number | undefined, + _indent: number, +): MarkdownListItem { + return { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: parseInlineContent(content), + } as MarkdownParagraph, + ], + }; +} + +/** + * Parse a complete markdown string into a tree structure. + */ +export function parseMarkdownToTree(markdown: string): MarkdownRoot { + const root: MarkdownRoot = { + type: 'root', + children: [], + }; + + const lines = markdown.split('\n'); + const state = createInitialState(); + + let currentList: MarkdownList | null = null; + let currentTable: MarkdownTable | null = null; + let pendingParagraphLines: string[] = []; + + const flushParagraph = () => { + if (pendingParagraphLines.length > 0) { + const text = pendingParagraphLines.join('\n'); + if (text.trim()) { + root.children.push({ + type: 'paragraph', + children: parseInlineContent(text), + } as MarkdownParagraph); + } + pendingParagraphLines = []; + } + }; + + const flushList = () => { + if (currentList) { + root.children.push(currentList); + currentList = null; + } + }; + + const flushTable = () => { + if (currentTable) { + root.children.push(currentTable); + currentTable = null; + } + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const result = parseBlockLine(line, state); + + // Update parser state + if (result.updateState) { + Object.assign(state, result.updateState); + } + + switch (result.type) { + case 'code-block-start': + flushParagraph(); + flushList(); + flushTable(); + break; + + case 'code-block-content': + // Content is accumulated in state + break; + + case 'code-block-end': + if (result.node) { + root.children.push(result.node); + } + break; + + case 'heading': + case 'thematic-break': + flushParagraph(); + flushList(); + flushTable(); + if (result.node) { + root.children.push(result.node); + } + break; + + case 'blockquote': + // Accumulated in state + break; + + case 'blockquote-end': + flushParagraph(); + flushList(); + flushTable(); + if (result.node) { + root.children.push(result.node); + } + // Re-parse the current line since it's not a blockquote + i--; + break; + + case 'ordered-list-item': + flushParagraph(); + flushTable(); + if (result.node) { + if (!currentList || !currentList.ordered) { + flushList(); + currentList = { + type: 'list', + ordered: true, + children: [], + }; + } + currentList.children.push(result.node as MarkdownListItem); + } + break; + + case 'unordered-list-item': + flushParagraph(); + flushTable(); + if (result.node) { + if (!currentList || currentList.ordered) { + flushList(); + currentList = { + type: 'list', + ordered: false, + children: [], + }; + } + currentList.children.push(result.node as MarkdownListItem); + } + break; + + case 'table-row': + flushParagraph(); + flushList(); + if (!currentTable) { + currentTable = { + type: 'table', + children: [], + }; + } + if (result.node) { + currentTable.children.push(result.node as MarkdownTableRow); + } + break; + + case 'table-separator': + // Table separator, already in table context + break; + + case 'empty': + flushParagraph(); + flushList(); + flushTable(); + break; + + case 'paragraph': + flushList(); + flushTable(); + pendingParagraphLines.push(line); + break; + } + } + + // Handle any remaining state + if (state.inCodeBlock) { + // Unclosed code block - add it anyway + root.children.push({ + type: 'code', + lang: state.codeBlockLang, + meta: state.codeBlockMeta, + value: state.codeBlockContent.replace(/\n$/, ''), + } as MarkdownCode); + } + + if (state.inBlockquote) { + root.children.push({ + type: 'blockquote', + children: parseMarkdownToTree(state.blockquoteContent.join('\n')) + .children as MarkdownNode[], + } as MarkdownBlockquote); + } + + flushParagraph(); + flushList(); + flushTable(); + + return root; +} + +/** + * Streaming-aware markdown parser that can handle partial content. + * + * This is designed to work with streaming LLM responses where the markdown + * content arrives incrementally. + */ +export class StreamingMarkdownParser { + private content: string = ''; + private lastTree: MarkdownRoot | null = null; + + /** + * Append new content and get the updated tree. + */ + append(chunk: string): MarkdownRoot { + this.content += chunk; + this.lastTree = parseMarkdownToTree(this.content); + return this.lastTree; + } + + /** + * Get the current tree without appending. + */ + getTree(): MarkdownRoot { + if (!this.lastTree) { + this.lastTree = parseMarkdownToTree(this.content); + } + return this.lastTree; + } + + /** + * Get the full content accumulated so far. + */ + getContent(): string { + return this.content; + } + + /** + * Reset the parser state. + */ + reset(): void { + this.content = ''; + this.lastTree = null; + } +} diff --git a/packages/react-native/src/server/markdown-stream.ts b/packages/react-native/src/server/markdown-stream.ts new file mode 100644 index 000000000000..94fcda7cbf16 --- /dev/null +++ b/packages/react-native/src/server/markdown-stream.ts @@ -0,0 +1,370 @@ +import type { + MarkdownRoot, + MarkdownTreeChunk, + MarkdownTreePatch, +} from '../types'; +import { + StreamingMarkdownParser, + parseMarkdownToTree, +} from './markdown-parser'; +import { + createMarkdownTreeDiff, + isStreamingAppend, +} from './markdown-tree-diff'; + +// Simple ID generator +function generateIdFunc(): string { + return Math.random().toString(36).substring(2, 9) + Date.now().toString(36); +} + +export interface MarkdownStreamOptions { + /** + * Generate a unique ID for the markdown tree. + * Defaults to the AI SDK's generateId function. + */ + generateId?: () => string; + + /** + * Minimum interval between patches in milliseconds. + * Batches rapid updates to reduce patch frequency. + * Default: 50ms + */ + throttleMs?: number; + + /** + * Maximum number of patches before sending a full snapshot. + * This helps prevent patch accumulation issues. + * Default: 20 + */ + maxPatchesBeforeSnapshot?: number; + + /** + * Whether to send the initial tree as a snapshot. + * Default: true + */ + sendInitialSnapshot?: boolean; +} + +/** + * Transform a text stream into a markdown tree stream. + * + * This takes a stream of text chunks (e.g., from an LLM) and transforms it + * into a stream of markdown tree patches optimized for React Native rendering. + */ +export function createMarkdownTreeStream( + textStream: ReadableStream, + options: MarkdownStreamOptions = {}, +): ReadableStream { + const { + generateId = generateIdFunc, + throttleMs = 50, + maxPatchesBeforeSnapshot = 20, + sendInitialSnapshot = true, + } = options; + + const treeId = generateId(); + const parser = new StreamingMarkdownParser(); + let lastTree: MarkdownRoot | null = null; + let patchCount = 0; + let lastSendTime = 0; + let pendingChunks: string[] = []; + let timeoutId: ReturnType | null = null; + + return new ReadableStream({ + async start(controller) { + // Send start event + controller.enqueue({ type: 'markdown-tree-start', id: treeId }); + + const reader = textStream.getReader(); + + const flushPendingChunks = () => { + if (pendingChunks.length === 0) return; + + // Append all pending chunks + for (const chunk of pendingChunks) { + parser.append(chunk); + } + pendingChunks = []; + + const newTree = parser.getTree(); + + // Decide whether to send patch or snapshot + const shouldSendSnapshot = + patchCount >= maxPatchesBeforeSnapshot || + (lastTree === null && sendInitialSnapshot); + + if (shouldSendSnapshot) { + controller.enqueue({ + type: 'markdown-tree-snapshot', + id: treeId, + tree: newTree, + }); + patchCount = 0; + } else { + const patch = createMarkdownTreeDiff(lastTree, newTree); + + if (patch.length > 0) { + controller.enqueue({ + type: 'markdown-tree-patch', + id: treeId, + patch, + }); + patchCount++; + } + } + + lastTree = newTree; + lastSendTime = Date.now(); + }; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + // Clear any pending timeout + if (timeoutId) { + clearTimeout(timeoutId); + } + + // Flush any remaining chunks + flushPendingChunks(); + + // Send final tree + const finalTree = parser.getTree(); + controller.enqueue({ + type: 'markdown-tree-end', + id: treeId, + tree: finalTree, + }); + + controller.close(); + break; + } + + // Add chunk to pending + pendingChunks.push(value); + + // Check if we should flush immediately or throttle + const now = Date.now(); + const timeSinceLastSend = now - lastSendTime; + + if (timeSinceLastSend >= throttleMs) { + // Enough time has passed, flush immediately + flushPendingChunks(); + } else if (!timeoutId) { + // Schedule a flush + timeoutId = setTimeout(() => { + timeoutId = null; + flushPendingChunks(); + }, throttleMs - timeSinceLastSend); + } + } + } catch (error) { + controller.error(error); + } + }, + }); +} + +/** + * Options for creating a markdown stream response + */ +export interface CreateMarkdownStreamResponseOptions { + /** + * Additional headers to include in the response. + */ + headers?: Record; + + /** + * Status code for the response. + * Default: 200 + */ + status?: number; + + /** + * Callback called when streaming starts. + */ + onStart?: (treeId: string) => void; + + /** + * Callback called for each chunk sent. + */ + onChunk?: (chunk: MarkdownTreeChunk) => void; + + /** + * Callback called when streaming finishes. + */ + onFinish?: (tree: MarkdownRoot) => void; + + /** + * Callback called on error. + */ + onError?: (error: Error) => void; + + /** + * Markdown stream options. + */ + streamOptions?: MarkdownStreamOptions; +} + +/** + * Create an HTTP Response that streams markdown tree updates. + * + * This can be used with Next.js, Express, or any other framework that + * supports the standard Response API. + * + * @example + * ```ts + * // Next.js App Router + * export async function POST(req: Request) { + * const result = streamText({ + * model: openai('gpt-4o'), + * messages: [{ role: 'user', content: 'Hello!' }], + * }); + * + * return createMarkdownStreamResponse(result.textStream); + * } + * ``` + */ +export function createMarkdownStreamResponse( + textStream: ReadableStream, + options: CreateMarkdownStreamResponseOptions = {}, +): Response { + const { + headers = {}, + status = 200, + onStart, + onChunk, + onFinish, + onError, + streamOptions, + } = options; + + const markdownStream = createMarkdownTreeStream(textStream, streamOptions); + + // Transform to SSE format + const sseStream = new ReadableStream({ + async start(controller) { + const reader = markdownStream.getReader(); + const encoder = new TextEncoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + // Call appropriate callbacks + if (value.type === 'markdown-tree-start') { + onStart?.(value.id); + } else if (value.type === 'markdown-tree-end') { + onFinish?.(value.tree); + } + + onChunk?.(value); + + // Format as SSE + const data = JSON.stringify(value); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + } + + controller.close(); + } catch (error) { + onError?.(error instanceof Error ? error : new Error(String(error))); + controller.error(error); + } + }, + }); + + return new Response(sseStream, { + status, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + ...headers, + }, + }); +} + +/** + * Parse a complete markdown string and return it as a tree. + * + * Use this for non-streaming scenarios or to parse stored content. + */ +export { parseMarkdownToTree } from './markdown-parser'; + +/** + * Apply a patch to a markdown tree. + * + * This is useful on the client side to apply received patches. + */ +export { + applyMarkdownTreePatch, + createMarkdownTreeDiff, + isStreamingAppend, +} from './markdown-tree-diff'; + +/** + * Integrate markdown tree streaming with AI SDK's streamText result. + * + * @example + * ```ts + * import { streamText } from 'ai'; + * import { openai } from '@ai-sdk/openai'; + * import { createMarkdownStreamFromStreamText } from '@ai-sdk/react-native/server'; + * + * const result = streamText({ + * model: openai('gpt-4o'), + * messages, + * }); + * + * // Get a markdown tree stream from the text stream + * const markdownStream = createMarkdownStreamFromStreamText(result); + * + * // Or get a Response directly + * return createMarkdownStreamResponseFromStreamText(result); + * ``` + */ +export function createMarkdownStreamFromStreamText( + result: { textStream: ReadableStream }, + options?: MarkdownStreamOptions, +): ReadableStream { + return createMarkdownTreeStream(result.textStream, options); +} + +/** + * Create an HTTP Response from AI SDK's streamText result with markdown tree streaming. + */ +export function createMarkdownStreamResponseFromStreamText( + result: { textStream: ReadableStream }, + options?: CreateMarkdownStreamResponseOptions, +): Response { + return createMarkdownStreamResponse(result.textStream, options); +} + +/** + * Create a UI message stream that includes markdown tree updates. + * + * This integrates with the AI SDK's UI message streaming system while also + * sending parsed markdown tree updates for efficient React Native rendering. + */ +export interface MarkdownUIMessageStreamOptions extends MarkdownStreamOptions { + /** + * Whether to include the raw text in addition to the markdown tree. + * Default: false + */ + includeRawText?: boolean; +} + +/** + * Custom chunk types that can be sent alongside standard UI message chunks. + */ +export type MarkdownUIMessageChunk = + | { type: 'markdown-tree-start'; id: string } + | { type: 'markdown-tree-patch'; id: string; patch: MarkdownTreePatch } + | { type: 'markdown-tree-snapshot'; id: string; tree: MarkdownRoot } + | { type: 'markdown-tree-end'; id: string; tree: MarkdownRoot }; diff --git a/packages/react-native/src/server/markdown-tree-diff.test.ts b/packages/react-native/src/server/markdown-tree-diff.test.ts new file mode 100644 index 000000000000..5625d83eebf3 --- /dev/null +++ b/packages/react-native/src/server/markdown-tree-diff.test.ts @@ -0,0 +1,572 @@ +import { describe, it, expect } from 'vitest'; +import { + applyMarkdownTreePatch, + createMarkdownTreeDiff, + estimatePatchEfficiency, + isStreamingAppend, +} from './markdown-tree-diff'; +import type { MarkdownRoot } from '../types'; + +describe('createMarkdownTreeDiff', () => { + it('should return full snapshot for null old tree', () => { + const newTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello' }], + }, + ], + }; + + const patch = createMarkdownTreeDiff(null, newTree); + + expect(patch).toHaveLength(1); + expect(patch[0]).toEqual({ + op: 'replace', + path: '', + value: newTree, + }); + }); + + it('should detect text appends', () => { + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello' }], + }, + ], + }; + + const newTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello, world!' }], + }, + ], + }; + + const patch = createMarkdownTreeDiff(oldTree, newTree); + + expect(patch).toContainEqual({ + op: 'append-text', + path: '/children/0/children/0/value', + value: ', world!', + }); + }); + + it('should detect added children', () => { + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'First' }], + }, + ], + }; + + const newTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'First' }], + }, + { + type: 'paragraph', + children: [{ type: 'text', value: 'Second' }], + }, + ], + }; + + const patch = createMarkdownTreeDiff(oldTree, newTree); + + expect(patch).toContainEqual({ + op: 'add', + path: '/children/1', + value: { + type: 'paragraph', + children: [{ type: 'text', value: 'Second' }], + }, + }); + }); + + it('should detect removed children', () => { + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'First' }], + }, + { + type: 'paragraph', + children: [{ type: 'text', value: 'Second' }], + }, + ], + }; + + const newTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'First' }], + }, + ], + }; + + const patch = createMarkdownTreeDiff(oldTree, newTree); + + expect(patch).toContainEqual({ + op: 'remove', + path: '/children/1', + }); + }); + + it('should detect type changes as replacements', () => { + // Use a larger tree to ensure patch is more efficient than snapshot + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'First paragraph with some content to make it larger', + }, + ], + }, + { + type: 'paragraph', + children: [{ type: 'text', value: 'Second paragraph' }], + }, + { + type: 'paragraph', + children: [{ type: 'text', value: 'Third paragraph to keep' }], + }, + ], + }; + + const newTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'heading', + depth: 1, + children: [ + { + type: 'text', + value: 'First paragraph with some content to make it larger', + }, + ], + }, + { + type: 'paragraph', + children: [{ type: 'text', value: 'Second paragraph' }], + }, + { + type: 'paragraph', + children: [{ type: 'text', value: 'Third paragraph to keep' }], + }, + ], + }; + + const patch = createMarkdownTreeDiff(oldTree, newTree); + + // Should have a replace operation for the first child that changed type + expect(patch).toContainEqual({ + op: 'replace', + path: '/children/0', + value: { + type: 'heading', + depth: 1, + children: [ + { + type: 'text', + value: 'First paragraph with some content to make it larger', + }, + ], + }, + }); + }); + + it('should return empty patch for identical trees', () => { + const tree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello' }], + }, + ], + }; + + const patch = createMarkdownTreeDiff(tree, structuredClone(tree)); + + expect(patch).toHaveLength(0); + }); +}); + +describe('applyMarkdownTreePatch', () => { + it('should apply replace operation on root', () => { + const newTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'New' }], + }, + ], + }; + + const result = applyMarkdownTreePatch(null, [ + { op: 'replace', path: '', value: newTree }, + ]); + + expect(result).toEqual(newTree); + }); + + it('should apply append-text operation', () => { + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello' }], + }, + ], + }; + + const result = applyMarkdownTreePatch(oldTree, [ + { + op: 'append-text', + path: '/children/0/children/0/value', + value: ', world!', + }, + ]); + + expect(result.children[0]).toMatchObject({ + type: 'paragraph', + children: [{ type: 'text', value: 'Hello, world!' }], + }); + }); + + it('should apply add operation', () => { + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'First' }], + }, + ], + }; + + const result = applyMarkdownTreePatch(oldTree, [ + { + op: 'add', + path: '/children/1', + value: { + type: 'paragraph', + children: [{ type: 'text', value: 'Second' }], + }, + }, + ]); + + expect(result.children).toHaveLength(2); + expect(result.children[1]).toMatchObject({ + type: 'paragraph', + children: [{ type: 'text', value: 'Second' }], + }); + }); + + it('should apply remove operation', () => { + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'First' }], + }, + { + type: 'paragraph', + children: [{ type: 'text', value: 'Second' }], + }, + ], + }; + + const result = applyMarkdownTreePatch(oldTree, [ + { op: 'remove', path: '/children/1' }, + ]); + + expect(result.children).toHaveLength(1); + expect(result.children[0]).toMatchObject({ + type: 'paragraph', + children: [{ type: 'text', value: 'First' }], + }); + }); + + it('should apply multiple operations in sequence', () => { + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello' }], + }, + ], + }; + + const result = applyMarkdownTreePatch(oldTree, [ + { + op: 'append-text', + path: '/children/0/children/0/value', + value: ', world!', + }, + { + op: 'add', + path: '/children/1', + value: { + type: 'paragraph', + children: [{ type: 'text', value: 'New paragraph' }], + }, + }, + ]); + + expect(result.children).toHaveLength(2); + expect(result.children[0]).toMatchObject({ + children: [{ type: 'text', value: 'Hello, world!' }], + }); + expect(result.children[1]).toMatchObject({ + children: [{ type: 'text', value: 'New paragraph' }], + }); + }); + + it('should create a new tree without mutating the original', () => { + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello' }], + }, + ], + }; + + const originalValue = (oldTree.children[0] as any).children[0].value; + + applyMarkdownTreePatch(oldTree, [ + { + op: 'append-text', + path: '/children/0/children/0/value', + value: ', world!', + }, + ]); + + // Original tree should be unchanged + expect((oldTree.children[0] as any).children[0].value).toBe(originalValue); + }); +}); + +describe('isStreamingAppend', () => { + it('should return true for append-text only patches', () => { + const patch = [ + { op: 'append-text' as const, path: '/children/0/value', value: 'more' }, + ]; + + expect(isStreamingAppend(patch)).toBe(true); + }); + + it('should return true for add operations in children', () => { + const patch = [ + { op: 'add' as const, path: '/children/1', value: { type: 'paragraph' } }, + ]; + + expect(isStreamingAppend(patch)).toBe(true); + }); + + it('should return false for replace operations', () => { + const patch = [ + { + op: 'replace' as const, + path: '/children/0', + value: { type: 'paragraph' }, + }, + ]; + + expect(isStreamingAppend(patch)).toBe(false); + }); + + it('should return false for remove operations', () => { + const patch = [{ op: 'remove' as const, path: '/children/0' }]; + + expect(isStreamingAppend(patch)).toBe(false); + }); + + it('should return false for empty patches', () => { + expect(isStreamingAppend([])).toBe(false); + }); +}); + +describe('estimatePatchEfficiency', () => { + it('should calculate savings correctly', () => { + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello' }], + }, + ], + }; + + const newTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello, world!' }], + }, + ], + }; + + const { patchSize, snapshotSize, savings } = estimatePatchEfficiency( + oldTree, + newTree, + ); + + expect(patchSize).toBeLessThan(snapshotSize); + expect(savings).toBeGreaterThan(0); + }); + + it('should handle null old tree', () => { + const newTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'paragraph', + children: [{ type: 'text', value: 'Hello' }], + }, + ], + }; + + const { savings } = estimatePatchEfficiency(null, newTree); + + // Full snapshot, no savings + expect(savings).toBeLessThanOrEqual(0); + }); +}); + +describe('round-trip diff and patch', () => { + it('should produce identical tree after applying diff', () => { + const oldTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: 'Title' }], + }, + { + type: 'paragraph', + children: [ + { type: 'text', value: 'Some ' }, + { type: 'strong', children: [{ type: 'text', value: 'bold' }] }, + { type: 'text', value: ' text.' }, + ], + }, + ], + }; + + const newTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: 'Title' }], + }, + { + type: 'paragraph', + children: [ + { type: 'text', value: 'Some ' }, + { type: 'strong', children: [{ type: 'text', value: 'bold' }] }, + { type: 'text', value: ' text with more content.' }, + ], + }, + { + type: 'code', + lang: 'javascript', + value: 'console.log("hello");', + }, + ], + }; + + const patch = createMarkdownTreeDiff(oldTree, newTree); + const result = applyMarkdownTreePatch(oldTree, patch); + + expect(result).toEqual(newTree); + }); + + it('should handle incremental streaming updates', () => { + let currentTree: MarkdownRoot = { + type: 'root', + children: [], + }; + + // Simulate streaming: add heading + let newTree: MarkdownRoot = { + type: 'root', + children: [ + { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: 'H' }], + }, + ], + }; + let patch = createMarkdownTreeDiff(currentTree, newTree); + currentTree = applyMarkdownTreePatch(currentTree, patch); + + // Continue streaming: extend heading + newTree = { + type: 'root', + children: [ + { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: 'Hello' }], + }, + ], + }; + patch = createMarkdownTreeDiff(currentTree, newTree); + currentTree = applyMarkdownTreePatch(currentTree, patch); + + // Add paragraph + newTree = { + type: 'root', + children: [ + { + type: 'heading', + depth: 1, + children: [{ type: 'text', value: 'Hello' }], + }, + { + type: 'paragraph', + children: [{ type: 'text', value: 'World' }], + }, + ], + }; + patch = createMarkdownTreeDiff(currentTree, newTree); + currentTree = applyMarkdownTreePatch(currentTree, patch); + + expect(currentTree).toEqual(newTree); + }); +}); diff --git a/packages/react-native/src/server/markdown-tree-diff.ts b/packages/react-native/src/server/markdown-tree-diff.ts new file mode 100644 index 000000000000..b70d43510297 --- /dev/null +++ b/packages/react-native/src/server/markdown-tree-diff.ts @@ -0,0 +1,318 @@ +import type { + MarkdownNode, + MarkdownRoot, + MarkdownTreePatch, + MarkdownTreePatchOp, +} from '../types'; + +/** + * Creates a minimal diff between two markdown trees. + * + * This is optimized for streaming scenarios where the new tree is typically + * an extension of the old tree (appending content), rather than arbitrary changes. + * + * The algorithm prioritizes: + * 1. Detecting text appends (common in streaming) + * 2. Detecting new nodes added at the end + * 3. Minimizing patch size + */ +export function createMarkdownTreeDiff( + oldTree: MarkdownRoot | null, + newTree: MarkdownRoot, +): MarkdownTreePatch { + if (!oldTree) { + // No old tree, send the whole thing as a snapshot + return [{ op: 'replace', path: '', value: newTree }]; + } + + const patches: MarkdownTreePatchOp[] = []; + + diffNodes(oldTree, newTree, '', patches); + + // If patches are too large, just send a snapshot + const patchSize = JSON.stringify(patches).length; + const snapshotSize = JSON.stringify(newTree).length; + + if (patchSize > snapshotSize * 0.8) { + return [{ op: 'replace', path: '', value: newTree }]; + } + + return patches; +} + +function diffNodes( + oldNode: MarkdownNode, + newNode: MarkdownNode, + path: string, + patches: MarkdownTreePatchOp[], +): void { + // Different types - replace entirely + if (oldNode.type !== newNode.type) { + patches.push({ op: 'replace', path, value: newNode }); + return; + } + + // Handle text nodes specially for streaming optimization + if (oldNode.type === 'text' && newNode.type === 'text') { + if (newNode.value.startsWith(oldNode.value)) { + // New text is an extension of old text - use append + const appendedText = newNode.value.slice(oldNode.value.length); + if (appendedText.length > 0) { + patches.push({ + op: 'append-text', + path: `${path}/value`, + value: appendedText, + }); + } + } else if (oldNode.value !== newNode.value) { + patches.push({ + op: 'replace', + path: `${path}/value`, + value: newNode.value, + }); + } + return; + } + + // Handle code blocks + if (oldNode.type === 'code' && newNode.type === 'code') { + if (oldNode.lang !== newNode.lang) { + patches.push({ + op: 'replace', + path: `${path}/lang`, + value: newNode.lang, + }); + } + if (oldNode.meta !== newNode.meta) { + patches.push({ + op: 'replace', + path: `${path}/meta`, + value: newNode.meta, + }); + } + if (newNode.value.startsWith(oldNode.value)) { + const appendedText = newNode.value.slice(oldNode.value.length); + if (appendedText.length > 0) { + patches.push({ + op: 'append-text', + path: `${path}/value`, + value: appendedText, + }); + } + } else if (oldNode.value !== newNode.value) { + patches.push({ + op: 'replace', + path: `${path}/value`, + value: newNode.value, + }); + } + return; + } + + // Handle inline code + if (oldNode.type === 'inlineCode' && newNode.type === 'inlineCode') { + if (oldNode.value !== newNode.value) { + patches.push({ + op: 'replace', + path: `${path}/value`, + value: newNode.value, + }); + } + return; + } + + // Handle nodes with children + if ('children' in oldNode && 'children' in newNode) { + const oldChildren = oldNode.children as MarkdownNode[]; + const newChildren = newNode.children as MarkdownNode[]; + + // First, diff existing children + const minLength = Math.min(oldChildren.length, newChildren.length); + for (let i = 0; i < minLength; i++) { + diffNodes( + oldChildren[i], + newChildren[i], + `${path}/children/${i}`, + patches, + ); + } + + // Handle removed children + if (oldChildren.length > newChildren.length) { + for (let i = oldChildren.length - 1; i >= newChildren.length; i--) { + patches.push({ op: 'remove', path: `${path}/children/${i}` }); + } + } + + // Handle added children + if (newChildren.length > oldChildren.length) { + for (let i = oldChildren.length; i < newChildren.length; i++) { + patches.push({ + op: 'add', + path: `${path}/children/${i}`, + value: newChildren[i], + }); + } + } + } + + // Handle other properties that might differ + const oldObj = oldNode as unknown as { [key: string]: unknown }; + const newObj = newNode as unknown as { [key: string]: unknown }; + const oldKeys = Object.keys(oldObj).filter( + k => k !== 'type' && k !== 'children', + ); + const newKeys = Object.keys(newObj).filter( + k => k !== 'type' && k !== 'children', + ); + + for (const key of new Set([...oldKeys, ...newKeys])) { + const oldValue = oldObj[key]; + const newValue = newObj[key]; + + if (oldValue !== newValue) { + if (newValue === undefined) { + patches.push({ op: 'remove', path: `${path}/${key}` }); + } else if (oldValue === undefined) { + patches.push({ op: 'add', path: `${path}/${key}`, value: newValue }); + } else { + patches.push({ + op: 'replace', + path: `${path}/${key}`, + value: newValue, + }); + } + } + } +} + +/** + * Apply a patch to a markdown tree, returning a new tree. + * + * This function is immutable - it does not modify the original tree. + */ +export function applyMarkdownTreePatch( + tree: MarkdownRoot | null, + patch: MarkdownTreePatch, +): MarkdownRoot { + // Start with a deep clone to ensure immutability + let result = tree + ? structuredClone(tree) + : ({ type: 'root', children: [] } as MarkdownRoot); + + for (const op of patch) { + result = applySingleOp(result, op); + } + + return result; +} + +type AnyObject = { [key: string]: unknown }; + +function applySingleOp( + tree: MarkdownRoot, + op: MarkdownTreePatchOp, +): MarkdownRoot { + if (op.path === '') { + // Replace entire tree + if (op.op === 'replace') { + return op.value as MarkdownRoot; + } + throw new Error(`Invalid operation on root: ${op.op}`); + } + + // Parse the path + const segments = op.path.split('/').filter(Boolean); + + // Navigate to the parent + let current: AnyObject | unknown[] = tree as unknown as AnyObject; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + if (Array.isArray(current)) { + current = current[parseInt(segment, 10)] as AnyObject; + } else { + current = current[segment] as AnyObject; + } + } + + const lastSegment = segments[segments.length - 1]; + + switch (op.op) { + case 'replace': + if (Array.isArray(current)) { + current[parseInt(lastSegment, 10)] = op.value; + } else { + current[lastSegment] = op.value; + } + break; + + case 'add': + if (Array.isArray(current)) { + const index = parseInt(lastSegment, 10); + if (index === current.length) { + current.push(op.value); + } else { + current.splice(index, 0, op.value); + } + } else { + current[lastSegment] = op.value; + } + break; + + case 'remove': + if (Array.isArray(current)) { + current.splice(parseInt(lastSegment, 10), 1); + } else { + delete current[lastSegment]; + } + break; + + case 'append-text': + if (Array.isArray(current)) { + const item = current[parseInt(lastSegment, 10)]; + if (typeof item === 'string') { + current[parseInt(lastSegment, 10)] = item + op.value; + } + } else if (typeof current[lastSegment] === 'string') { + current[lastSegment] = (current[lastSegment] as string) + op.value; + } + break; + } + + return tree; +} + +/** + * Checks if a patch is a "streaming append" pattern. + * + * Streaming appends are typically small patches that only add to existing + * content rather than modifying structure. These can be applied more efficiently. + */ +export function isStreamingAppend(patch: MarkdownTreePatch): boolean { + if (patch.length === 0) return false; + + // A streaming append typically has: + // - Only 'append-text' operations, OR + // - Only 'add' operations at the end of children arrays + + return patch.every(op => { + if (op.op === 'append-text') return true; + if (op.op === 'add' && op.path.includes('/children/')) return true; + return false; + }); +} + +/** + * Estimate the size savings of using patches vs a full snapshot. + */ +export function estimatePatchEfficiency( + oldTree: MarkdownRoot | null, + newTree: MarkdownRoot, +): { patchSize: number; snapshotSize: number; savings: number } { + const patch = createMarkdownTreeDiff(oldTree, newTree); + const patchSize = JSON.stringify(patch).length; + const snapshotSize = JSON.stringify(newTree).length; + const savings = snapshotSize > 0 ? (1 - patchSize / snapshotSize) * 100 : 0; + + return { patchSize, snapshotSize, savings }; +} diff --git a/packages/react-native/src/types.ts b/packages/react-native/src/types.ts new file mode 100644 index 000000000000..f3380d40da8b --- /dev/null +++ b/packages/react-native/src/types.ts @@ -0,0 +1,349 @@ +/** + * Base interface for all markdown tree nodes + */ +export interface MarkdownNodeBase { + type: string; +} + +/** + * Root node containing the entire markdown document + */ +export interface MarkdownRoot extends MarkdownNodeBase { + type: 'root'; + children: MarkdownNode[]; +} + +/** + * Paragraph containing inline content + */ +export interface MarkdownParagraph extends MarkdownNodeBase { + type: 'paragraph'; + children: MarkdownInlineNode[]; +} + +/** + * Heading with depth level 1-6 + */ +export interface MarkdownHeading extends MarkdownNodeBase { + type: 'heading'; + depth: 1 | 2 | 3 | 4 | 5 | 6; + children: MarkdownInlineNode[]; +} + +/** + * Plain text content + */ +export interface MarkdownText extends MarkdownNodeBase { + type: 'text'; + value: string; +} + +/** + * Bold/strong text + */ +export interface MarkdownStrong extends MarkdownNodeBase { + type: 'strong'; + children: MarkdownInlineNode[]; +} + +/** + * Italic/emphasized text + */ +export interface MarkdownEmphasis extends MarkdownNodeBase { + type: 'emphasis'; + children: MarkdownInlineNode[]; +} + +/** + * Strikethrough text + */ +export interface MarkdownStrikethrough extends MarkdownNodeBase { + type: 'strikethrough'; + children: MarkdownInlineNode[]; +} + +/** + * Fenced code block + */ +export interface MarkdownCode extends MarkdownNodeBase { + type: 'code'; + lang?: string; + meta?: string; + value: string; +} + +/** + * Inline code + */ +export interface MarkdownInlineCode extends MarkdownNodeBase { + type: 'inlineCode'; + value: string; +} + +/** + * Hyperlink + */ +export interface MarkdownLink extends MarkdownNodeBase { + type: 'link'; + url: string; + title?: string; + children: MarkdownInlineNode[]; +} + +/** + * Image + */ +export interface MarkdownImage extends MarkdownNodeBase { + type: 'image'; + url: string; + alt?: string; + title?: string; +} + +/** + * Ordered or unordered list + */ +export interface MarkdownList extends MarkdownNodeBase { + type: 'list'; + ordered: boolean; + start?: number; + spread?: boolean; + children: MarkdownListItem[]; +} + +/** + * List item + */ +export interface MarkdownListItem extends MarkdownNodeBase { + type: 'listItem'; + spread?: boolean; + checked?: boolean | null; + children: MarkdownNode[]; +} + +/** + * Block quote + */ +export interface MarkdownBlockquote extends MarkdownNodeBase { + type: 'blockquote'; + children: MarkdownNode[]; +} + +/** + * Thematic break (horizontal rule) + */ +export interface MarkdownThematicBreak extends MarkdownNodeBase { + type: 'thematicBreak'; +} + +/** + * Line break + */ +export interface MarkdownBreak extends MarkdownNodeBase { + type: 'break'; +} + +/** + * Table + */ +export interface MarkdownTable extends MarkdownNodeBase { + type: 'table'; + align?: Array<'left' | 'center' | 'right' | null>; + children: MarkdownTableRow[]; +} + +/** + * Table row + */ +export interface MarkdownTableRow extends MarkdownNodeBase { + type: 'tableRow'; + children: MarkdownTableCell[]; +} + +/** + * Table cell + */ +export interface MarkdownTableCell extends MarkdownNodeBase { + type: 'tableCell'; + children: MarkdownInlineNode[]; +} + +/** + * HTML content (raw) + */ +export interface MarkdownHtml extends MarkdownNodeBase { + type: 'html'; + value: string; +} + +/** + * All inline node types + */ +export type MarkdownInlineNode = + | MarkdownText + | MarkdownStrong + | MarkdownEmphasis + | MarkdownStrikethrough + | MarkdownInlineCode + | MarkdownLink + | MarkdownImage + | MarkdownBreak + | MarkdownHtml; + +/** + * All block-level node types + */ +export type MarkdownBlockNode = + | MarkdownParagraph + | MarkdownHeading + | MarkdownCode + | MarkdownList + | MarkdownBlockquote + | MarkdownThematicBreak + | MarkdownTable + | MarkdownHtml; + +/** + * All markdown node types (union) + */ +export type MarkdownNode = + | MarkdownRoot + | MarkdownParagraph + | MarkdownHeading + | MarkdownText + | MarkdownStrong + | MarkdownEmphasis + | MarkdownStrikethrough + | MarkdownCode + | MarkdownInlineCode + | MarkdownLink + | MarkdownImage + | MarkdownList + | MarkdownListItem + | MarkdownBlockquote + | MarkdownThematicBreak + | MarkdownBreak + | MarkdownTable + | MarkdownTableRow + | MarkdownTableCell + | MarkdownHtml; + +/** + * Operation types for JSON patching + */ +export type MarkdownTreePatchOp = + | { + op: 'replace'; + path: string; + value: unknown; + } + | { + op: 'add'; + path: string; + value: unknown; + } + | { + op: 'remove'; + path: string; + } + | { + op: 'append-text'; + path: string; + value: string; + }; + +/** + * A batch of patch operations + */ +export type MarkdownTreePatch = MarkdownTreePatchOp[]; + +/** + * UI Message chunk types for markdown tree streaming + */ +export type MarkdownTreeChunk = + | { + type: 'markdown-tree-start'; + id: string; + } + | { + type: 'markdown-tree-patch'; + id: string; + patch: MarkdownTreePatch; + } + | { + type: 'markdown-tree-snapshot'; + id: string; + tree: MarkdownRoot; + } + | { + type: 'markdown-tree-end'; + id: string; + tree: MarkdownRoot; + }; + +/** + * Message with markdown tree + */ +export interface MarkdownMessage { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + markdownTree?: MarkdownRoot; + createdAt?: Date; +} + +/** + * Props for custom component renderers + */ +export interface MarkdownComponentProps { + node: T; + children?: React.ReactNode; +} + +/** + * Custom components map for MarkdownRenderer + */ +export interface MarkdownComponents { + root?: React.ComponentType>; + paragraph?: React.ComponentType>; + heading?: React.ComponentType< + MarkdownComponentProps & { level: 1 | 2 | 3 | 4 | 5 | 6 } + >; + text?: React.ComponentType>; + strong?: React.ComponentType>; + emphasis?: React.ComponentType>; + strikethrough?: React.ComponentType< + MarkdownComponentProps + >; + code?: React.ComponentType< + MarkdownComponentProps & { language?: string; value: string } + >; + inlineCode?: React.ComponentType< + MarkdownComponentProps & { value: string } + >; + link?: React.ComponentType< + MarkdownComponentProps & { href: string; title?: string } + >; + image?: React.ComponentType< + MarkdownComponentProps & { + src: string; + alt?: string; + title?: string; + } + >; + list?: React.ComponentType< + MarkdownComponentProps & { ordered: boolean } + >; + listItem?: React.ComponentType>; + blockquote?: React.ComponentType>; + thematicBreak?: React.ComponentType< + MarkdownComponentProps + >; + break?: React.ComponentType>; + table?: React.ComponentType>; + tableRow?: React.ComponentType>; + tableCell?: React.ComponentType>; + html?: React.ComponentType< + MarkdownComponentProps & { value: string } + >; +} diff --git a/packages/react-native/src/use-markdown-chat.ts b/packages/react-native/src/use-markdown-chat.ts new file mode 100644 index 000000000000..43eed2e49bbe --- /dev/null +++ b/packages/react-native/src/use-markdown-chat.ts @@ -0,0 +1,545 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import throttle from 'throttleit'; +import type { + MarkdownMessage, + MarkdownRoot, + MarkdownTreeChunk, + MarkdownTreePatch, +} from './types'; +import { applyMarkdownTreePatch } from './apply-patch'; + +export type MarkdownChatStatus = 'ready' | 'streaming' | 'submitted' | 'error'; + +export interface UseMarkdownChatOptions { + /** + * The API endpoint to send messages to. + */ + api: string; + + /** + * Optional unique identifier for this chat. + */ + id?: string; + + /** + * Initial messages to populate the chat. + */ + initialMessages?: MarkdownMessage[]; + + /** + * Additional headers to send with requests. + */ + headers?: Record; + + /** + * Additional body parameters to send with requests. + */ + body?: Record; + + /** + * Throttle interval for UI updates during streaming (ms). + * Default: 50ms + */ + throttleMs?: number; + + /** + * Callback when an error occurs. + */ + onError?: (error: Error) => void; + + /** + * Callback when streaming finishes. + */ + onFinish?: (message: MarkdownMessage) => void; + + /** + * Callback when a new message is received. + */ + onMessage?: (message: MarkdownMessage) => void; + + /** + * Custom fetch implementation. + * Useful for React Native where you might need to use a polyfill. + */ + fetch?: typeof fetch; + + /** + * Generate a unique ID for messages. + */ + generateId?: () => string; +} + +export interface UseMarkdownChatHelpers { + /** + * The current messages in the chat. + */ + messages: MarkdownMessage[]; + + /** + * Set the messages directly. + */ + setMessages: React.Dispatch>; + + /** + * Send a new message. + */ + sendMessage: (content: string, options?: SendMessageOptions) => Promise; + + /** + * Stop the current streaming response. + */ + stop: () => void; + + /** + * Reload and regenerate the last assistant response. + */ + reload: () => Promise; + + /** + * The current status of the chat. + */ + status: MarkdownChatStatus; + + /** + * The current error, if any. + */ + error?: Error; + + /** + * Clear the error state. + */ + clearError: () => void; + + /** + * Whether the chat is currently loading/streaming. + */ + isLoading: boolean; + + /** + * The current input value (for controlled input). + */ + input: string; + + /** + * Set the input value. + */ + setInput: React.Dispatch>; + + /** + * Handle input change event. + */ + handleInputChange: ( + e: React.ChangeEvent | string, + ) => void; + + /** + * Handle form submission. + */ + handleSubmit: (e?: React.FormEvent) => void; +} + +export interface SendMessageOptions { + /** + * Additional headers for this specific request. + */ + headers?: Record; + + /** + * Additional body parameters for this specific request. + */ + body?: Record; +} + +// Simple ID generator +function defaultGenerateId(): string { + return Math.random().toString(36).substring(2, 9); +} + +/** + * React hook for chat with optimized markdown tree streaming. + * + * This hook is designed for React Native and provides efficient markdown + * rendering through server-side parsing and JSON patch streaming. + * + * @example + * ```tsx + * const { messages, sendMessage, status } = useMarkdownChat({ + * api: '/api/chat', + * }); + * + * return ( + * + * {messages.map(message => ( + * + * {message.markdownTree ? ( + * + * ) : ( + * {message.content} + * )} + * + * ))} + * + * ); + * ``` + */ +export function useMarkdownChat( + options: UseMarkdownChatOptions, +): UseMarkdownChatHelpers { + const { + api, + id: chatId, + initialMessages = [], + headers: globalHeaders, + body: globalBody, + throttleMs = 50, + onError, + onFinish, + onMessage, + fetch: fetchFn = fetch, + generateId = defaultGenerateId, + } = options; + + const [messages, setMessages] = useState(initialMessages); + const [status, setStatus] = useState('ready'); + const [error, setError] = useState(); + const [input, setInput] = useState(''); + + const abortControllerRef = useRef(null); + const currentTreeRef = useRef(null); + const currentMessageIdRef = useRef(null); + + // Throttled update function + const updateMessageTree = useCallback( + (messageId: string, tree: MarkdownRoot, content: string) => { + setMessages(prev => { + const idx = prev.findIndex(m => m.id === messageId); + if (idx === -1) return prev; + + const updated = [...prev]; + updated[idx] = { + ...updated[idx], + markdownTree: tree, + content, + }; + return updated; + }); + }, + [], + ); + + const throttledUpdateTree = useCallback( + throttle(updateMessageTree, throttleMs), + [updateMessageTree, throttleMs], + ); + + const processSSEStream = useCallback( + async ( + reader: ReadableStreamDefaultReader, + messageId: string, + ) => { + const decoder = new TextDecoder(); + let buffer = ''; + let fullContent = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete SSE events + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') continue; + + try { + const chunk: MarkdownTreeChunk = JSON.parse(data); + processChunk(chunk, messageId, fullContent); + + // Extract content from tree for storage + if ( + chunk.type === 'markdown-tree-snapshot' || + chunk.type === 'markdown-tree-end' + ) { + fullContent = extractTextFromTree(chunk.tree); + } + } catch (e) { + console.warn('Failed to parse SSE chunk:', e); + } + } + } + } + } catch (err) { + if ((err as Error).name !== 'AbortError') { + throw err; + } + } + }, + [throttledUpdateTree], + ); + + const processChunk = useCallback( + (chunk: MarkdownTreeChunk, messageId: string, _currentContent: string) => { + switch (chunk.type) { + case 'markdown-tree-start': + currentTreeRef.current = { type: 'root', children: [] }; + currentMessageIdRef.current = messageId; + break; + + case 'markdown-tree-snapshot': + currentTreeRef.current = chunk.tree; + throttledUpdateTree( + messageId, + chunk.tree, + extractTextFromTree(chunk.tree), + ); + break; + + case 'markdown-tree-patch': + if (currentTreeRef.current) { + currentTreeRef.current = applyMarkdownTreePatch( + currentTreeRef.current, + chunk.patch, + ); + throttledUpdateTree( + messageId, + currentTreeRef.current, + extractTextFromTree(currentTreeRef.current), + ); + } + break; + + case 'markdown-tree-end': + currentTreeRef.current = chunk.tree; + // Final update without throttling + updateMessageTree( + messageId, + chunk.tree, + extractTextFromTree(chunk.tree), + ); + break; + } + }, + [throttledUpdateTree, updateMessageTree], + ); + + const sendMessage = useCallback( + async (content: string, sendOptions: SendMessageOptions = {}) => { + if (status === 'streaming' || status === 'submitted') { + return; + } + + const userMessage: MarkdownMessage = { + id: generateId(), + role: 'user', + content, + createdAt: new Date(), + }; + + const assistantMessage: MarkdownMessage = { + id: generateId(), + role: 'assistant', + content: '', + createdAt: new Date(), + }; + + setMessages(prev => [...prev, userMessage, assistantMessage]); + setStatus('submitted'); + setError(undefined); + + // Reset refs + currentTreeRef.current = null; + currentMessageIdRef.current = assistantMessage.id; + + // Create abort controller + abortControllerRef.current = new AbortController(); + + try { + const response = await fetchFn(api, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + ...globalHeaders, + ...sendOptions.headers, + }, + body: JSON.stringify({ + messages: [...messages, userMessage].map(m => ({ + role: m.role, + content: m.content, + })), + chatId, + ...globalBody, + ...sendOptions.body, + }), + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + + setStatus('streaming'); + + const reader = response.body.getReader(); + await processSSEStream(reader, assistantMessage.id); + + setStatus('ready'); + + // Get final message + const finalMessages = await new Promise(resolve => { + setMessages(prev => { + resolve(prev); + return prev; + }); + }); + + const finalAssistantMessage = finalMessages.find( + m => m.id === assistantMessage.id, + ); + if (finalAssistantMessage) { + onFinish?.(finalAssistantMessage); + onMessage?.(finalAssistantMessage); + } + } catch (err) { + if ((err as Error).name === 'AbortError') { + setStatus('ready'); + return; + } + + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + setStatus('error'); + onError?.(error); + } finally { + abortControllerRef.current = null; + } + }, + [ + api, + chatId, + fetchFn, + generateId, + globalBody, + globalHeaders, + messages, + onError, + onFinish, + onMessage, + processSSEStream, + status, + ], + ); + + const stop = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + setStatus('ready'); + }, []); + + const reload = useCallback(async () => { + // Find the last user message + let lastUserMessageIndex = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'user') { + lastUserMessageIndex = i; + break; + } + } + if (lastUserMessageIndex === -1) return; + + const lastUserMessage = messages[lastUserMessageIndex]; + + // Remove the last assistant message if it exists + const newMessages = messages.slice(0, lastUserMessageIndex); + setMessages(newMessages); + + // Resend the user message + await sendMessage(lastUserMessage.content); + }, [messages, sendMessage]); + + const clearError = useCallback(() => { + setError(undefined); + if (status === 'error') { + setStatus('ready'); + } + }, [status]); + + const handleInputChange = useCallback( + (e: React.ChangeEvent | string) => { + const value = typeof e === 'string' ? e : e.target.value; + setInput(value); + }, + [], + ); + + const handleSubmit = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault(); + if (input.trim()) { + sendMessage(input.trim()); + setInput(''); + } + }, + [input, sendMessage], + ); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + return { + messages, + setMessages, + sendMessage, + stop, + reload, + status, + error, + clearError, + isLoading: status === 'streaming' || status === 'submitted', + input, + setInput, + handleInputChange, + handleSubmit, + }; +} + +/** + * Extract plain text content from a markdown tree. + * Useful for storing the content alongside the tree structure. + */ +function extractTextFromTree(tree: MarkdownRoot): string { + const parts: string[] = []; + + function traverse(node: MarkdownRoot | MarkdownRoot['children'][number]) { + if ('value' in node && typeof node.value === 'string') { + parts.push(node.value); + } + if ('children' in node && Array.isArray(node.children)) { + for (const child of node.children) { + traverse(child); + } + } + } + + traverse(tree); + return parts.join(''); +} diff --git a/packages/react-native/tsconfig.build.json b/packages/react-native/tsconfig.build.json new file mode 100644 index 000000000000..80b6a0a84612 --- /dev/null +++ b/packages/react-native/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false + } +} diff --git a/packages/react-native/tsconfig.json b/packages/react-native/tsconfig.json new file mode 100644 index 000000000000..3e0aca2f3895 --- /dev/null +++ b/packages/react-native/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@vercel/ai-tsconfig/react-library.json", + "compilerOptions": { + "target": "ES2018", + "stripInternal": true, + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["dist", "build", "node_modules", "tsup.config.ts"] +} diff --git a/packages/react-native/tsup.config.ts b/packages/react-native/tsup.config.ts new file mode 100644 index 000000000000..e58f2306c986 --- /dev/null +++ b/packages/react-native/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + { + entry: ['src/index.ts', 'src/server/index.ts'], + outDir: 'dist', + banner: {}, + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + }, +]); diff --git a/packages/react-native/turbo.json b/packages/react-native/turbo.json new file mode 100644 index 000000000000..35be9c68fa8b --- /dev/null +++ b/packages/react-native/turbo.json @@ -0,0 +1,8 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/packages/react-native/vitest.node.config.js b/packages/react-native/vitest.node.config.js new file mode 100644 index 000000000000..c1184646d6de --- /dev/null +++ b/packages/react-native/vitest.node.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +// https://vitejs.dev/config/ +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce750ca66c36..01526448b100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2674,6 +2674,40 @@ importers: specifier: 3.25.76 version: 3.25.76 + packages/react-native: + dependencies: + react: + specifier: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + version: 18.3.1 + throttleit: + specifier: 2.1.0 + version: 2.1.0 + devDependencies: + '@types/node': + specifier: 20.17.24 + version: 20.17.24 + '@types/react': + specifier: ^18 + version: 18.3.3 + '@vercel/ai-tsconfig': + specifier: workspace:* + version: link:../../tools/tsconfig + eslint: + specifier: 8.57.1 + version: 8.57.1 + eslint-config-vercel-ai: + specifier: workspace:* + version: link:../../tools/eslint-config + tsup: + specifier: ^7.2.0 + version: 7.2.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.3))(typescript@5.8.3) + typescript: + specifier: 5.8.3 + version: 5.8.3 + zod: + specifier: 3.25.76 + version: 3.25.76 + packages/replicate: dependencies: '@ai-sdk/provider': @@ -19430,7 +19464,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.3(chokidar@4.0.3) - '@angular-devkit/build-webpack': 0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2) + '@angular-devkit/build-webpack': 0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9)) '@angular-devkit/core': 20.3.3(chokidar@4.0.3) '@angular/build': 20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(@angular/compiler@20.3.2)(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@20.17.24)(chokidar@4.0.3)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(postcss@8.5.6)(tailwindcss@4.1.18)(terser@5.43.1)(tslib@2.8.1)(tsx@4.19.2)(typescript@5.8.3)(vitest@2.1.4(@edge-runtime/vm@5.0.0)(@types/node@22.7.4)(jsdom@26.0.0)(less@4.4.0)(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.7.4)(typescript@5.8.3))(sass@1.90.0)(terser@5.43.1))(yaml@2.7.0) '@angular/compiler-cli': 20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3) @@ -19444,13 +19478,13 @@ snapshots: '@babel/preset-env': 7.28.3(@babel/core@7.28.3) '@babel/runtime': 7.28.3 '@discoveryjs/json-ext': 0.6.3 - '@ngtools/webpack': 20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2) + '@ngtools/webpack': 20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9)) ansi-colors: 4.1.3 autoprefixer: 10.4.21(postcss@8.5.6) - babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2) + babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)) browserslist: 4.25.1 - copy-webpack-plugin: 13.0.1(webpack@5.101.2) - css-loader: 7.1.2(webpack@5.101.2) + copy-webpack-plugin: 13.0.1(webpack@5.101.2(esbuild@0.25.9)) + css-loader: 7.1.2(webpack@5.101.2(esbuild@0.25.9)) esbuild-wasm: 0.25.9 fast-glob: 3.3.3 http-proxy-middleware: 3.0.5 @@ -19458,22 +19492,22 @@ snapshots: jsonc-parser: 3.3.1 karma-source-map-support: 1.4.0 less: 4.4.0 - less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2) - license-webpack-plugin: 4.0.2(webpack@5.101.2) + less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)) + license-webpack-plugin: 4.0.2(webpack@5.101.2(esbuild@0.25.9)) loader-utils: 3.3.1 - mini-css-extract-plugin: 2.9.4(webpack@5.101.2) + mini-css-extract-plugin: 2.9.4(webpack@5.101.2(esbuild@0.25.9)) open: 10.2.0 ora: 8.2.0 picomatch: 4.0.3 piscina: 5.1.3 postcss: 8.5.6 - postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2) + postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9)) resolve-url-loader: 5.0.0 rxjs: 7.8.2 sass: 1.90.0 - sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2) + sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)) semver: 7.7.2 - source-map-loader: 5.0.0(webpack@5.101.2) + source-map-loader: 5.0.0(webpack@5.101.2(esbuild@0.25.9)) source-map-support: 0.5.21 terser: 5.43.1 tree-kill: 1.2.2 @@ -19483,7 +19517,7 @@ snapshots: webpack-dev-middleware: 7.4.2(webpack@5.101.2) webpack-dev-server: 5.2.2(webpack@5.101.2) webpack-merge: 6.0.1 - webpack-subresource-integrity: 5.1.0(webpack@5.101.2) + webpack-subresource-integrity: 5.1.0(webpack@5.101.2(esbuild@0.25.9)) optionalDependencies: '@angular/core': 20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)) @@ -19513,7 +19547,7 @@ snapshots: - webpack-cli - yaml - '@angular-devkit/build-webpack@0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2)': + '@angular-devkit/build-webpack@0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9))': dependencies: '@angular-devkit/architect': 0.2003.3(chokidar@4.0.3) rxjs: 7.8.2 @@ -24647,7 +24681,7 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.7': optional: true - '@ngtools/webpack@20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2)': + '@ngtools/webpack@20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9))': dependencies: '@angular/compiler-cli': 20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3) typescript: 5.8.3 @@ -29588,7 +29622,7 @@ snapshots: transitivePeerDependencies: - supports-color - babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2): + babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)): dependencies: '@babel/core': 7.28.3 find-up: 5.0.0 @@ -30335,7 +30369,7 @@ snapshots: dependencies: is-what: 5.5.0 - copy-webpack-plugin@13.0.1(webpack@5.101.2): + copy-webpack-plugin@13.0.1(webpack@5.101.2(esbuild@0.25.9)): dependencies: glob-parent: 6.0.2 normalize-path: 3.0.0 @@ -30437,7 +30471,7 @@ snapshots: dependencies: postcss: 8.5.6 - css-loader@7.1.2(webpack@5.101.2): + css-loader@7.1.2(webpack@5.101.2(esbuild@0.25.9)): dependencies: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 @@ -34457,7 +34491,7 @@ snapshots: dependencies: readable-stream: 2.3.8 - less-loader@12.3.0(less@4.4.0)(webpack@5.101.2): + less-loader@12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)): dependencies: less: 4.4.0 optionalDependencies: @@ -34484,7 +34518,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - license-webpack-plugin@4.0.2(webpack@5.101.2): + license-webpack-plugin@4.0.2(webpack@5.101.2(esbuild@0.25.9)): dependencies: webpack-sources: 3.3.3 optionalDependencies: @@ -35348,7 +35382,7 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.9.4(webpack@5.101.2): + mini-css-extract-plugin@2.9.4(webpack@5.101.2(esbuild@0.25.9)): dependencies: schema-utils: 4.3.2 tapable: 2.2.1 @@ -36792,7 +36826,7 @@ snapshots: tsx: 4.21.0 yaml: 2.7.0 - postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2): + postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9)): dependencies: cosmiconfig: 9.0.0(typescript@5.8.3) jiti: 1.21.6 @@ -37779,7 +37813,7 @@ snapshots: safer-buffer@2.1.2: {} - sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2): + sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -38134,7 +38168,7 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.101.2): + source-map-loader@5.0.0(webpack@5.101.2(esbuild@0.25.9)): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 @@ -40425,7 +40459,7 @@ snapshots: webpack-sources@3.3.3: {} - webpack-subresource-integrity@5.1.0(webpack@5.101.2): + webpack-subresource-integrity@5.1.0(webpack@5.101.2(esbuild@0.25.9)): dependencies: typed-assert: 1.0.9 webpack: 5.101.2(esbuild@0.25.9) diff --git a/tsconfig.json b/tsconfig.json index 6343ac10aa9d..34e3dcc18a0b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ { "path": "packages/provider" }, { "path": "packages/provider-utils" }, { "path": "packages/react" }, + { "path": "packages/react-native" }, { "path": "packages/replicate" }, { "path": "packages/revai" }, { "path": "packages/rsc" }, From 9f3ad0a04b8ef7effeab82444d21aa1c068026f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Jan 2026 22:44:55 +0000 Subject: [PATCH 2/4] Refactor: Update react-native package to use @ai-sdk/react Co-authored-by: jcourson8 --- packages/react-native/README.md | 335 ++++++++--- packages/react-native/package.json | 1 + packages/react-native/src/index.ts | 70 ++- .../react-native/src/use-markdown-chat.ts | 545 ------------------ .../react-native/src/use-markdown-tree.tsx | 142 +++++ packages/react-native/tsconfig.json | 3 +- pnpm-lock.yaml | 3 + 7 files changed, 451 insertions(+), 648 deletions(-) delete mode 100644 packages/react-native/src/use-markdown-chat.ts create mode 100644 packages/react-native/src/use-markdown-tree.tsx diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 21908d0fbeaa..8932e30917f2 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -1,28 +1,17 @@ # AI SDK React Native -React Native integration for the [AI SDK](https://ai-sdk.dev/docs) with optimized markdown streaming. +React Native optimized utilities for the [AI SDK](https://ai-sdk.dev/docs). ## Overview -This package provides React Native-optimized hooks and utilities for building AI-powered chat applications. The key innovation is **server-side markdown parsing with JSON tree streaming**, which dramatically improves rendering performance on React Native. +This package provides React Native-specific optimizations for the AI SDK, including efficient markdown rendering. It re-exports all hooks from `@ai-sdk/react` so you can use the same API you're familiar with. -### The Problem +### Key Features -Parsing markdown in React Native is expensive. Traditional approaches send raw text to the client, where it must be parsed on every render. This creates: - -- High CPU usage during streaming -- Janky animations and scrolling -- Battery drain on mobile devices - -### The Solution - -Instead of streaming raw markdown text, this package: - -1. **Parses markdown on the server** into a JSON tree structure -2. **Streams JSON patches** to the client for efficient updates -3. **Renders native components** directly from the tree - -This approach, inspired by v0's mobile app, shifts the expensive parsing work to the server where it belongs. +- **Same API as `@ai-sdk/react`** - `useChat`, `useCompletion`, `useObject` all work the same +- **Optimized markdown rendering** - Parse markdown to JSON trees for efficient native rendering +- **Full agent support** - Tools, multi-step, reasoning, and all other parts work as expected +- **Server-side parsing option** - For maximum performance, parse on the server and stream trees ## Installation @@ -32,41 +21,111 @@ npm install @ai-sdk/react-native ## Usage -### Client Side (React Native) +### Basic Chat with Markdown ```tsx -import { useMarkdownChat, MarkdownRenderer } from '@ai-sdk/react-native'; -import { View, Text, ScrollView } from 'react-native'; +import { useChat } from '@ai-sdk/react-native'; +import { MarkdownText } from '@ai-sdk/react-native'; +import { View, Text, TextInput, Button, ScrollView } from 'react-native'; function ChatScreen() { - const { messages, sendMessage, status } = useMarkdownChat({ + const { messages, input, handleInputChange, handleSubmit, status } = useChat({ api: '/api/chat', }); + return ( + + + {messages.map(message => ( + + {message.role} + {message.parts.map((part, index) => { + switch (part.type) { + case 'text': + // Use MarkdownText for efficient markdown rendering + return ; + + case 'reasoning': + return ( + + Thinking... + + + ); + + default: + return null; + } + })} + + ))} + + + + +