hooks.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. 'use client';
  2. import { useState, useCallback, useRef, useEffect } from 'react';
  3. import type { UITree, UIElement, JsonPatch } from '@json-render/core';
  4. import { setByPath } from '@json-render/core';
  5. /**
  6. * Parse a single JSON patch line
  7. */
  8. function parsePatchLine(line: string): JsonPatch | null {
  9. try {
  10. const trimmed = line.trim();
  11. if (!trimmed || trimmed.startsWith('//')) {
  12. return null;
  13. }
  14. return JSON.parse(trimmed) as JsonPatch;
  15. } catch {
  16. return null;
  17. }
  18. }
  19. /**
  20. * Apply a JSON patch to the current tree
  21. */
  22. function applyPatch(tree: UITree, patch: JsonPatch): UITree {
  23. const newTree = { ...tree, elements: { ...tree.elements } };
  24. switch (patch.op) {
  25. case 'set':
  26. case 'add':
  27. case 'replace': {
  28. // Handle root path
  29. if (patch.path === '/root') {
  30. newTree.root = patch.value as string;
  31. return newTree;
  32. }
  33. // Handle elements paths
  34. if (patch.path.startsWith('/elements/')) {
  35. const pathParts = patch.path.slice('/elements/'.length).split('/');
  36. const elementKey = pathParts[0];
  37. if (!elementKey) return newTree;
  38. if (pathParts.length === 1) {
  39. // Setting entire element
  40. newTree.elements[elementKey] = patch.value as UIElement;
  41. } else {
  42. // Setting property of element
  43. const element = newTree.elements[elementKey];
  44. if (element) {
  45. const propPath = '/' + pathParts.slice(1).join('/');
  46. const newElement = { ...element };
  47. setByPath(newElement as unknown as Record<string, unknown>, propPath, patch.value);
  48. newTree.elements[elementKey] = newElement;
  49. }
  50. }
  51. }
  52. break;
  53. }
  54. case 'remove': {
  55. if (patch.path.startsWith('/elements/')) {
  56. const elementKey = patch.path.slice('/elements/'.length).split('/')[0];
  57. if (elementKey) {
  58. const { [elementKey]: _, ...rest } = newTree.elements;
  59. newTree.elements = rest;
  60. }
  61. }
  62. break;
  63. }
  64. }
  65. return newTree;
  66. }
  67. /**
  68. * Options for useUIStream
  69. */
  70. export interface UseUIStreamOptions {
  71. /** API endpoint */
  72. api: string;
  73. /** Callback when complete */
  74. onComplete?: (tree: UITree) => void;
  75. /** Callback on error */
  76. onError?: (error: Error) => void;
  77. }
  78. /**
  79. * Return type for useUIStream
  80. */
  81. export interface UseUIStreamReturn {
  82. /** Current UI tree */
  83. tree: UITree | null;
  84. /** Whether currently streaming */
  85. isStreaming: boolean;
  86. /** Error if any */
  87. error: Error | null;
  88. /** Send a prompt to generate UI */
  89. send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
  90. /** Clear the current tree */
  91. clear: () => void;
  92. }
  93. /**
  94. * Hook for streaming UI generation
  95. */
  96. export function useUIStream({
  97. api,
  98. onComplete,
  99. onError,
  100. }: UseUIStreamOptions): UseUIStreamReturn {
  101. const [tree, setTree] = useState<UITree | null>(null);
  102. const [isStreaming, setIsStreaming] = useState(false);
  103. const [error, setError] = useState<Error | null>(null);
  104. const abortControllerRef = useRef<AbortController | null>(null);
  105. const clear = useCallback(() => {
  106. setTree(null);
  107. setError(null);
  108. }, []);
  109. const send = useCallback(
  110. async (prompt: string, context?: Record<string, unknown>) => {
  111. // Abort any existing request
  112. abortControllerRef.current?.abort();
  113. abortControllerRef.current = new AbortController();
  114. setIsStreaming(true);
  115. setError(null);
  116. // Start with an empty tree
  117. let currentTree: UITree = { root: '', elements: {} };
  118. setTree(currentTree);
  119. try {
  120. const response = await fetch(api, {
  121. method: 'POST',
  122. headers: { 'Content-Type': 'application/json' },
  123. body: JSON.stringify({
  124. prompt,
  125. context,
  126. currentTree,
  127. }),
  128. signal: abortControllerRef.current.signal,
  129. });
  130. if (!response.ok) {
  131. throw new Error(`HTTP error: ${response.status}`);
  132. }
  133. const reader = response.body?.getReader();
  134. if (!reader) {
  135. throw new Error('No response body');
  136. }
  137. const decoder = new TextDecoder();
  138. let buffer = '';
  139. while (true) {
  140. const { done, value } = await reader.read();
  141. if (done) break;
  142. buffer += decoder.decode(value, { stream: true });
  143. // Process complete lines
  144. const lines = buffer.split('\n');
  145. buffer = lines.pop() ?? '';
  146. for (const line of lines) {
  147. const patch = parsePatchLine(line);
  148. if (patch) {
  149. currentTree = applyPatch(currentTree, patch);
  150. setTree({ ...currentTree });
  151. }
  152. }
  153. }
  154. // Process any remaining buffer
  155. if (buffer.trim()) {
  156. const patch = parsePatchLine(buffer);
  157. if (patch) {
  158. currentTree = applyPatch(currentTree, patch);
  159. setTree({ ...currentTree });
  160. }
  161. }
  162. onComplete?.(currentTree);
  163. } catch (err) {
  164. if ((err as Error).name === 'AbortError') {
  165. return;
  166. }
  167. const error = err instanceof Error ? err : new Error(String(err));
  168. setError(error);
  169. onError?.(error);
  170. } finally {
  171. setIsStreaming(false);
  172. }
  173. },
  174. [api, onComplete, onError]
  175. );
  176. // Cleanup on unmount
  177. useEffect(() => {
  178. return () => {
  179. abortControllerRef.current?.abort();
  180. };
  181. }, []);
  182. return {
  183. tree,
  184. isStreaming,
  185. error,
  186. send,
  187. clear,
  188. };
  189. }
  190. /**
  191. * Convert a flat element list to a UITree
  192. */
  193. export function flatToTree(
  194. elements: Array<UIElement & { parentKey?: string | null }>
  195. ): UITree {
  196. const elementMap: Record<string, UIElement> = {};
  197. let root = '';
  198. // First pass: add all elements to map
  199. for (const element of elements) {
  200. elementMap[element.key] = {
  201. key: element.key,
  202. type: element.type,
  203. props: element.props,
  204. children: [],
  205. visible: element.visible,
  206. };
  207. }
  208. // Second pass: build parent-child relationships
  209. for (const element of elements) {
  210. if (element.parentKey) {
  211. const parent = elementMap[element.parentKey];
  212. if (parent) {
  213. if (!parent.children) {
  214. parent.children = [];
  215. }
  216. parent.children.push(element.key);
  217. }
  218. } else {
  219. root = element.key;
  220. }
  221. }
  222. return { root, elements: elementMap };
  223. }