| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553 |
- import { useState, useCallback, useRef, useEffect } from "react";
- import type {
- Spec,
- UIElement,
- FlatElement,
- SpecStreamLine,
- } from "@json-render/core";
- import {
- applySpecPatch,
- validateSpec,
- autoFixSpec,
- formatSpecIssues,
- } from "@json-render/core";
- import { useStateStore } from "./contexts/state";
- // =============================================================================
- // useBoundProp — Two-way binding helper for $bindState/$bindItem expressions
- // =============================================================================
- /**
- * Hook for two-way bound props. Returns `[value, setValue]` where:
- *
- * - `value` is the already-resolved prop value (passed through from render props)
- * - `setValue` writes back to the bound state path (no-op if not bound)
- *
- * @example
- * ```tsx
- * const [value, setValue] = useBoundProp<string>(element.props.value, bindings?.value);
- * ```
- */
- export function useBoundProp<T>(
- propValue: T | undefined,
- bindingPath: string | undefined,
- ): [T | undefined, (value: T) => void] {
- const { set } = useStateStore();
- const setValue = useCallback(
- (value: T) => {
- if (bindingPath) set(bindingPath, value);
- },
- [bindingPath, set],
- );
- return [propValue, setValue];
- }
- /**
- * Result of attempting to parse a JSONL line.
- * - `patch`: successfully parsed patch (or null)
- * - `malformed`: true only if the line looked like JSON (starts with `{`)
- * but could not be parsed. Plain text commentary is NOT malformed.
- */
- interface ParseResult {
- patch: SpecStreamLine | null;
- malformed: boolean;
- }
- /**
- * Check if a line looks like it's attempting to be JSON.
- * LLMs often output commentary text before/between patches — those
- * lines should be skipped, not treated as malformed.
- */
- function looksLikeJson(line: string): boolean {
- return line.startsWith("{") || line.startsWith("[");
- }
- /**
- * Parse a single JSON patch line with LLM-specific recovery.
- * Wraps core's parseSpecStreamLine with recovery for common LLM errors
- * like trailing extra braces.
- *
- * Returns a ParseResult so the caller can distinguish between:
- * - Successfully parsed patch
- * - Commentary text (skip silently)
- * - Genuinely malformed JSON (trigger retry)
- */
- function parsePatchLine(line: string): ParseResult {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith("//")) {
- return { patch: null, malformed: false };
- }
- // If it doesn't look like JSON at all, it's commentary — skip it
- if (!looksLikeJson(trimmed)) {
- return { patch: null, malformed: false };
- }
- // Try parsing as-is first
- try {
- const parsed = JSON.parse(trimmed);
- // Validate it's a patch operation (must have `op` field)
- if (parsed && typeof parsed === "object" && typeof parsed.op === "string") {
- return { patch: parsed as SpecStreamLine, malformed: false };
- }
- // Valid JSON but not a patch — skip (could be metadata)
- return { patch: null, malformed: false };
- } catch {
- // Fall through to recovery
- }
- // Recovery: strip trailing extra braces/brackets one at a time
- // LLMs commonly generate extra closing characters in nested JSON
- let attempt = trimmed;
- for (let i = 0; i < 8; i++) {
- const last = attempt[attempt.length - 1];
- if (last === "}" || last === "]") {
- attempt = attempt.slice(0, -1);
- try {
- const result = JSON.parse(attempt);
- // Validate that the parsed result is actually a patch operation
- if (
- result &&
- typeof result === "object" &&
- typeof result.op === "string"
- ) {
- console.warn(
- `[json-render] Recovered malformed JSONL line by removing ${i + 1} trailing '${last}'`,
- );
- return { patch: result as SpecStreamLine, malformed: false };
- }
- // Valid JSON but not a patch — treat as malformed
- return { patch: null, malformed: true };
- } catch {
- // Keep stripping
- }
- } else {
- break;
- }
- }
- // Looks like JSON but couldn't parse — genuinely malformed
- return { patch: null, malformed: true };
- }
- // =============================================================================
- // Stream result types
- // =============================================================================
- /** Result of a single stream request */
- interface StreamResult {
- /** The spec after applying all successfully parsed patches */
- spec: Spec;
- /** Whether the stream completed naturally (vs. being aborted) */
- completed: boolean;
- /** Malformed lines that could not be parsed (even after recovery) */
- malformedLines: string[];
- }
- /**
- * Options for useUIStream
- */
- export interface UseUIStreamOptions {
- /** API endpoint */
- api: string;
- /** Callback when complete */
- onComplete?: (spec: Spec) => void;
- /** Callback on error */
- onError?: (error: Error) => void;
- /**
- * Custom fetch implementation with ReadableStream support.
- *
- * Falls back to the global `fetch` if not provided.
- */
- fetch?: (url: string, init?: RequestInit) => Promise<Response>;
- /**
- * Enable validation and auto-repair.
- *
- * When true:
- * - **Mid-stream**: Each JSONL line is validated as it arrives. If a line
- * is malformed JSON (and recovery fails), the stream is aborted
- * immediately and a repair prompt is sent to continue generation.
- * - **Post-stream**: After the stream completes, structural validation
- * runs (missing children, visible-in-props, etc.). Issues that can be
- * auto-fixed are fixed locally; remaining errors trigger a repair prompt.
- *
- * Defaults to false.
- */
- validate?: boolean;
- /**
- * Maximum number of automatic repair retries (covers both mid-stream
- * and post-stream retries combined). Defaults to 5.
- */
- maxRetries?: number;
- }
- /**
- * Return type for useUIStream
- */
- export interface UseUIStreamReturn {
- /** Current UI spec */
- spec: Spec | 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>;
- /** Stop the current generation */
- stop: () => void;
- /** Clear the current spec */
- clear: () => void;
- }
- /**
- * Hook for streaming UI generation via JSONL patches.
- *
- * @example
- * ```tsx
- * const { spec, isStreaming, send } = useUIStream({
- * api: "/api/generate-ui",
- * onComplete: (spec) => console.log("Done!", spec),
- * });
- *
- * // Trigger generation
- * await send("Create a dashboard with stats");
- *
- * // Render the spec
- * <Renderer spec={spec} loading={isStreaming} />
- * ```
- */
- export function useUIStream({
- api,
- onComplete,
- onError,
- fetch: fetchFn = globalThis.fetch,
- validate: enableValidation = false,
- maxRetries = 5,
- }: UseUIStreamOptions): UseUIStreamReturn {
- const [spec, setSpec] = useState<Spec | null>(null);
- const [isStreaming, setIsStreaming] = useState(false);
- const [error, setError] = useState<Error | null>(null);
- const abortControllerRef = useRef<AbortController | null>(null);
- // Tracks the current request so only the latest one updates isStreaming.
- const requestIdRef = useRef(0);
- // Use refs for callbacks to avoid stale closures and unnecessary
- // re-creation of `send` when consumers pass inline arrow functions.
- const onCompleteRef = useRef(onComplete);
- onCompleteRef.current = onComplete;
- const onErrorRef = useRef(onError);
- onErrorRef.current = onError;
- const stop = useCallback(() => {
- abortControllerRef.current?.abort();
- setIsStreaming(false);
- }, []);
- const clear = useCallback(() => {
- setSpec(null);
- setError(null);
- }, []);
- /**
- * Stream a single request. Returns the result including whether the
- * stream completed and any malformed lines encountered.
- *
- * When `abortOnMalformed` is true, the stream is aborted on the first
- * malformed line so the caller can retry immediately.
- */
- const streamRequest = useCallback(
- async (
- prompt: string,
- context: Record<string, unknown> | undefined,
- initialSpec: Spec,
- abortOnMalformed: boolean,
- controller: AbortController,
- ): Promise<StreamResult> => {
- let currentSpec = initialSpec;
- setSpec(currentSpec);
- const malformedLines: string[] = [];
- const response = await fetchFn(api, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- prompt,
- context,
- currentSpec,
- }),
- signal: controller.signal,
- });
- if (!response.ok) {
- let errorMessage = `HTTP error: ${response.status}`;
- try {
- const errorData = await response.json();
- if (errorData.message) {
- errorMessage = errorData.message;
- } else if (errorData.error) {
- errorMessage = errorData.error;
- }
- } catch {
- // Ignore JSON parsing errors, use default message
- }
- throw new Error(errorMessage);
- }
- const reader = response.body?.getReader();
- if (!reader) {
- throw new Error("No response body");
- }
- const decoder = new TextDecoder();
- let buffer = "";
- let aborted = false;
- while (true) {
- const { done, value } = await reader.read();
- if (done) {
- break;
- }
- const decoded = decoder.decode(value, { stream: true });
- buffer += decoded;
- // Process complete lines
- const lines = buffer.split("\n");
- buffer = lines.pop() ?? "";
- for (const line of lines) {
- const { patch, malformed } = parsePatchLine(line);
- if (patch) {
- // applySpecPatch mutates in place — deep-clone so React
- // never sees mutated objects from a previous render.
- currentSpec = applySpecPatch(structuredClone(currentSpec), patch);
- setSpec(currentSpec);
- } else if (malformed) {
- // Genuinely malformed JSON (started with { but couldn't parse)
- malformedLines.push(line.trim());
- if (abortOnMalformed) {
- await reader.cancel();
- aborted = true;
- break;
- }
- }
- // else: commentary text — skip silently
- }
- if (aborted) break;
- }
- // Process any remaining buffer (only if stream completed naturally)
- if (!aborted && buffer.trim()) {
- const { patch, malformed } = parsePatchLine(buffer);
- if (patch) {
- currentSpec = applySpecPatch(structuredClone(currentSpec), patch);
- setSpec(currentSpec);
- } else if (malformed) {
- malformedLines.push(buffer.trim());
- }
- }
- return { spec: currentSpec, completed: !aborted, malformedLines };
- },
- [api, fetchFn],
- );
- const send = useCallback(
- async (prompt: string, context?: Record<string, unknown>) => {
- // Abort any existing request
- abortControllerRef.current?.abort();
- const controller = new AbortController();
- abortControllerRef.current = controller;
- const thisRequestId = ++requestIdRef.current;
- setIsStreaming(true);
- setError(null);
- // Start with previous spec if provided, otherwise empty spec
- const previousSpec = context?.previousSpec as Spec | undefined;
- let currentSpec: Spec =
- previousSpec && previousSpec.root
- ? { ...previousSpec, elements: { ...previousSpec.elements } }
- : { root: "", elements: {} };
- let retriesUsed = 0;
- let currentPrompt = prompt;
- let currentContext = context;
- try {
- // Retry loop handles both mid-stream (malformed JSON) and
- // post-stream (structural validation) repairs.
- while (retriesUsed <= maxRetries) {
- const result = await streamRequest(
- currentPrompt,
- currentContext,
- currentSpec,
- enableValidation, // only abort on malformed when validation is on
- controller,
- );
- currentSpec = result.spec;
- // ---------------------------------------------------------------
- // Mid-stream repair: stream was aborted due to malformed line
- // ---------------------------------------------------------------
- if (!result.completed && result.malformedLines.length > 0) {
- if (retriesUsed >= maxRetries) {
- break;
- }
- retriesUsed++;
- // Build a repair prompt that asks the AI to continue from
- // the current partial spec
- currentContext = { ...context, previousSpec: currentSpec };
- currentPrompt =
- `The previous generation contained malformed JSON that could not be parsed. The line was:\n` +
- `${result.malformedLines[result.malformedLines.length - 1]?.slice(0, 500)}\n\n` +
- `The current spec state is provided. Continue generating from where you left off. ` +
- `Output ONLY the remaining patches needed to complete the UI.`;
- continue;
- }
- // ---------------------------------------------------------------
- // Post-stream: validation is off or spec is empty → done
- // ---------------------------------------------------------------
- if (!enableValidation || !currentSpec.root) {
- break;
- }
- // ---------------------------------------------------------------
- // Post-stream: auto-fix deterministic issues. Lossless fixes (field
- // relocations) apply immediately. Lossy fixes (pruned content) are
- // held back while retries remain so validation fails and the model
- // is asked to repair; they apply as a last resort once retries are
- // exhausted, trading dropped content for a renderable spec.
- // ---------------------------------------------------------------
- const { spec: fixedSpec, fixDetails } = autoFixSpec(currentSpec, {
- lossy: retriesUsed >= maxRetries,
- });
- if (fixDetails.length > 0) {
- currentSpec = fixedSpec;
- setSpec({ ...currentSpec });
- }
- // ---------------------------------------------------------------
- // Post-stream: structural validation
- // ---------------------------------------------------------------
- const validation = validateSpec(currentSpec);
- if (validation.valid) {
- break;
- }
- // Still has errors — check if max retries exhausted
- if (retriesUsed >= maxRetries) {
- break;
- }
- retriesUsed++;
- const issueText = formatSpecIssues(validation.issues);
- currentContext = { ...context, previousSpec: currentSpec };
- currentPrompt =
- `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` +
- issueText;
- // continue loop
- }
- // If retries were exhausted and validation still fails, report error
- // instead of silently treating partial/invalid specs as complete.
- if (enableValidation && retriesUsed >= maxRetries && currentSpec.root) {
- const finalValidation = validateSpec(currentSpec);
- if (!finalValidation.valid) {
- const issueText = formatSpecIssues(finalValidation.issues);
- const validationError = new Error(
- `Spec validation failed after ${maxRetries} retries:\n${issueText}`,
- );
- setError(validationError);
- onErrorRef.current?.(validationError);
- return;
- }
- }
- onCompleteRef.current?.(currentSpec);
- } catch (err) {
- if ((err as Error).name === "AbortError") {
- return;
- }
- const error = err instanceof Error ? err : new Error(String(err));
- setError(error);
- onErrorRef.current?.(error);
- } finally {
- // Only the latest request updates isStreaming to avoid the race
- // where an aborted request's finally clears streaming for the active one.
- if (requestIdRef.current === thisRequestId) {
- setIsStreaming(false);
- }
- }
- },
- [api, fetchFn, enableValidation, maxRetries, streamRequest],
- );
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- abortControllerRef.current?.abort();
- };
- }, []);
- return {
- spec,
- isStreaming,
- error,
- send,
- stop,
- clear,
- };
- }
- /**
- * Convert a flat element list to a Spec.
- * Input elements use key/parentKey to establish identity and relationships.
- * Output spec uses the map-based format where key is the map entry key
- * and parent-child relationships are expressed through children arrays.
- */
- export function flatToTree(elements: FlatElement[]): Spec {
- const elementMap: Record<string, UIElement> = {};
- let root = "";
- // First pass: add all elements to map
- for (const element of elements) {
- elementMap[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 {
- console.warn(
- `[json-render] flatToTree: element "${element.key}" references parent "${element.parentKey}" which does not exist. Element will be orphaned.`,
- );
- }
- } else {
- if (root) {
- console.warn(
- `[json-render] flatToTree: multiple root elements found ("${root}" and "${element.key}"). Using "${element.key}" as root.`,
- );
- }
- root = element.key;
- }
- }
- return { root, elements: elementMap };
- }
|