Spaces:
Running on Zero
Running on Zero
File size: 8,593 Bytes
5f43c7d 761261e 5f43c7d 761261e 5f43c7d 761261e 5f43c7d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | 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;
}
|