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