hooks.ts 6.4 KB

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