UNPKG

@v0-sdk/react

Version:

Headless React components for rendering v0 Platform API content

1,253 lines (1,236 loc) 47 kB
Object.defineProperty(exports, '__esModule', { value: true }); var React = require('react'); var jsondiffpatch = require('jsondiffpatch'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return n; } var React__default = /*#__PURE__*/_interopDefault(React); var jsondiffpatch__namespace = /*#__PURE__*/_interopNamespace(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__default.default.createElement(React__default.default.Fragment, {}, children); } const codeBlockData = useCodeBlock({ language, code, filename }); // Simple fallback - just render plain code // Uses React.createElement for maximum compatibility across environments return React__default.default.createElement('pre', { className, 'data-language': codeBlockData.language, ...filename && { 'data-filename': filename } }, React__default.default.createElement('code', {}, codeBlockData.code)); } // Context for providing custom icon implementation const IconContext = React.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 = React.useContext(IconContext); // Use custom icon implementation if provided via context if (CustomIcon) { return React__default.default.createElement(CustomIcon, props); } const iconData = useIcon(props); // Fallback implementation - consumers should override this // This uses minimal DOM-specific attributes for maximum compatibility return React__default.default.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__default.default.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] = React.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__default.default.createElement(React__default.default.Fragment, {}, children); } // Uses React.createElement for maximum compatibility across environments return React__default.default.createElement('div', { className, 'data-component': 'code-project-block' }, React__default.default.createElement('button', { onClick: toggleCollapsed, 'data-expanded': !collapsed }, React__default.default.createElement('div', { 'data-header': true }, iconRenderer ? React__default.default.createElement(iconRenderer, { name: 'folder' }) : React__default.default.createElement(Icon, { name: 'folder' }), React__default.default.createElement('span', { 'data-title': true }, data.title)), React__default.default.createElement('span', { 'data-version': true }, 'v1')), !collapsed ? React__default.default.createElement('div', { 'data-content': true }, React__default.default.createElement('div', { 'data-file-list': true }, data.files.map((file, index)=>React__default.default.createElement('div', { key: index, 'data-file': true, ...file.active && { 'data-active': true } }, iconRenderer ? React__default.default.createElement(iconRenderer, { name: 'file-text' }) : React__default.default.createElement(Icon, { name: 'file-text' }), React__default.default.createElement('span', { 'data-filename': true }, file.name), React__default.default.createElement('span', { 'data-filepath': true }, file.path)))), data.code ? React__default.default.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] = React.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__default.default.createElement(React__default.default.Fragment, {}, children); } // Uses React.createElement for maximum compatibility across environments return React__default.default.createElement('div', { className, 'data-component': 'thinking-section' }, React__default.default.createElement('button', { onClick: handleCollapse, 'data-expanded': !collapsed, 'data-button': true }, React__default.default.createElement('div', { 'data-icon-container': true }, collapsed ? React__default.default.createElement(React__default.default.Fragment, {}, brainIcon || (iconRenderer ? React__default.default.createElement(iconRenderer, { name: 'brain' }) : React__default.default.createElement(Icon, { name: 'brain' })), chevronRightIcon || (iconRenderer ? React__default.default.createElement(iconRenderer, { name: 'chevron-right' }) : React__default.default.createElement(Icon, { name: 'chevron-right' }))) : chevronDownIcon || (iconRenderer ? React__default.default.createElement(iconRenderer, { name: 'chevron-down' }) : React__default.default.createElement(Icon, { name: 'chevron-down' }))), React__default.default.createElement('span', { 'data-title': true }, data.title + (data.formattedDuration ? ` for ${data.formattedDuration}` : ''))), !collapsed && data.thought ? React__default.default.createElement('div', { 'data-content': true }, React__default.default.createElement('div', { 'data-thought-container': true }, data.paragraphs.map((paragraph, index)=>React__default.default.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__default.default.createElement('div', { key: index }, React__default.default.createElement('p', {}, partData.content), partData.sources.length > 0 ? React__default.default.createElement('div', {}, partData.sources.map((source, sourceIndex)=>React__default.default.createElement('a', { key: sourceIndex, href: source.url, target: '_blank', rel: 'noopener noreferrer' }, source.title))) : null); } if (partData.type === 'search-repo' && partData.files) { return React__default.default.createElement('div', { key: index }, React__default.default.createElement('span', {}, partData.content), partData.files.map((file, fileIndex)=>React__default.default.createElement('span', { key: fileIndex }, iconRenderer ? React__default.default.createElement(iconRenderer, { name: 'file-text' }) : React__default.default.createElement(Icon, { name: 'file-text' }), ' ', file))); } return React__default.default.createElement('div', { key: index }, partData.content); } // Headless hook for task section function useTaskSection({ title, type, parts = [], collapsed: initialCollapsed = true, onCollapse }) { const [internalCollapsed, setInternalCollapsed] = React.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__default.default.createElement(React__default.default.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__default.default.createElement('div', { className, 'data-component': 'task-section-inline' }, React__default.default.createElement('div', { 'data-part': true }, renderTaskPartContent(partData, 0, iconRenderer))); } // Uses React.createElement for maximum compatibility across environments return React__default.default.createElement('div', { className, 'data-component': 'task-section' }, React__default.default.createElement('button', { onClick: handleCollapse, 'data-expanded': !collapsed, 'data-button': true }, React__default.default.createElement('div', { 'data-icon-container': true }, React__default.default.createElement('div', { 'data-task-icon': true }, taskIcon || (iconRenderer ? React__default.default.createElement(iconRenderer, { name: data.iconName }) : React__default.default.createElement(Icon, { name: data.iconName }))), collapsed ? chevronRightIcon || (iconRenderer ? React__default.default.createElement(iconRenderer, { name: 'chevron-right' }) : React__default.default.createElement(Icon, { name: 'chevron-right' })) : chevronDownIcon || (iconRenderer ? React__default.default.createElement(iconRenderer, { name: 'chevron-down' }) : React__default.default.createElement(Icon, { name: 'chevron-down' }))), React__default.default.createElement('span', { 'data-title': true }, data.title)), !collapsed ? React__default.default.createElement('div', { 'data-content': true }, React__default.default.createElement('div', { 'data-parts-container': true }, processedParts.map((partData, index)=>React__default.default.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] = React.useState(true); return React__default.default.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] = React.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__default.default.createElement(TaskComponent, { title: contentData.title, type: contentData.type, parts: contentData.parts, collapsed, onCollapse: ()=>setCollapsed(!collapsed), taskIcon, chevronRightIcon, chevronDownIcon }); } if (contentData.componentType === 'unknown') { return React__default.default.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__default.default.createElement('span', { key: element.key }, element.data); case 'content-part': return React__default.default.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__default.default.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__default.default.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__default.default.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__default.default.createElement(tagName, elementProps, renderedChildren); } case 'component': return React__default.default.createElement(React__default.default.Fragment, { key: element.key }, element.children?.map(renderElement)); default: return null; } }; return React__default.default.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__default.default.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__default.default.memo(MessageImpl); const jdf = jsondiffpatch__namespace.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 = React.useRef(null); if (!managerRef.current) { managerRef.current = new StreamStateManager(); } const manager = managerRef.current; // Subscribe to state changes using useSyncExternalStore const state = React.useSyncExternalStore(manager.subscribe, manager.getState, manager.getState); // Process stream when it changes const lastStreamRef = React.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__default.default.createElement(React__default.default.Fragment, {}, errorComponent(streamingData.error)); } // Fallback error component using React.createElement for compatibility return React__default.default.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__default.default.createElement(React__default.default.Fragment, {}, loadingComponent); } // Fallback loading component using React.createElement for compatibility return React__default.default.createElement('div', { className: 'flex items-center space-x-2 text-gray-500', style: { display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#6b7280' } }, React__default.default.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__default.default.createElement('span', {}, 'Loading...')); } // Render the message content return React__default.default.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__default.default.createElement(React__default.default.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__default.default.createElement(mathData.inline ? 'span' : 'div', { className, 'data-math-inline': mathData.inline, 'data-math-display': mathData.displayMode }, mathData.processedContent); } exports.AssistantMessageContentPart = ContentPartRenderer; exports.CodeBlock = CodeBlock; exports.CodeProjectBlock = CodeProjectPart; exports.CodeProjectPart = CodeProjectPart; exports.ContentPartRenderer = ContentPartRenderer; exports.Icon = Icon; exports.IconProvider = IconProvider; exports.MathPart = MathPart; exports.MathRenderer = MathPart; exports.Message = Message; exports.MessageContent = Message; exports.MessageRenderer = Message; exports.StreamingMessage = StreamingMessage; exports.TaskSection = TaskSection; exports.ThinkingSection = ThinkingSection; exports.V0MessageRenderer = Message; exports.useCodeBlock = useCodeBlock; exports.useCodeProject = useCodeProject; exports.useContentPart = useContentPart; exports.useIcon = useIcon; exports.useMath = useMath; exports.useMessage = useMessage; exports.useStreamingMessage = useStreamingMessage; exports.useStreamingMessageData = useStreamingMessageData; exports.useTaskSection = useTaskSection; exports.useThinkingSection = useThinkingSection;