her / ui /src /useAnalysis.js
geekwrestler's picture
Deploy Her (Gradio Server / ZeroGPU + bucket + per-client isolation + enrichment)
761261e verified
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;
}