UNPKG

@v0-sdk/react

Version:

Headless React components for rendering v0 Platform API content

1,203 lines (1,190 loc) 44.2 kB
import React, { createContext, useContext, useState, useRef, useSyncExternalStore } from 'react'; import * as jsondiffpatch from 'jsondiffpatch'; // Headless hook for code block data function useCodeBlock(props) { const lines = props.code.split('\n'); return { language: props.language, code: props.code, filename: props.filename, lines, lineCount: lines.length }; } /** * Generic code block component * Renders plain code by default - consumers should provide their own styling and highlighting * * For headless usage, use the useCodeBlock hook instead. */ function CodeBlock({ language, code, className = '', children, filename }) { // If children provided, use that (allows complete customization) if (children) { return React.createElement(React.Fragment, {}, children); } const codeBlockData = useCodeBlock({ language, code, filename }); // Simple fallback - just render plain code // Uses React.createElement for maximum compatibility across environments return React.createElement('pre', { className, 'data-language': codeBlockData.language, ...filename && { 'data-filename': filename } }, React.createElement('code', {}, codeBlockData.code)); } // Context for providing custom icon implementation const IconContext = createContext(null); // Headless hook for icon data function useIcon(props) { return { name: props.name, fallback: getIconFallback(props.name), ariaLabel: props.name.replace('-', ' ') }; } /** * Generic icon component that can be customized by consumers. * By default, renders a simple fallback. Consumers should provide * their own icon implementation via context or props. * * For headless usage, use the useIcon hook instead. */ function Icon(props) { const CustomIcon = useContext(IconContext); // Use custom icon implementation if provided via context if (CustomIcon) { return React.createElement(CustomIcon, props); } const iconData = useIcon(props); // Fallback implementation - consumers should override this // This uses minimal DOM-specific attributes for maximum compatibility return React.createElement('span', { className: props.className, 'data-icon': iconData.name, 'aria-label': iconData.ariaLabel }, iconData.fallback); } /** * Provider for custom icon implementation */ function IconProvider({ children, component }) { return React.createElement(IconContext.Provider, { value: component }, children); } function getIconFallback(name) { const iconMap = { 'chevron-right': '▶', 'chevron-down': '▼', search: '🔍', folder: '📁', settings: '⚙️', 'file-text': '📄', brain: '🧠', wrench: '🔧' }; return iconMap[name] || '•'; } // Headless hook for code project function useCodeProject({ title, filename, code, language = 'typescript', collapsed: initialCollapsed = true }) { const [collapsed, setCollapsed] = useState(initialCollapsed); // Mock file structure - in a real implementation this could be dynamic const files = [ { name: filename || 'page.tsx', path: 'app/page.tsx', active: true }, { name: 'layout.tsx', path: 'app/layout.tsx', active: false }, { name: 'globals.css', path: 'app/globals.css', active: false } ]; return { data: { title: title || 'Code Project', filename, code, language, collapsed, files }, collapsed, toggleCollapsed: ()=>setCollapsed(!collapsed) }; } /** * Generic code project block component * Renders a collapsible code project with basic structure - consumers provide styling * * For headless usage, use the useCodeProject hook instead. */ function CodeProjectPart({ title, filename, code, language = 'typescript', collapsed: initialCollapsed = true, className, children, iconRenderer }) { const { data, collapsed, toggleCollapsed } = useCodeProject({ title, filename, code, language, collapsed: initialCollapsed }); // If children provided, use that (allows complete customization) if (children) { return React.createElement(React.Fragment, {}, children); } // Uses React.createElement for maximum compatibility across environments return React.createElement('div', { className, 'data-component': 'code-project-block' }, React.createElement('button', { onClick: toggleCollapsed, 'data-expanded': !collapsed }, React.createElement('div', { 'data-header': true }, iconRenderer ? React.createElement(iconRenderer, { name: 'folder' }) : React.createElement(Icon, { name: 'folder' }), React.createElement('span', { 'data-title': true }, data.title)), React.createElement('span', { 'data-version': true }, 'v1')), !collapsed ? React.createElement('div', { 'data-content': true }, React.createElement('div', { 'data-file-list': true }, data.files.map((file, index)=>React.createElement('div', { key: index, 'data-file': true, ...file.active && { 'data-active': true } }, iconRenderer ? React.createElement(iconRenderer, { name: 'file-text' }) : React.createElement(Icon, { name: 'file-text' }), React.createElement('span', { 'data-filename': true }, file.name), React.createElement('span', { 'data-filepath': true }, file.path)))), data.code ? React.createElement(CodeBlock, { language: data.language, code: data.code }) : null) : null); } // Headless hook for thinking section function useThinkingSection({ title, duration, thought, collapsed: initialCollapsed = true, onCollapse }) { const [internalCollapsed, setInternalCollapsed] = useState(initialCollapsed); const collapsed = onCollapse ? initialCollapsed : internalCollapsed; const handleCollapse = onCollapse || (()=>setInternalCollapsed(!internalCollapsed)); const paragraphs = thought ? thought.split('\n\n') : []; const formattedDuration = duration ? `${Math.round(duration)}s` : undefined; return { data: { title: title || 'Thinking', duration, thought, collapsed, paragraphs, formattedDuration }, collapsed, handleCollapse }; } /** * Generic thinking section component * Renders a collapsible section with basic structure - consumers provide styling * * For headless usage, use the useThinkingSection hook instead. */ function ThinkingSection({ title, duration, thought, collapsed: initialCollapsed = true, onCollapse, className, children, iconRenderer, brainIcon, chevronRightIcon, chevronDownIcon }) { const { data, collapsed, handleCollapse } = useThinkingSection({ title, duration, thought, collapsed: initialCollapsed, onCollapse }); // If children provided, use that (allows complete customization) if (children) { return React.createElement(React.Fragment, {}, children); } // Uses React.createElement for maximum compatibility across environments return React.createElement('div', { className, 'data-component': 'thinking-section' }, React.createElement('button', { onClick: handleCollapse, 'data-expanded': !collapsed, 'data-button': true }, React.createElement('div', { 'data-icon-container': true }, collapsed ? React.createElement(React.Fragment, {}, brainIcon || (iconRenderer ? React.createElement(iconRenderer, { name: 'brain' }) : React.createElement(Icon, { name: 'brain' })), chevronRightIcon || (iconRenderer ? React.createElement(iconRenderer, { name: 'chevron-right' }) : React.createElement(Icon, { name: 'chevron-right' }))) : chevronDownIcon || (iconRenderer ? React.createElement(iconRenderer, { name: 'chevron-down' }) : React.createElement(Icon, { name: 'chevron-down' }))), React.createElement('span', { 'data-title': true }, data.title + (data.formattedDuration ? ` for ${data.formattedDuration}` : ''))), !collapsed && data.thought ? React.createElement('div', { 'data-content': true }, React.createElement('div', { 'data-thought-container': true }, data.paragraphs.map((paragraph, index)=>React.createElement('div', { key: index, 'data-paragraph': true }, paragraph)))) : null); } function getTypeIcon(type, title) { // Check title content for specific cases if (title?.includes('No issues found')) { return 'wrench'; } if (title?.includes('Analyzed codebase')) { return 'search'; } // Fallback to type-based icons switch(type){ case 'task-search-web-v1': return 'search'; case 'task-search-repo-v1': return 'folder'; case 'task-diagnostics-v1': return 'settings'; case 'task-generate-design-inspiration-v1': return 'wrench'; case 'task-read-file-v1': return 'folder'; case 'task-coding-v1': return 'wrench'; default: return 'wrench'; } } function processTaskPart(part, index) { const baseData = { type: part.type, status: part.status, content: null }; if (part.type === 'search-web') { if (part.status === 'searching') { return { ...baseData, isSearching: true, query: part.query, content: `Searching "${part.query}"` }; } if (part.status === 'analyzing') { return { ...baseData, isAnalyzing: true, count: part.count, content: `Analyzing ${part.count} results...` }; } if (part.status === 'complete' && part.answer) { return { ...baseData, isComplete: true, answer: part.answer, sources: part.sources, content: part.answer }; } } if (part.type === 'search-repo') { if (part.status === 'searching') { return { ...baseData, isSearching: true, query: part.query, content: `Searching "${part.query}"` }; } if (part.status === 'reading' && part.files) { return { ...baseData, files: part.files, content: 'Reading files' }; } } if (part.type === 'diagnostics') { if (part.status === 'checking') { return { ...baseData, content: 'Checking for issues...' }; } if (part.status === 'complete' && part.issues === 0) { return { ...baseData, isComplete: true, issues: part.issues, content: '✅ No issues found' }; } } return { ...baseData, content: JSON.stringify(part) }; } function renderTaskPartContent(partData, index, iconRenderer) { if (partData.type === 'search-web' && partData.isComplete && partData.sources) { return React.createElement('div', { key: index }, React.createElement('p', {}, partData.content), partData.sources.length > 0 ? React.createElement('div', {}, partData.sources.map((source, sourceIndex)=>React.createElement('a', { key: sourceIndex, href: source.url, target: '_blank', rel: 'noopener noreferrer' }, source.title))) : null); } if (partData.type === 'search-repo' && partData.files) { return React.createElement('div', { key: index }, React.createElement('span', {}, partData.content), partData.files.map((file, fileIndex)=>React.createElement('span', { key: fileIndex }, iconRenderer ? React.createElement(iconRenderer, { name: 'file-text' }) : React.createElement(Icon, { name: 'file-text' }), ' ', file))); } return React.createElement('div', { key: index }, partData.content); } // Headless hook for task section function useTaskSection({ title, type, parts = [], collapsed: initialCollapsed = true, onCollapse }) { const [internalCollapsed, setInternalCollapsed] = useState(initialCollapsed); const collapsed = onCollapse ? initialCollapsed : internalCollapsed; const handleCollapse = onCollapse || (()=>setInternalCollapsed(!internalCollapsed)); // Count meaningful parts (parts that would render something) const meaningfulParts = parts.filter((part)=>{ // Check if the part would render meaningful content if (part.type === 'search-web') { return part.status === 'searching' || part.status === 'analyzing' || part.status === 'complete' && part.answer; } if (part.type === 'starting-repo-search' && part.query) return true; if (part.type === 'select-files' && part.filePaths?.length > 0) return true; if (part.type === 'starting-web-search' && part.query) return true; if (part.type === 'got-results' && part.count) return true; if (part.type === 'finished-web-search' && part.answer) return true; if (part.type === 'diagnostics-passed') return true; if (part.type === 'fetching-diagnostics') return true; return false; }); const processedParts = parts.map(processTaskPart); return { data: { title: title || 'Task', type, parts, collapsed, meaningfulParts, shouldShowCollapsible: meaningfulParts.length > 1, iconName: getTypeIcon(type, title) }, collapsed, handleCollapse, processedParts }; } /** * Generic task section component * Renders a collapsible task section with basic structure - consumers provide styling * * For headless usage, use the useTaskSection hook instead. */ function TaskSection({ title, type, parts = [], collapsed: initialCollapsed = true, onCollapse, className, children, iconRenderer, taskIcon, chevronRightIcon, chevronDownIcon }) { const { data, collapsed, handleCollapse, processedParts } = useTaskSection({ title, type, parts, collapsed: initialCollapsed, onCollapse }); // If children provided, use that (allows complete customization) if (children) { return React.createElement(React.Fragment, {}, children); } // If there's only one meaningful part, show just the content without the collapsible wrapper if (!data.shouldShowCollapsible && data.meaningfulParts.length === 1) { const partData = processTaskPart(data.meaningfulParts[0]); return React.createElement('div', { className, 'data-component': 'task-section-inline' }, React.createElement('div', { 'data-part': true }, renderTaskPartContent(partData, 0, iconRenderer))); } // Uses React.createElement for maximum compatibility across environments return React.createElement('div', { className, 'data-component': 'task-section' }, React.createElement('button', { onClick: handleCollapse, 'data-expanded': !collapsed, 'data-button': true }, React.createElement('div', { 'data-icon-container': true }, React.createElement('div', { 'data-task-icon': true }, taskIcon || (iconRenderer ? React.createElement(iconRenderer, { name: data.iconName }) : React.createElement(Icon, { name: data.iconName }))), collapsed ? chevronRightIcon || (iconRenderer ? React.createElement(iconRenderer, { name: 'chevron-right' }) : React.createElement(Icon, { name: 'chevron-right' })) : chevronDownIcon || (iconRenderer ? React.createElement(iconRenderer, { name: 'chevron-down' }) : React.createElement(Icon, { name: 'chevron-down' }))), React.createElement('span', { 'data-title': true }, data.title)), !collapsed ? React.createElement('div', { 'data-content': true }, React.createElement('div', { 'data-parts-container': true }, processedParts.map((partData, index)=>React.createElement('div', { key: index, 'data-part': true }, renderTaskPartContent(partData, index, iconRenderer))))) : null); } // Headless hook for content part function useContentPart(part) { if (!part) { return { type: '', parts: [], metadata: {}, componentType: null }; } const { type, parts = [], ...metadata } = part; let componentType = 'unknown'; let title; let iconName; let thinkingData; switch(type){ case 'task-thinking-v1': componentType = 'thinking'; title = 'Thought'; const thinkingPart = parts.find((p)=>p.type === 'thinking-end'); thinkingData = { duration: thinkingPart?.duration, thought: thinkingPart?.thought }; break; case 'task-search-web-v1': componentType = 'task'; title = metadata.taskNameComplete || metadata.taskNameActive; iconName = 'search'; break; case 'task-search-repo-v1': componentType = 'task'; title = metadata.taskNameComplete || metadata.taskNameActive; iconName = 'folder'; break; case 'task-diagnostics-v1': componentType = 'task'; title = metadata.taskNameComplete || metadata.taskNameActive; iconName = 'settings'; break; case 'task-read-file-v1': componentType = 'task'; title = metadata.taskNameComplete || metadata.taskNameActive || 'Reading file'; iconName = 'folder'; break; case 'task-coding-v1': componentType = 'task'; title = metadata.taskNameComplete || metadata.taskNameActive || 'Coding'; iconName = 'wrench'; break; case 'task-start-v1': componentType = null; // Usually just indicates task start - can be hidden break; case 'task-generate-design-inspiration-v1': componentType = 'task'; title = metadata.taskNameComplete || metadata.taskNameActive || 'Generating Design Inspiration'; iconName = 'wrench'; break; // Handle any other task-*-v1 patterns that might be added in the future default: // Check if it's a task type we haven't explicitly handled yet if (type && typeof type === 'string' && type.startsWith('task-') && type.endsWith('-v1')) { componentType = 'task'; // Generate a readable title from the task type const taskName = type.replace('task-', '').replace('-v1', '').split('-').map((word)=>word.charAt(0).toUpperCase() + word.slice(1)).join(' '); title = metadata.taskNameComplete || metadata.taskNameActive || taskName; iconName = 'wrench'; // Default icon for unknown task types } else { componentType = 'unknown'; } break; } return { type, parts, metadata, componentType, title, iconName, thinkingData }; } /** * Content part renderer that handles different types of v0 API content parts * * For headless usage, use the useContentPart hook instead. */ function ContentPartRenderer({ part, iconRenderer, thinkingSectionRenderer, taskSectionRenderer, brainIcon, chevronRightIcon, chevronDownIcon, searchIcon, folderIcon, settingsIcon, wrenchIcon }) { const contentData = useContentPart(part); if (!contentData.componentType) { return null; } if (contentData.componentType === 'thinking') { const ThinkingComponent = thinkingSectionRenderer || ThinkingSection; const [collapsed, setCollapsed] = useState(true); return React.createElement(ThinkingComponent, { title: contentData.title, duration: contentData.thinkingData?.duration, thought: contentData.thinkingData?.thought, collapsed, onCollapse: ()=>setCollapsed(!collapsed), brainIcon, chevronRightIcon, chevronDownIcon }); } if (contentData.componentType === 'task') { const TaskComponent = taskSectionRenderer || TaskSection; const [collapsed, setCollapsed] = useState(true); // Map icon names to icon components let taskIcon; switch(contentData.iconName){ case 'search': taskIcon = searchIcon; break; case 'folder': taskIcon = folderIcon; break; case 'settings': taskIcon = settingsIcon; break; case 'wrench': taskIcon = wrenchIcon; break; default: taskIcon = undefined; break; } return React.createElement(TaskComponent, { title: contentData.title, type: contentData.type, parts: contentData.parts, collapsed, onCollapse: ()=>setCollapsed(!collapsed), taskIcon, chevronRightIcon, chevronDownIcon }); } if (contentData.componentType === 'unknown') { return React.createElement('div', { 'data-unknown-part-type': contentData.type }, `Unknown part type: ${contentData.type}`); } return null; } // Utility function to merge class names function cn(...classes) { return classes.filter(Boolean).join(' '); } // Headless hook for processing message content function useMessage({ content, messageId = 'unknown', role = 'assistant', streaming = false, isLastMessage = false, components, renderers }) { if (!Array.isArray(content)) { console.warn('MessageContent: content must be an array (MessageBinaryFormat)'); return { elements: [], messageId, role, streaming, isLastMessage }; } // Merge components and renderers (backward compatibility) const mergedComponents = { ...components, // Map legacy renderers to new component names ...renderers?.CodeBlock && { CodeBlock: renderers.CodeBlock }, ...renderers?.MathRenderer && { MathPart: renderers.MathRenderer }, ...renderers?.MathPart && { MathPart: renderers.MathPart }, ...renderers?.Icon && { Icon: renderers.Icon } }; // Process content exactly like v0's Renderer component const elements = content.map(([type, data], index)=>{ const key = `${messageId}-${index}`; // Markdown data (type 0) - this is the main content if (type === 0) { return processElements(data, key, mergedComponents); } // Metadata (type 1) - extract context but don't render if (type === 1) { // In the future, we could extract sources/context here like v0 does // For now, just return null like v0's renderer return null; } // Other types - v0 doesn't handle these in the main renderer return null; }).filter(Boolean); return { elements, messageId, role, streaming, isLastMessage }; } // Process elements into headless data structure function processElements(data, keyPrefix, components) { // Handle case where data might not be an array due to streaming/patching if (!Array.isArray(data)) { return null; } const children = data.map((item, index)=>{ const key = `${keyPrefix}-${index}`; return processElement(item, key, components); }).filter(Boolean); return { type: 'component', key: keyPrefix, data: 'elements', children }; } // Process individual elements into headless data structure function processElement(element, key, components) { if (typeof element === 'string') { return { type: 'text', key, data: element }; } if (!Array.isArray(element)) { return null; } const [tagName, props, ...children] = element; if (!tagName) { return null; } // Handle special v0 Platform API elements if (tagName === 'AssistantMessageContentPart') { return { type: 'content-part', key, data: { part: props.part, iconRenderer: components?.Icon, thinkingSectionRenderer: components?.ThinkingSection, taskSectionRenderer: components?.TaskSection } }; } if (tagName === 'Codeblock') { return { type: 'code-project', key, data: { language: props.lang, code: children[0], iconRenderer: components?.Icon, customRenderer: components?.CodeProjectPart } }; } if (tagName === 'text') { return { type: 'text', key, data: children[0] || '' }; } // Process children const processedChildren = children.map((child, childIndex)=>{ const childKey = `${key}-child-${childIndex}`; return processElement(child, childKey, components); }).filter(Boolean); // Handle standard HTML elements const componentOrConfig = components?.[tagName]; return { type: 'html', key, data: { tagName, props, componentOrConfig }, children: processedChildren }; } // Default JSX renderer for backward compatibility function MessageRenderer({ messageData, className }) { const renderElement = (element)=>{ switch(element.type){ case 'text': return React.createElement('span', { key: element.key }, element.data); case 'content-part': return React.createElement(ContentPartRenderer, { key: element.key, part: element.data.part, iconRenderer: element.data.iconRenderer, thinkingSectionRenderer: element.data.thinkingSectionRenderer, taskSectionRenderer: element.data.taskSectionRenderer }); case 'code-project': const CustomCodeProjectPart = element.data.customRenderer; const CodeProjectComponent = CustomCodeProjectPart || CodeProjectPart; return React.createElement(CodeProjectComponent, { key: element.key, language: element.data.language, code: element.data.code, iconRenderer: element.data.iconRenderer }); case 'html': const { tagName, props, componentOrConfig } = element.data; const renderedChildren = element.children?.map(renderElement); if (typeof componentOrConfig === 'function') { const Component = componentOrConfig; return React.createElement(Component, { key: element.key, ...props, className: props?.className }, renderedChildren); } else if (componentOrConfig && typeof componentOrConfig === 'object') { const mergedClassName = cn(props?.className, componentOrConfig.className); return React.createElement(tagName, { key: element.key, ...props, className: mergedClassName }, renderedChildren); } else { // Default HTML element rendering const elementProps = { key: element.key, ...props }; if (props?.className) { elementProps.className = props.className; } // Special handling for links if (tagName === 'a') { elementProps.target = '_blank'; elementProps.rel = 'noopener noreferrer'; } return React.createElement(tagName, elementProps, renderedChildren); } case 'component': return React.createElement(React.Fragment, { key: element.key }, element.children?.map(renderElement)); default: return null; } }; return React.createElement('div', { className }, messageData.elements.map(renderElement)); } // Simplified renderer that matches v0's exact approach (backward compatibility) function MessageImpl({ content, messageId = 'unknown', role = 'assistant', streaming = false, isLastMessage = false, className, components, renderers }) { const messageData = useMessage({ content, messageId, role, streaming, isLastMessage, components, renderers }); return React.createElement(MessageRenderer, { messageData, className }); } /** * Main component for rendering v0 Platform API message content * This is a backward-compatible JSX renderer. For headless usage, use the useMessage hook. */ const Message = React.memo(MessageImpl); const jdf = jsondiffpatch.create({}); // Exact copy of the patch function from v0/chat/lib/diffpatch.ts function patch(original, delta) { const newObj = jdf.clone(original); // Check for our customized delta if (Array.isArray(delta) && delta[1] === 9 && delta[2] === 9) { // Get the path to the modified element const indexes = delta[0].slice(0, -1); // Get the string to be appended const value = delta[0].slice(-1); let obj = newObj; for (const index of indexes){ if (typeof obj[index] === 'string') { obj[index] += value; return newObj; } obj = obj[index]; } } // If not custom delta, apply standard jsondiffpatch-ing jdf.patch(newObj, delta); return newObj; } // Stream state manager - isolated from React lifecycle class StreamStateManager { constructor(){ this.content = []; this.isStreaming = false; this.isComplete = false; this.callbacks = new Set(); this.processedStreams = new WeakSet(); this.cachedState = null; this.subscribe = (callback)=>{ this.callbacks.add(callback); return ()=>{ this.callbacks.delete(callback); }; }; this.notifySubscribers = ()=>{ // Invalidate cached state when state changes this.cachedState = null; this.callbacks.forEach((callback)=>callback()); }; this.getState = ()=>{ // Return cached state to prevent infinite re-renders if (this.cachedState === null) { this.cachedState = { content: this.content, isStreaming: this.isStreaming, error: this.error, isComplete: this.isComplete }; } return this.cachedState; }; this.processStream = async (stream, options = {})=>{ // Prevent processing the same stream multiple times if (this.processedStreams.has(stream)) { return; } // Handle locked streams gracefully if (stream.locked) { console.warn('Stream is locked, cannot process'); return; } this.processedStreams.add(stream); this.reset(); this.setStreaming(true); try { await this.readStream(stream, options); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown streaming error'; this.setError(errorMessage); options.onError?.(errorMessage); } finally{ this.setStreaming(false); } }; this.reset = ()=>{ this.content = []; this.isStreaming = false; this.error = undefined; this.isComplete = false; this.notifySubscribers(); }; this.setStreaming = (streaming)=>{ this.isStreaming = streaming; this.notifySubscribers(); }; this.setError = (error)=>{ this.error = error; this.notifySubscribers(); }; this.setComplete = (complete)=>{ this.isComplete = complete; this.notifySubscribers(); }; this.updateContent = (newContent)=>{ this.content = [ ...newContent ]; this.notifySubscribers(); }; this.readStream = async (stream, options)=>{ const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ''; let currentContent = []; try { while(true){ const { done, value } = await reader.read(); if (done) { break; } const chunk = decoder.decode(value, { stream: true }); buffer += chunk; const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines){ if (line.trim() === '') { continue; } // Handle SSE format (data: ...) let jsonData; if (line.startsWith('data: ')) { jsonData = line.slice(6); // Remove "data: " prefix if (jsonData === '[DONE]') { this.setComplete(true); options.onComplete?.(currentContent); return; } } else { // Handle raw JSON lines (fallback) jsonData = line; } try { // Parse the JSON data const parsedData = JSON.parse(jsonData); // Handle v0 streaming format if (parsedData.type === 'connected') { continue; } else if (parsedData.type === 'done') { this.setComplete(true); options.onComplete?.(currentContent); return; } else if (parsedData.object && parsedData.object.startsWith('chat')) { // Handle chat metadata messages (chat, chat.title, chat.name, etc.) options.onChatData?.(parsedData); continue; } else if (parsedData.delta) { // Apply the delta using jsondiffpatch const patchedContent = patch(currentContent, parsedData.delta); currentContent = Array.isArray(patchedContent) ? patchedContent : []; this.updateContent(currentContent); options.onChunk?.(currentContent); } } catch (e) { console.warn('Failed to parse streaming data:', line, e); } } } this.setComplete(true); options.onComplete?.(currentContent); } finally{ reader.releaseLock(); } }; } } /** * Hook for handling streaming message content from v0 API using useSyncExternalStore */ function useStreamingMessage(stream, options = {}) { // Create a stable stream manager instance const managerRef = useRef(null); if (!managerRef.current) { managerRef.current = new StreamStateManager(); } const manager = managerRef.current; // Subscribe to state changes using useSyncExternalStore const state = useSyncExternalStore(manager.subscribe, manager.getState, manager.getState); // Process stream when it changes const lastStreamRef = useRef(null); if (stream !== lastStreamRef.current) { lastStreamRef.current = stream; if (stream) { manager.processStream(stream, options); } } return state; } // Headless hook for streaming message function useStreamingMessageData({ stream, messageId = 'unknown', role = 'assistant', components, renderers, onChunk, onComplete, onError, onChatData }) { const streamingState = useStreamingMessage(stream, { onChunk, onComplete, onError, onChatData }); const messageData = streamingState.content.length > 0 ? useMessage({ content: streamingState.content, messageId, role, streaming: streamingState.isStreaming, isLastMessage: true, components, renderers }) : null; return { ...streamingState, messageData }; } /** * Component for rendering streaming message content from v0 API * * For headless usage, use the useStreamingMessageData hook instead. * * @example * ```tsx * import { v0 } from 'v0-sdk' * import { StreamingMessage } from '@v0-sdk/react' * * function ChatDemo() { * const [stream, setStream] = useState<ReadableStream<Uint8Array> | null>(null) * * const handleSubmit = async () => { * const response = await v0.chats.create({ * message: 'Create a button component', * responseMode: 'experimental_stream' * }) * setStream(response) * } * * return ( * <div> * <button onClick={handleSubmit}>Send Message</button> * {stream && ( * <StreamingMessage * stream={stream} * messageId="demo-message" * role="assistant" * onComplete={(content) => handleCompletion(content)} * onChatData={(chatData) => handleChatData(chatData)} * /> * )} * </div> * ) * } * ``` */ function StreamingMessage({ stream, showLoadingIndicator = true, loadingComponent, errorComponent, onChunk, onComplete, onError, onChatData, className, ...messageProps }) { const streamingData = useStreamingMessageData({ stream, onChunk, onComplete, onError, onChatData, ...messageProps }); // Handle error state if (streamingData.error) { if (errorComponent) { return React.createElement(React.Fragment, {}, errorComponent(streamingData.error)); } // Fallback error component using React.createElement for compatibility return React.createElement('div', { className: 'text-red-500 p-4 border border-red-200 rounded', style: { color: 'red', padding: '1rem', border: '1px solid #fecaca', borderRadius: '0.375rem' } }, `Error: ${streamingData.error}`); } // Handle loading state if (showLoadingIndicator && streamingData.isStreaming && streamingData.content.length === 0) { if (loadingComponent) { return React.createElement(React.Fragment, {}, loadingComponent); } // Fallback loading component using React.createElement for compatibility return React.createElement('div', { className: 'flex items-center space-x-2 text-gray-500', style: { display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#6b7280' } }, React.createElement('div', { className: 'animate-spin h-4 w-4 border-2 border-gray-300 border-t-gray-600 rounded-full', style: { animation: 'spin 1s linear infinite', height: '1rem', width: '1rem', border: '2px solid #d1d5db', borderTopColor: '#4b5563', borderRadius: '50%' } }), React.createElement('span', {}, 'Loading...')); } // Render the message content return React.createElement(Message, { ...messageProps, content: streamingData.content, streaming: streamingData.isStreaming, isLastMessage: true, className }); } // Headless hook for math data function useMath(props) { return { content: props.content, inline: props.inline ?? false, displayMode: props.displayMode ?? !props.inline, processedContent: props.content }; } /** * Generic math renderer component * Renders plain math content by default - consumers should provide their own math rendering * * For headless usage, use the useMath hook instead. */ function MathPart({ content, inline = false, className = '', children, displayMode }) { // If children provided, use that (allows complete customization) if (children) { return React.createElement(React.Fragment, {}, children); } const mathData = useMath({ content, inline, displayMode }); // Simple fallback - just render plain math content // Uses React.createElement for maximum compatibility across environments return React.createElement(mathData.inline ? 'span' : 'div', { className, 'data-math-inline': mathData.inline, 'data-math-display': mathData.displayMode }, mathData.processedContent); } export { ContentPartRenderer as AssistantMessageContentPart, CodeBlock, CodeProjectPart as CodeProjectBlock, CodeProjectPart, ContentPartRenderer, Icon, IconProvider, MathPart, MathPart as MathRenderer, Message, Message as MessageContent, Message as MessageRenderer, StreamingMessage, TaskSection, ThinkingSection, Message as V0MessageRenderer, useCodeBlock, useCodeProject, useContentPart, useIcon, useMath, useMessage, useStreamingMessage, useStreamingMessageData, useTaskSection, useThinkingSection };