Spaces:
Running on Zero
Running on Zero
| 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 == <abs> -> /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; | |
| } | |