@v0-sdk/react
Version:
Headless React components for rendering v0 Platform API content
1,203 lines (1,190 loc) • 44.2 kB
JavaScript
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 };