import { useEffect, useState } from "react"; import { setApiMode, apiMode, callOverview, callAdvice, callChat, callProjectChat, callProjectNarrative, } from "./gradioApi.js"; import { withClient } from "./client.js"; // Loader behind the contract seam. The UI consumes ONLY {session, turns, events, // findings} (the normalized engine output) plus an OPTIONAL narrated overlay. // No JSONL is ever touched here; this is the same shape an hf-loader would emit. // // Two sources, same shape: // sourcePath == null -> /fixture-analysis.json (the bundled POC, static) // sourcePath == -> /api/analyze?path=... (any real ~/.claude session, // analyzed on demand by the LOCAL server/app.py) // // 100% local: static assets from /public, or the localhost API. No egress. export function useAnalysis(sourcePath) { const [state, setState] = useState({ status: "loading", data: null, narrated: null, error: null }); useEffect(() => { let alive = true; setState((s) => ({ ...s, status: "loading" })); (async () => { try { const url = sourcePath ? `/api/analyze?path=${encodeURIComponent(sourcePath)}` : "fixture-analysis.json"; const res = await fetch(url, { cache: "no-store", headers: withClient() }); if (!res.ok) throw new Error(`engine output HTTP ${res.status}`); const data = await res.json(); if (data.error) throw new Error(data.error); // The narrated overlay is best-effort and exists only for the bundled // fixture. A missing/malformed file must NOT break the core view. let narrated = null; if (!sourcePath) { try { const nr = await fetch("fixture-analysis.narrated.json", { cache: "no-store" }); if (nr.ok) { const txt = await nr.text(); if (txt.trim().startsWith("{")) narrated = JSON.parse(txt); } } catch { narrated = null; } } if (alive) setState({ status: "ready", data, narrated, error: null }); } catch (e) { if (!alive) return; // No bundled fixture (trace content is never shipped) -> first-run welcome, // not an error. A real failure on a chosen session is a genuine error. if (!sourcePath) setState({ status: "welcome", data: null, narrated: null, error: null }); else setState({ status: "error", data: null, narrated: null, error: String(e) }); } })(); return () => { alive = false; }; }, [sourcePath]); return state; } // Plain-English "what happened" for a session (narrator prose). On the Space this is // a GPU call routed through @gradio/client; locally it's the REST /api/overview route. // Returns { overview, model }. export async function fetchOverview(sourcePath) { if (apiMode() === "space") { try { return await callOverview(sourcePath); } catch { return { overview: "", model: null }; } } const r = await fetch(`/api/overview?path=${encodeURIComponent(sourcePath || "")}`, { cache: "no-store" }); return r.json(); } // Session-scoped "what could have been better" — the engine DETECTS the fixable // signals; the model WRITES the advice, scoped to this session's objective + the // cited Anthropic best practice. Returns { recommendations:[{...,scoped}], model }. // `scoped` is null when the model is offline (caller falls back to the engine's // transcribed fix text). Deterministic detection is never model-dependent. On the // Space this is a GPU call via @gradio/client; locally it's REST /api/advice. export async function fetchAdvice(sourcePath) { if (apiMode() === "space") { try { return await callAdvice(sourcePath); } catch { return { recommendations: [], model: null }; } } const r = await fetch(`/api/advice?path=${encodeURIComponent(sourcePath || "")}`, { cache: "no-store" }); if (!r.ok) throw new Error(`advice HTTP ${r.status}`); return r.json(); } // Is the local engine API up? (Enables the session browser + chat.) Returns // { hasApi, llama, space } once known; null while probing. `space` is this HF // Space's "owner/name" (empty locally) — used to build a self-referencing // uploader-download command in the help modal. export function useApi() { const [api, setApi] = useState(null); useEffect(() => { let alive = true; (async () => { try { const r = await fetch("/api/health", { cache: "no-store" }); const j = r.ok ? await r.json() : {}; // The ZeroGPU Space marks itself with gpu:true so narration calls route // through @gradio/client (auth headers forward for GPU quota); the local // `./her` server omits it and narration stays on plain REST. setApiMode(j.gpu ? "space" : "local"); if (alive) setApi({ hasApi: !!j.ok, llama: !!j.llama, space: j.space || "" }); } catch { if (alive) setApi({ hasApi: false, llama: false, space: "" }); } })(); return () => { alive = false; }; }, []); return api; } export async function fetchSessions() { const r = await fetch("/api/sessions", { cache: "no-store", headers: withClient() }); if (!r.ok) throw new Error(`sessions HTTP ${r.status}`); return r.json(); } // Ask a question grounded ONLY in the current session's trace. The server does // deterministic retrieval, the model writes the prose. Returns // { answer, focusTurn, citedTurns, model }. GPU call via @gradio/client on the // Space; REST /api/chat locally. export async function askTrace(question, sourcePath) { if (apiMode() === "space") { const j = await callChat(question, sourcePath); if (j && j.error) throw new Error(j.error); return j; } const r = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question, path: sourcePath || "" }), }); const j = await r.json(); if (!r.ok || j.error) throw new Error(j.error || `chat HTTP ${r.status}`); return j; } // Cross-session project chat ("which session did X happen in?"). GPU call via // @gradio/client on the Space; REST /api/project_chat locally. Returns // { answer, sessionHits, model }. export async function fetchProjectChat(question, cwd) { if (apiMode() === "space") { const j = await callProjectChat(question, cwd); if (j && j.error) throw new Error(j.error); return j; } const r = await fetch("/api/project_chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question, cwd }), }); const j = await r.json(); if (!r.ok || j.error) throw new Error(j.error || `HTTP ${r.status}`); return j; } // Project changelog prose. On the Space /api/project is deterministic-only, so the // narrative is fetched separately here (GPU via @gradio/client). Locally the // narrative already arrives inside /api/project, so this is a no-op. Returns // { narrative, model }. export async function fetchProjectNarrative(cwd) { if (apiMode() === "space") return callProjectNarrative(cwd); return { narrative: "", model: null }; } // Pull narrator prose for a turn out of the overlay, if any. Returns null when // absent so callers can fall back to the deterministic narrative. // Real narrated schema: turns[].why_tokens (+ optional .guidance.text). export function turnProse(narrated, i) { if (!narrated) return null; const arr = narrated.turns || narrated.turnProse || []; const hit = Array.isArray(arr) ? arr.find((x) => x && x.i === i) : null; if (!hit) return null; return hit.why_tokens || hit.text || hit.prose || hit.body || null; } // Narrator-proposed guidance prose for a turn (label generated; suggests, never // asserts). Falls back to the engine Guide body when no narrated guidance. export function turnGuideProse(narrated, i) { if (!narrated) return null; const arr = narrated.turns || []; const hit = Array.isArray(arr) ? arr.find((x) => x && x.i === i) : null; return hit && hit.guidance ? hit.guidance.text || hit.guidance.body || null : null; } // Session-level outcome prose. Real schema: session_outcome (string). export function sessionProse(narrated) { if (!narrated) return null; return narrated.session_outcome || narrated.session || narrated.summary || narrated.outcome || null; } // Session-level "next time" tips (list of strings) proposed by the narrator. export function nextTimeTips(narrated) { if (!narrated) return null; const nt = narrated.next_time || narrated.nextTime; return Array.isArray(nt) && nt.length ? nt : null; }