hooks.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. import { useState, useCallback, useRef, useEffect } from "react";
  2. import type {
  3. Spec,
  4. UIElement,
  5. FlatElement,
  6. SpecStreamLine,
  7. } from "@json-render/core";
  8. import {
  9. applySpecPatch,
  10. validateSpec,
  11. autoFixSpec,
  12. formatSpecIssues,
  13. } from "@json-render/core";
  14. import { useStateStore } from "./contexts/state";
  15. // =============================================================================
  16. // useBoundProp — Two-way binding helper for $bindState/$bindItem expressions
  17. // =============================================================================
  18. /**
  19. * Hook for two-way bound props. Returns `[value, setValue]` where:
  20. *
  21. * - `value` is the already-resolved prop value (passed through from render props)
  22. * - `setValue` writes back to the bound state path (no-op if not bound)
  23. *
  24. * @example
  25. * ```tsx
  26. * const [value, setValue] = useBoundProp<string>(element.props.value, bindings?.value);
  27. * ```
  28. */
  29. export function useBoundProp<T>(
  30. propValue: T | undefined,
  31. bindingPath: string | undefined,
  32. ): [T | undefined, (value: T) => void] {
  33. const { set } = useStateStore();
  34. const setValue = useCallback(
  35. (value: T) => {
  36. if (bindingPath) set(bindingPath, value);
  37. },
  38. [bindingPath, set],
  39. );
  40. return [propValue, setValue];
  41. }
  42. /**
  43. * Result of attempting to parse a JSONL line.
  44. * - `patch`: successfully parsed patch (or null)
  45. * - `malformed`: true only if the line looked like JSON (starts with `{`)
  46. * but could not be parsed. Plain text commentary is NOT malformed.
  47. */
  48. interface ParseResult {
  49. patch: SpecStreamLine | null;
  50. malformed: boolean;
  51. }
  52. /**
  53. * Check if a line looks like it's attempting to be JSON.
  54. * LLMs often output commentary text before/between patches — those
  55. * lines should be skipped, not treated as malformed.
  56. */
  57. function looksLikeJson(line: string): boolean {
  58. return line.startsWith("{") || line.startsWith("[");
  59. }
  60. /**
  61. * Parse a single JSON patch line with LLM-specific recovery.
  62. * Wraps core's parseSpecStreamLine with recovery for common LLM errors
  63. * like trailing extra braces.
  64. *
  65. * Returns a ParseResult so the caller can distinguish between:
  66. * - Successfully parsed patch
  67. * - Commentary text (skip silently)
  68. * - Genuinely malformed JSON (trigger retry)
  69. */
  70. function parsePatchLine(line: string): ParseResult {
  71. const trimmed = line.trim();
  72. if (!trimmed || trimmed.startsWith("//")) {
  73. return { patch: null, malformed: false };
  74. }
  75. // If it doesn't look like JSON at all, it's commentary — skip it
  76. if (!looksLikeJson(trimmed)) {
  77. return { patch: null, malformed: false };
  78. }
  79. // Try parsing as-is first
  80. try {
  81. const parsed = JSON.parse(trimmed);
  82. // Validate it's a patch operation (must have `op` field)
  83. if (parsed && typeof parsed === "object" && typeof parsed.op === "string") {
  84. return { patch: parsed as SpecStreamLine, malformed: false };
  85. }
  86. // Valid JSON but not a patch — skip (could be metadata)
  87. return { patch: null, malformed: false };
  88. } catch {
  89. // Fall through to recovery
  90. }
  91. // Recovery: strip trailing extra braces/brackets one at a time
  92. // LLMs commonly generate extra closing characters in nested JSON
  93. let attempt = trimmed;
  94. for (let i = 0; i < 8; i++) {
  95. const last = attempt[attempt.length - 1];
  96. if (last === "}" || last === "]") {
  97. attempt = attempt.slice(0, -1);
  98. try {
  99. const result = JSON.parse(attempt);
  100. // Validate that the parsed result is actually a patch operation
  101. if (
  102. result &&
  103. typeof result === "object" &&
  104. typeof result.op === "string"
  105. ) {
  106. console.warn(
  107. `[json-render] Recovered malformed JSONL line by removing ${i + 1} trailing '${last}'`,
  108. );
  109. return { patch: result as SpecStreamLine, malformed: false };
  110. }
  111. // Valid JSON but not a patch — treat as malformed
  112. return { patch: null, malformed: true };
  113. } catch {
  114. // Keep stripping
  115. }
  116. } else {
  117. break;
  118. }
  119. }
  120. // Looks like JSON but couldn't parse — genuinely malformed
  121. return { patch: null, malformed: true };
  122. }
  123. // =============================================================================
  124. // Stream result types
  125. // =============================================================================
  126. /** Result of a single stream request */
  127. interface StreamResult {
  128. /** The spec after applying all successfully parsed patches */
  129. spec: Spec;
  130. /** Whether the stream completed naturally (vs. being aborted) */
  131. completed: boolean;
  132. /** Malformed lines that could not be parsed (even after recovery) */
  133. malformedLines: string[];
  134. }
  135. /**
  136. * Options for useUIStream
  137. */
  138. export interface UseUIStreamOptions {
  139. /** API endpoint */
  140. api: string;
  141. /** Callback when complete */
  142. onComplete?: (spec: Spec) => void;
  143. /** Callback on error */
  144. onError?: (error: Error) => void;
  145. /**
  146. * Custom fetch implementation with ReadableStream support.
  147. *
  148. * Falls back to the global `fetch` if not provided.
  149. */
  150. fetch?: (url: string, init?: RequestInit) => Promise<Response>;
  151. /**
  152. * Enable validation and auto-repair.
  153. *
  154. * When true:
  155. * - **Mid-stream**: Each JSONL line is validated as it arrives. If a line
  156. * is malformed JSON (and recovery fails), the stream is aborted
  157. * immediately and a repair prompt is sent to continue generation.
  158. * - **Post-stream**: After the stream completes, structural validation
  159. * runs (missing children, visible-in-props, etc.). Issues that can be
  160. * auto-fixed are fixed locally; remaining errors trigger a repair prompt.
  161. *
  162. * Defaults to false.
  163. */
  164. validate?: boolean;
  165. /**
  166. * Maximum number of automatic repair retries (covers both mid-stream
  167. * and post-stream retries combined). Defaults to 5.
  168. */
  169. maxRetries?: number;
  170. }
  171. /**
  172. * Return type for useUIStream
  173. */
  174. export interface UseUIStreamReturn {
  175. /** Current UI spec */
  176. spec: Spec | null;
  177. /** Whether currently streaming */
  178. isStreaming: boolean;
  179. /** Error if any */
  180. error: Error | null;
  181. /** Send a prompt to generate UI */
  182. send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
  183. /** Stop the current generation */
  184. stop: () => void;
  185. /** Clear the current spec */
  186. clear: () => void;
  187. }
  188. /**
  189. * Hook for streaming UI generation via JSONL patches.
  190. *
  191. * @example
  192. * ```tsx
  193. * const { spec, isStreaming, send } = useUIStream({
  194. * api: "/api/generate-ui",
  195. * onComplete: (spec) => console.log("Done!", spec),
  196. * });
  197. *
  198. * // Trigger generation
  199. * await send("Create a dashboard with stats");
  200. *
  201. * // Render the spec
  202. * <Renderer spec={spec} loading={isStreaming} />
  203. * ```
  204. */
  205. export function useUIStream({
  206. api,
  207. onComplete,
  208. onError,
  209. fetch: fetchFn = globalThis.fetch,
  210. validate: enableValidation = false,
  211. maxRetries = 5,
  212. }: UseUIStreamOptions): UseUIStreamReturn {
  213. const [spec, setSpec] = useState<Spec | null>(null);
  214. const [isStreaming, setIsStreaming] = useState(false);
  215. const [error, setError] = useState<Error | null>(null);
  216. const abortControllerRef = useRef<AbortController | null>(null);
  217. // Tracks the current request so only the latest one updates isStreaming.
  218. const requestIdRef = useRef(0);
  219. // Use refs for callbacks to avoid stale closures and unnecessary
  220. // re-creation of `send` when consumers pass inline arrow functions.
  221. const onCompleteRef = useRef(onComplete);
  222. onCompleteRef.current = onComplete;
  223. const onErrorRef = useRef(onError);
  224. onErrorRef.current = onError;
  225. const stop = useCallback(() => {
  226. abortControllerRef.current?.abort();
  227. setIsStreaming(false);
  228. }, []);
  229. const clear = useCallback(() => {
  230. setSpec(null);
  231. setError(null);
  232. }, []);
  233. /**
  234. * Stream a single request. Returns the result including whether the
  235. * stream completed and any malformed lines encountered.
  236. *
  237. * When `abortOnMalformed` is true, the stream is aborted on the first
  238. * malformed line so the caller can retry immediately.
  239. */
  240. const streamRequest = useCallback(
  241. async (
  242. prompt: string,
  243. context: Record<string, unknown> | undefined,
  244. initialSpec: Spec,
  245. abortOnMalformed: boolean,
  246. controller: AbortController,
  247. ): Promise<StreamResult> => {
  248. let currentSpec = initialSpec;
  249. setSpec(currentSpec);
  250. const malformedLines: string[] = [];
  251. const response = await fetchFn(api, {
  252. method: "POST",
  253. headers: { "Content-Type": "application/json" },
  254. body: JSON.stringify({
  255. prompt,
  256. context,
  257. currentSpec,
  258. }),
  259. signal: controller.signal,
  260. });
  261. if (!response.ok) {
  262. let errorMessage = `HTTP error: ${response.status}`;
  263. try {
  264. const errorData = await response.json();
  265. if (errorData.message) {
  266. errorMessage = errorData.message;
  267. } else if (errorData.error) {
  268. errorMessage = errorData.error;
  269. }
  270. } catch {
  271. // Ignore JSON parsing errors, use default message
  272. }
  273. throw new Error(errorMessage);
  274. }
  275. const reader = response.body?.getReader();
  276. if (!reader) {
  277. throw new Error("No response body");
  278. }
  279. const decoder = new TextDecoder();
  280. let buffer = "";
  281. let aborted = false;
  282. while (true) {
  283. const { done, value } = await reader.read();
  284. if (done) {
  285. break;
  286. }
  287. const decoded = decoder.decode(value, { stream: true });
  288. buffer += decoded;
  289. // Process complete lines
  290. const lines = buffer.split("\n");
  291. buffer = lines.pop() ?? "";
  292. for (const line of lines) {
  293. const { patch, malformed } = parsePatchLine(line);
  294. if (patch) {
  295. // applySpecPatch mutates in place — deep-clone so React
  296. // never sees mutated objects from a previous render.
  297. currentSpec = applySpecPatch(structuredClone(currentSpec), patch);
  298. setSpec(currentSpec);
  299. } else if (malformed) {
  300. // Genuinely malformed JSON (started with { but couldn't parse)
  301. malformedLines.push(line.trim());
  302. if (abortOnMalformed) {
  303. await reader.cancel();
  304. aborted = true;
  305. break;
  306. }
  307. }
  308. // else: commentary text — skip silently
  309. }
  310. if (aborted) break;
  311. }
  312. // Process any remaining buffer (only if stream completed naturally)
  313. if (!aborted && buffer.trim()) {
  314. const { patch, malformed } = parsePatchLine(buffer);
  315. if (patch) {
  316. currentSpec = applySpecPatch(structuredClone(currentSpec), patch);
  317. setSpec(currentSpec);
  318. } else if (malformed) {
  319. malformedLines.push(buffer.trim());
  320. }
  321. }
  322. return { spec: currentSpec, completed: !aborted, malformedLines };
  323. },
  324. [api, fetchFn],
  325. );
  326. const send = useCallback(
  327. async (prompt: string, context?: Record<string, unknown>) => {
  328. // Abort any existing request
  329. abortControllerRef.current?.abort();
  330. const controller = new AbortController();
  331. abortControllerRef.current = controller;
  332. const thisRequestId = ++requestIdRef.current;
  333. setIsStreaming(true);
  334. setError(null);
  335. // Start with previous spec if provided, otherwise empty spec
  336. const previousSpec = context?.previousSpec as Spec | undefined;
  337. let currentSpec: Spec =
  338. previousSpec && previousSpec.root
  339. ? { ...previousSpec, elements: { ...previousSpec.elements } }
  340. : { root: "", elements: {} };
  341. let retriesUsed = 0;
  342. let currentPrompt = prompt;
  343. let currentContext = context;
  344. try {
  345. // Retry loop handles both mid-stream (malformed JSON) and
  346. // post-stream (structural validation) repairs.
  347. while (retriesUsed <= maxRetries) {
  348. const result = await streamRequest(
  349. currentPrompt,
  350. currentContext,
  351. currentSpec,
  352. enableValidation, // only abort on malformed when validation is on
  353. controller,
  354. );
  355. currentSpec = result.spec;
  356. // ---------------------------------------------------------------
  357. // Mid-stream repair: stream was aborted due to malformed line
  358. // ---------------------------------------------------------------
  359. if (!result.completed && result.malformedLines.length > 0) {
  360. if (retriesUsed >= maxRetries) {
  361. break;
  362. }
  363. retriesUsed++;
  364. // Build a repair prompt that asks the AI to continue from
  365. // the current partial spec
  366. currentContext = { ...context, previousSpec: currentSpec };
  367. currentPrompt =
  368. `The previous generation contained malformed JSON that could not be parsed. The line was:\n` +
  369. `${result.malformedLines[result.malformedLines.length - 1]?.slice(0, 500)}\n\n` +
  370. `The current spec state is provided. Continue generating from where you left off. ` +
  371. `Output ONLY the remaining patches needed to complete the UI.`;
  372. continue;
  373. }
  374. // ---------------------------------------------------------------
  375. // Post-stream: validation is off or spec is empty → done
  376. // ---------------------------------------------------------------
  377. if (!enableValidation || !currentSpec.root) {
  378. break;
  379. }
  380. // ---------------------------------------------------------------
  381. // Post-stream: auto-fix deterministic issues. Lossless fixes (field
  382. // relocations) apply immediately. Lossy fixes (pruned content) are
  383. // held back while retries remain so validation fails and the model
  384. // is asked to repair; they apply as a last resort once retries are
  385. // exhausted, trading dropped content for a renderable spec.
  386. // ---------------------------------------------------------------
  387. const { spec: fixedSpec, fixDetails } = autoFixSpec(currentSpec, {
  388. lossy: retriesUsed >= maxRetries,
  389. });
  390. if (fixDetails.length > 0) {
  391. currentSpec = fixedSpec;
  392. setSpec({ ...currentSpec });
  393. }
  394. // ---------------------------------------------------------------
  395. // Post-stream: structural validation
  396. // ---------------------------------------------------------------
  397. const validation = validateSpec(currentSpec);
  398. if (validation.valid) {
  399. break;
  400. }
  401. // Still has errors — check if max retries exhausted
  402. if (retriesUsed >= maxRetries) {
  403. break;
  404. }
  405. retriesUsed++;
  406. const issueText = formatSpecIssues(validation.issues);
  407. currentContext = { ...context, previousSpec: currentSpec };
  408. currentPrompt =
  409. `FIX THE FOLLOWING ERRORS in the current UI spec. Output ONLY the patches needed to fix these issues, do not recreate the entire UI.\n\n` +
  410. issueText;
  411. // continue loop
  412. }
  413. // If retries were exhausted and validation still fails, report error
  414. // instead of silently treating partial/invalid specs as complete.
  415. if (enableValidation && retriesUsed >= maxRetries && currentSpec.root) {
  416. const finalValidation = validateSpec(currentSpec);
  417. if (!finalValidation.valid) {
  418. const issueText = formatSpecIssues(finalValidation.issues);
  419. const validationError = new Error(
  420. `Spec validation failed after ${maxRetries} retries:\n${issueText}`,
  421. );
  422. setError(validationError);
  423. onErrorRef.current?.(validationError);
  424. return;
  425. }
  426. }
  427. onCompleteRef.current?.(currentSpec);
  428. } catch (err) {
  429. if ((err as Error).name === "AbortError") {
  430. return;
  431. }
  432. const error = err instanceof Error ? err : new Error(String(err));
  433. setError(error);
  434. onErrorRef.current?.(error);
  435. } finally {
  436. // Only the latest request updates isStreaming to avoid the race
  437. // where an aborted request's finally clears streaming for the active one.
  438. if (requestIdRef.current === thisRequestId) {
  439. setIsStreaming(false);
  440. }
  441. }
  442. },
  443. [api, fetchFn, enableValidation, maxRetries, streamRequest],
  444. );
  445. // Cleanup on unmount
  446. useEffect(() => {
  447. return () => {
  448. abortControllerRef.current?.abort();
  449. };
  450. }, []);
  451. return {
  452. spec,
  453. isStreaming,
  454. error,
  455. send,
  456. stop,
  457. clear,
  458. };
  459. }
  460. /**
  461. * Convert a flat element list to a Spec.
  462. * Input elements use key/parentKey to establish identity and relationships.
  463. * Output spec uses the map-based format where key is the map entry key
  464. * and parent-child relationships are expressed through children arrays.
  465. */
  466. export function flatToTree(elements: FlatElement[]): Spec {
  467. const elementMap: Record<string, UIElement> = {};
  468. let root = "";
  469. // First pass: add all elements to map
  470. for (const element of elements) {
  471. elementMap[element.key] = {
  472. type: element.type,
  473. props: element.props,
  474. children: [],
  475. visible: element.visible,
  476. };
  477. }
  478. // Second pass: build parent-child relationships
  479. for (const element of elements) {
  480. if (element.parentKey) {
  481. const parent = elementMap[element.parentKey];
  482. if (parent) {
  483. if (!parent.children) {
  484. parent.children = [];
  485. }
  486. parent.children.push(element.key);
  487. } else {
  488. console.warn(
  489. `[json-render] flatToTree: element "${element.key}" references parent "${element.parentKey}" which does not exist. Element will be orphaned.`,
  490. );
  491. }
  492. } else {
  493. if (root) {
  494. console.warn(
  495. `[json-render] flatToTree: multiple root elements found ("${root}" and "${element.key}"). Using "${element.key}" as root.`,
  496. );
  497. }
  498. root = element.key;
  499. }
  500. }
  501. return { root, elements: elementMap };
  502. }