Synaptopus/viewer/app.js

352 lines
11 KiB
JavaScript

const state = {
manifest: null,
graph: null,
trace: null,
report: null,
filter: "all",
};
const ids = {
manifestUrl: document.getElementById("manifest-url"),
manifestUrlLoad: document.getElementById("manifest-url-load"),
manifestFile: document.getElementById("manifest-file"),
graphFile: document.getElementById("graph-file"),
traceFile: document.getElementById("trace-file"),
reportFile: document.getElementById("report-file"),
summaryStatus: document.getElementById("summary-status"),
graphStatus: document.getElementById("graph-status"),
traceStatus: document.getElementById("trace-status"),
stats: document.getElementById("stats"),
analysisMetrics: document.getElementById("analysis-metrics"),
nodeChips: document.getElementById("node-chips"),
edgeList: document.getElementById("edge-list"),
traceList: document.getElementById("trace-list"),
traceTemplate: document.getElementById("trace-card-template"),
filterButtons: Array.from(document.querySelectorAll(".filter")),
};
ids.manifestUrlLoad.addEventListener("click", () => loadManifestFromUrl());
ids.manifestUrl.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
loadManifestFromUrl();
}
});
ids.manifestFile.addEventListener("change", (event) => loadJsonFile(event, "manifest"));
ids.graphFile.addEventListener("change", (event) => loadJsonFile(event, "graph"));
ids.traceFile.addEventListener("change", (event) => loadJsonFile(event, "trace"));
ids.reportFile.addEventListener("change", (event) => loadJsonFile(event, "report"));
for (const button of ids.filterButtons) {
button.addEventListener("click", () => {
state.filter = button.dataset.filter ?? "all";
syncFilterButtons();
renderTrace();
});
}
syncFilterButtons();
renderSummary();
renderGraph();
renderTrace();
async function loadJsonFile(event, kind) {
const input = event.currentTarget;
const file = input.files?.[0];
if (!file) {
return;
}
try {
const text = await file.text();
const parsed = JSON.parse(text);
if (kind === "manifest") {
validateManifest(parsed);
state.manifest = parsed;
ids.summaryStatus.textContent =
`Manifest loaded: schema ${parsed.schema_version}. ` +
"For full auto-load, use the manifest URL field while serving the artifacts over HTTP.";
return;
}
const envelope = validateEnvelope(parsed, expectedArtifactType(kind));
state[kind] = envelope.payload;
if (kind === "graph") {
renderGraph();
} else if (kind === "trace") {
renderTrace();
} else if (kind === "report") {
renderSummary();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(`Failed to load ${kind} JSON: ${message}`);
} finally {
input.value = "";
}
}
async function loadManifestFromUrl() {
const manifestUrl = ids.manifestUrl.value.trim();
if (!manifestUrl) {
window.alert("Enter a manifest URL first.");
return;
}
try {
const manifestResponse = await fetch(manifestUrl);
if (!manifestResponse.ok) {
throw new Error(`Manifest request failed with status ${manifestResponse.status}`);
}
const manifest = await manifestResponse.json();
validateManifest(manifest);
state.manifest = manifest;
const baseUrl = new URL(manifestUrl, window.location.href);
const parent = new URL("./", baseUrl);
const artifactMap = Object.fromEntries(
manifest.artifacts.map((artifact) => [artifact.artifact_type, artifact.file_name])
);
const graph = await fetchArtifactJson(parent, artifactMap.graph_schema, "graph_schema");
const trace = await fetchArtifactJson(
parent,
artifactMap.execution_trace,
"execution_trace"
);
const report = await fetchArtifactJson(parent, artifactMap.run_report, "run_report");
state.graph = graph.payload;
state.trace = trace.payload;
state.report = report.payload;
ids.summaryStatus.textContent = `Loaded manifest and artifacts from ${baseUrl.origin}.`;
renderGraph();
renderTrace();
renderSummary();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(`Failed to load manifest URL: ${message}`);
}
}
async function fetchArtifactJson(baseUrl, fileName, expectedType) {
if (!fileName) {
throw new Error(`Manifest does not include ${expectedType}`);
}
const artifactUrl = new URL(fileName, baseUrl);
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(
`${expectedType} request failed with status ${response.status}`
);
}
const json = await response.json();
return validateEnvelope(json, expectedType);
}
function expectedArtifactType(kind) {
if (kind === "graph") return "graph_schema";
if (kind === "trace") return "execution_trace";
if (kind === "report") return "run_report";
return kind;
}
function validateEnvelope(value, expectedType) {
if (
!value ||
typeof value !== "object" ||
typeof value.artifact_type !== "string" ||
typeof value.schema_version !== "string" ||
!("payload" in value) ||
typeof value.metadata !== "object"
) {
throw new Error("Invalid artifact envelope");
}
if (value.artifact_type !== expectedType) {
throw new Error(
`Unexpected artifact type: expected ${expectedType}, got ${value.artifact_type}`
);
}
return value;
}
function validateManifest(value) {
if (
!value ||
typeof value !== "object" ||
typeof value.schema_version !== "string" ||
!Array.isArray(value.artifacts)
) {
throw new Error("Invalid artifact manifest");
}
}
function syncFilterButtons() {
for (const button of ids.filterButtons) {
const active = button.dataset.filter === state.filter;
button.classList.toggle("is-active", active);
}
}
function renderSummary() {
ids.stats.replaceChildren();
ids.analysisMetrics.replaceChildren();
if (!state.report) {
ids.summaryStatus.textContent = "Load a report to populate metrics.";
return;
}
ids.summaryStatus.textContent = "Report loaded.";
appendStat("Accepted", state.report.accepted_count, ids.stats);
appendStat("Attempts", state.report.attempt_count, ids.stats);
appendStat(
"Avg / Accept",
formatNumber(state.report.average_attempts_per_accept),
ids.stats
);
appendStat("Seconds", formatNumber(state.report.total_seconds), ids.stats);
const analysis = state.report.sequence_analysis ?? {};
if (Object.keys(analysis).length === 0) {
const empty = document.createElement("div");
empty.className = "empty-note";
empty.textContent = "No sequence analysis in this report.";
ids.analysisMetrics.append(empty);
return;
}
for (const [key, value] of Object.entries(analysis)) {
appendMetric(key, value, ids.analysisMetrics);
}
}
function renderGraph() {
ids.nodeChips.replaceChildren();
ids.edgeList.replaceChildren();
if (!state.graph) {
ids.graphStatus.textContent = "Load a graph schema to inspect node wiring.";
return;
}
ids.graphStatus.textContent = `${state.graph.nodes.length} nodes, ${state.graph.edges.length} edges.`;
for (const node of state.graph.nodes) {
const chip = document.createElement("article");
chip.className = "chip";
chip.innerHTML = `
<p class="chip-type">${escapeHtml(node.node_type)}</p>
<h3>${escapeHtml(node.node_id)}</h3>
<p>${escapeHtml(node.input_names.join(", ") || "no inputs")} -> ${escapeHtml(
node.output_names.join(", ") || "no outputs"
)}</p>
`;
ids.nodeChips.append(chip);
}
for (const edge of state.graph.edges) {
const item = document.createElement("li");
item.innerHTML = `<code>${escapeHtml(edge.source_node_id)}.${escapeHtml(
edge.source_output
)}</code> -> <code>${escapeHtml(edge.target_node_id)}.${escapeHtml(
edge.target_input
)}</code>`;
ids.edgeList.append(item);
}
}
function renderTrace() {
ids.traceList.replaceChildren();
if (!state.trace) {
ids.traceStatus.textContent =
"Load a trace to inspect candidate-by-candidate behavior.";
return;
}
const attempts = state.trace.attempts ?? [];
const filtered = attempts.filter((attempt) => {
if (state.filter === "accepted") {
return attempt.accepted;
}
if (state.filter === "rejected") {
return !attempt.accepted;
}
return true;
});
ids.traceStatus.textContent = `${filtered.length} shown of ${attempts.length} attempts.`;
filtered.forEach((trace, index) => {
const fragment = ids.traceTemplate.content.cloneNode(true);
const card = fragment.querySelector(".trace-card");
const traceLabel = fragment.querySelector(".trace-label");
const traceTitle = fragment.querySelector(".trace-title");
const badge = fragment.querySelector(".badge");
const metrics = fragment.querySelector(".metric-list");
const metadata = fragment.querySelector(".json-block");
const transition = fragment.querySelector(".transition-block");
card.dataset.accepted = String(trace.accepted);
traceLabel.textContent = `Attempt ${index + 1}`;
traceTitle.textContent = `Candidate ${JSON.stringify(trace.candidate)}`;
badge.textContent = trace.accepted ? "Accepted" : "Rejected";
badge.classList.toggle("accepted", trace.accepted);
badge.classList.toggle("rejected", !trace.accepted);
appendMetric("elapsed_seconds", trace.elapsed_seconds, metrics);
const score =
trace.metadata?.critique?.outputs?.[0] ?? trace.metadata?.decision?.accepted;
if (score !== undefined) {
appendMetric("signal", score, metrics);
}
metadata.textContent = JSON.stringify(trace.metadata, null, 2);
transition.textContent = JSON.stringify(
{
previous_state: trace.previous_state,
next_state: trace.next_state,
},
null,
2
);
ids.traceList.append(fragment);
});
}
function appendStat(label, value, container) {
const article = document.createElement("article");
article.className = "stat-card";
article.innerHTML = `<p>${escapeHtml(label)}</p><h3>${escapeHtml(String(value))}</h3>`;
container.append(article);
}
function appendMetric(label, value, container) {
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
dd.textContent =
typeof value === "number" ? formatNumber(value) : JSON.stringify(value);
container.append(dt, dd);
}
function formatNumber(value) {
if (typeof value !== "number") {
return String(value);
}
if (Number.isInteger(value)) {
return String(value);
}
return value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
}
function escapeHtml(value) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}