| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- 'use client';
- import { useState, useCallback, useRef, useEffect } from 'react';
- import type { UITree, UIElement, JsonPatch } from '@json-render/core';
- import { setByPath } from '@json-render/core';
- /**
- * Parse a single JSON patch line
- */
- function parsePatchLine(line: string): JsonPatch | null {
- try {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('//')) {
- return null;
- }
- return JSON.parse(trimmed) as JsonPatch;
- } catch {
- return null;
- }
- }
- /**
- * Apply a JSON patch to the current tree
- */
- function applyPatch(tree: UITree, patch: JsonPatch): UITree {
- const newTree = { ...tree, elements: { ...tree.elements } };
- switch (patch.op) {
- case 'set':
- case 'add':
- case 'replace': {
- // Handle root path
- if (patch.path === '/root') {
- newTree.root = patch.value as string;
- return newTree;
- }
- // Handle elements paths
- if (patch.path.startsWith('/elements/')) {
- const pathParts = patch.path.slice('/elements/'.length).split('/');
- const elementKey = pathParts[0];
- if (!elementKey) return newTree;
- if (pathParts.length === 1) {
- // Setting entire element
- newTree.elements[elementKey] = patch.value as UIElement;
- } else {
- // Setting property of element
- const element = newTree.elements[elementKey];
- if (element) {
- const propPath = '/' + pathParts.slice(1).join('/');
- const newElement = { ...element };
- setByPath(newElement as unknown as Record<string, unknown>, propPath, patch.value);
- newTree.elements[elementKey] = newElement;
- }
- }
- }
- break;
- }
- case 'remove': {
- if (patch.path.startsWith('/elements/')) {
- const elementKey = patch.path.slice('/elements/'.length).split('/')[0];
- if (elementKey) {
- const { [elementKey]: _, ...rest } = newTree.elements;
- newTree.elements = rest;
- }
- }
- break;
- }
- }
- return newTree;
- }
- /**
- * Options for useUIStream
- */
- export interface UseUIStreamOptions {
- /** API endpoint */
- api: string;
- /** Callback when complete */
- onComplete?: (tree: UITree) => void;
- /** Callback on error */
- onError?: (error: Error) => void;
- }
- /**
- * Return type for useUIStream
- */
- export interface UseUIStreamReturn {
- /** Current UI tree */
- tree: UITree | null;
- /** Whether currently streaming */
- isStreaming: boolean;
- /** Error if any */
- error: Error | null;
- /** Send a prompt to generate UI */
- send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
- /** Clear the current tree */
- clear: () => void;
- }
- /**
- * Hook for streaming UI generation
- */
- export function useUIStream({
- api,
- onComplete,
- onError,
- }: UseUIStreamOptions): UseUIStreamReturn {
- const [tree, setTree] = useState<UITree | null>(null);
- const [isStreaming, setIsStreaming] = useState(false);
- const [error, setError] = useState<Error | null>(null);
- const abortControllerRef = useRef<AbortController | null>(null);
- const clear = useCallback(() => {
- setTree(null);
- setError(null);
- }, []);
- const send = useCallback(
- async (prompt: string, context?: Record<string, unknown>) => {
- // Abort any existing request
- abortControllerRef.current?.abort();
- abortControllerRef.current = new AbortController();
- setIsStreaming(true);
- setError(null);
- // Start with an empty tree
- let currentTree: UITree = { root: '', elements: {} };
- setTree(currentTree);
- try {
- const response = await fetch(api, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- prompt,
- context,
- currentTree,
- }),
- signal: abortControllerRef.current.signal,
- });
- if (!response.ok) {
- throw new Error(`HTTP error: ${response.status}`);
- }
- const reader = response.body?.getReader();
- if (!reader) {
- throw new Error('No response body');
- }
- const decoder = new TextDecoder();
- let buffer = '';
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- // Process complete lines
- const lines = buffer.split('\n');
- buffer = lines.pop() ?? '';
- for (const line of lines) {
- const patch = parsePatchLine(line);
- if (patch) {
- currentTree = applyPatch(currentTree, patch);
- setTree({ ...currentTree });
- }
- }
- }
- // Process any remaining buffer
- if (buffer.trim()) {
- const patch = parsePatchLine(buffer);
- if (patch) {
- currentTree = applyPatch(currentTree, patch);
- setTree({ ...currentTree });
- }
- }
- onComplete?.(currentTree);
- } catch (err) {
- if ((err as Error).name === 'AbortError') {
- return;
- }
- const error = err instanceof Error ? err : new Error(String(err));
- setError(error);
- onError?.(error);
- } finally {
- setIsStreaming(false);
- }
- },
- [api, onComplete, onError]
- );
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- abortControllerRef.current?.abort();
- };
- }, []);
- return {
- tree,
- isStreaming,
- error,
- send,
- clear,
- };
- }
- /**
- * Convert a flat element list to a UITree
- */
- export function flatToTree(
- elements: Array<UIElement & { parentKey?: string | null }>
- ): UITree {
- const elementMap: Record<string, UIElement> = {};
- let root = '';
- // First pass: add all elements to map
- for (const element of elements) {
- elementMap[element.key] = {
- key: element.key,
- type: element.type,
- props: element.props,
- children: [],
- visible: element.visible,
- };
- }
- // Second pass: build parent-child relationships
- for (const element of elements) {
- if (element.parentKey) {
- const parent = elementMap[element.parentKey];
- if (parent) {
- if (!parent.children) {
- parent.children = [];
- }
- parent.children.push(element.key);
- }
- } else {
- root = element.key;
- }
- }
- return { root, elements: elementMap };
- }
|