Implement Sophia MVP stages 4-7 (introspection, queries, persistence, polish)
Brings the system from "engrams cluster + form synapses" to a complete end-to-end demo: ingest text, watch it cluster, ask questions, restart with state intact. - Stage 4: birth introspection (taxonomy/goals/open_questions via LLM, bounded by the global parallel-op semaphore), per-engram memory log, click-to-inspect side panel. - Stage 5: queries as conversations. POST /api/galaxy/:id/query embeds the question, materializes a pinned Query-Engram at the donut center, runs broadcast retrieval (global cosine scan + 1-hop synaptic expansion with attenuation) and fans out responder LLM calls. The integrator runs every 2s on accumulated snippets and streams the refining answer back over SSE; responders briefly transition to Conversing on the WS bus so the right dots light up. - Stage 6: snapshot persistence. sled-backed store keyed by galaxy id, JSON-encoded values (bincode chokes on internally-tagged enums like Manifest/MemoryKind), 60s periodic snapshot task, hydrate-on-boot, DELETE /api/galaxy/:id wired through. State survives kill -9. - Stage 7: HUD additions (sim ticks/sec, LLM queue depth, FPS) via a new GET /api/stats polled at 1Hz. `sophia demo` subcommand boots the server then auto-ingests a 50-paragraph corpus baked into the binary with include_str!. README quickstart added. Token caps for query_responder/integrator bumped (gemma-4-e4b is a thinking model — output budget must cover hidden reasoning + visible answer, otherwise content comes back empty). Pinned engrams skip physics; their tick scheduling is also skipped at materialization so they stay perfectly still at the donut center. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
145
web/index.html
145
web/index.html
@@ -94,6 +94,127 @@
|
||||
}
|
||||
#ingest button:hover { background: #232c36; }
|
||||
#ingest button:disabled { opacity: 0.5; cursor: progress; }
|
||||
#query {
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
width: 380px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#query-form { display: flex; gap: 6px; }
|
||||
#query-input {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
background: #0c1014;
|
||||
color: #cfd6df;
|
||||
border: 1px solid #2a323d;
|
||||
border-radius: 3px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
#query-submit {
|
||||
font: inherit;
|
||||
background: #1a2129;
|
||||
color: #cfd6df;
|
||||
border: 1px solid #2a323d;
|
||||
padding: 4px 12px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#query-submit:hover { background: #232c36; }
|
||||
#query-submit:disabled { opacity: 0.5; cursor: progress; }
|
||||
#query-status {
|
||||
margin-top: 6px;
|
||||
opacity: 0.55;
|
||||
font-size: 11px;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
#query-answer {
|
||||
margin-top: 4px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.45;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: 3px;
|
||||
min-height: 2em;
|
||||
}
|
||||
#query-answer:empty::before {
|
||||
content: "ask what your knowledge knows…";
|
||||
opacity: 0.4;
|
||||
}
|
||||
#inspector {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 460px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
background: rgba(0,0,0,0.78);
|
||||
padding: 12px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #2a323d;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
display: none;
|
||||
}
|
||||
#inspector.open { display: block; }
|
||||
#inspector h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.6;
|
||||
font-weight: 500;
|
||||
}
|
||||
#inspector .field { margin-bottom: 8px; }
|
||||
#inspector .label {
|
||||
opacity: 0.45;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
#inspector .value { color: #e1e6ec; }
|
||||
#inspector .muted { color: #6b7480; }
|
||||
#inspector ul {
|
||||
margin: 2px 0 0 14px;
|
||||
padding: 0;
|
||||
}
|
||||
#inspector ul li { margin-bottom: 1px; }
|
||||
#inspector .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#inspector .close {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #cfd6df;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#inspector .close:hover { opacity: 1; }
|
||||
#inspector .manifest {
|
||||
white-space: pre-wrap;
|
||||
max-height: 8em;
|
||||
overflow-y: auto;
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
#inspector .id {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -105,6 +226,9 @@
|
||||
<div><span class="k">torus</span> <span id="hud-torus">…</span></div>
|
||||
<div><span class="k">engrams</span> <span id="hud-count">0</span></div>
|
||||
<div><span class="k">synapses</span> <span id="hud-synapses">0</span></div>
|
||||
<div><span class="k">sim</span> <span id="hud-ticks">…</span></div>
|
||||
<div><span class="k">llm queue</span> <span id="hud-llm-queue">…</span></div>
|
||||
<div><span class="k">fps</span> <span id="hud-fps">…</span></div>
|
||||
<div><span class="k">lm studio</span> <span id="hud-lm">…</span></div>
|
||||
<div><span class="k">health</span> <span id="hud-health">…</span></div>
|
||||
</div>
|
||||
@@ -119,6 +243,27 @@
|
||||
<button id="resize-btn">resize</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="inspector">
|
||||
<div class="row">
|
||||
<h3>engram</h3>
|
||||
<button class="close" id="inspector-close" title="close">×</button>
|
||||
</div>
|
||||
<div class="field id" id="inspector-id"></div>
|
||||
<div class="field"><span class="label">state</span><div class="value" id="inspector-state"></div></div>
|
||||
<div class="field"><span class="label">manifest</span><div class="manifest" id="inspector-manifest"></div></div>
|
||||
<div class="field"><span class="label">taxonomy</span><div class="value" id="inspector-taxonomy"></div></div>
|
||||
<div class="field"><span class="label">goals</span><div class="value" id="inspector-goals"></div></div>
|
||||
<div class="field"><span class="label">open questions</span><div class="value" id="inspector-questions"></div></div>
|
||||
<div class="field"><span class="label">memories</span><div class="value" id="inspector-memories"></div></div>
|
||||
</div>
|
||||
<div id="query">
|
||||
<form id="query-form">
|
||||
<input id="query-input" type="text" placeholder="What did we ingest about…?" autocomplete="off" />
|
||||
<button id="query-submit" type="submit">ask</button>
|
||||
</form>
|
||||
<div id="query-status"></div>
|
||||
<div id="query-answer"></div>
|
||||
</div>
|
||||
<div id="ingest">
|
||||
<textarea id="ingest-text" placeholder="One engram per line. Empty lines are skipped. e.g. Pasta carbonara uses guanciale, eggs, pecorino, and pepper. Backpropagation computes gradients of a loss with respect to weights. The fall of Constantinople occurred in 1453."></textarea>
|
||||
<div class="row">
|
||||
|
||||
@@ -105,6 +105,18 @@ export class EngramMesh {
|
||||
/** Optional hook fired when an engram's base color is set/updated. The
|
||||
* trail renderer subscribes so head + tail share the same colour. */
|
||||
public onColorAssigned: ((idx: number, r: number, g: number, b: number) => void) | null = null;
|
||||
/** Reverse lookup: instance_idx → engram UUID. Filled on upsert; used by
|
||||
* the click-picker to map a raycast hit back to an engram id. */
|
||||
public readonly idxToId: string[] = [];
|
||||
/** Forward lookup: engram UUID → instance_idx. Mirrors `idxToId` so live
|
||||
* state-change events (which carry the UUID, not the slot index) can find
|
||||
* the right vertex to re-paint. */
|
||||
private readonly idToIdx = new Map<string, number>();
|
||||
|
||||
/** Expose the underlying `THREE.Points` so the scene can raycast against it. */
|
||||
pointsObject(): THREE.Points {
|
||||
return this.points;
|
||||
}
|
||||
|
||||
constructor(scene: THREE.Scene, capacity = 5000) {
|
||||
this.capacity = capacity;
|
||||
@@ -125,6 +137,12 @@ export class EngramMesh {
|
||||
geom.setAttribute("aSize", this.sizeAttr);
|
||||
geom.setAttribute("aHash", this.hashAttr);
|
||||
geom.setDrawRange(0, 0);
|
||||
// Set a permanent oversized bounding sphere. Without this, three.js
|
||||
// computes one once based on the initial all-zero positions (radius 0)
|
||||
// and Points.raycast() short-circuits — every click misses. Recomputing
|
||||
// per frame is expensive; a giant fixed sphere always passes the early
|
||||
// reject and the per-vertex test then runs normally.
|
||||
geom.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), 100_000);
|
||||
|
||||
this.material = new THREE.ShaderMaterial({
|
||||
vertexShader: VERT_SHADER,
|
||||
@@ -197,6 +215,28 @@ export class EngramMesh {
|
||||
|
||||
if (idx + 1 > this.maxIdx) this.maxIdx = idx + 1;
|
||||
this.points.geometry.setDrawRange(0, this.maxIdx);
|
||||
this.idxToId[idx] = e.id;
|
||||
this.idToIdx.set(e.id, idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-paint the dot for an existing engram when its lifecycle state
|
||||
* changes (Stage 5: responder lights up to Conversing while answering a
|
||||
* query, Query-Engram transitions Searching→Memorize at completion).
|
||||
* Silently ignored if the engram isn't known yet — state-change events
|
||||
* for unfamiliar ids can race ahead of the corresponding `engram_created`
|
||||
* over the WS bus during a reconnect window.
|
||||
*/
|
||||
setStateById(id: string, state: string): void {
|
||||
const idx = this.idToIdx.get(id);
|
||||
if (idx === undefined) return;
|
||||
const color = STATE_COLOR[state] ?? STATE_COLOR.idle;
|
||||
const colorArr = this.colorAttr.array as Float32Array;
|
||||
colorArr[idx * 3] = color[0];
|
||||
colorArr[idx * 3 + 1] = color[1];
|
||||
colorArr[idx * 3 + 2] = color[2];
|
||||
this.colorAttr.needsUpdate = true;
|
||||
this.onColorAssigned?.(idx, color[0], color[1], color[2]);
|
||||
}
|
||||
|
||||
applyPositionFrame(frame: PositionFrame): void {
|
||||
|
||||
179
web/src/inspector.ts
Normal file
179
web/src/inspector.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import type { EngramMesh } from "./engram_mesh";
|
||||
|
||||
/**
|
||||
* Click-to-inspect: raycast the mouse into the engram point-cloud, fetch
|
||||
* `/api/galaxy/:gid/engrams/:eid` for the picked engram, and render the
|
||||
* result in the side panel. Re-clicking an already-open engram refreshes
|
||||
* the panel (handy because introspection arrives async after birth).
|
||||
*/
|
||||
|
||||
type EngramDetail = {
|
||||
id: string;
|
||||
instance_idx: number;
|
||||
position: [number, number, number];
|
||||
size: number;
|
||||
state: string;
|
||||
age: number;
|
||||
manifest: { kind: string; content?: string } | null;
|
||||
slate_dim: number | null;
|
||||
slate_norm: number | null;
|
||||
introspection: {
|
||||
taxonomy: string[];
|
||||
goals: string[];
|
||||
open_questions: string[];
|
||||
};
|
||||
memories: Array<{
|
||||
at_ms: number;
|
||||
kind: { kind: string; with?: string; weight?: number };
|
||||
}>;
|
||||
};
|
||||
|
||||
// World-space radius around each engram vertex that counts as a "hit" when
|
||||
// raycasting. Has to be generous because the rendered dot is enlarged by
|
||||
// bloom + sprite scaling — the user expects to be able to click anywhere
|
||||
// the dot *appears*, not the exact 1-unit vertex position.
|
||||
const PICK_THRESHOLD = 12;
|
||||
// Pixels of cursor movement between mousedown and mouseup that still count
|
||||
// as a "click" rather than a drag. Trackpads in particular need slack.
|
||||
const CLICK_DRAG_TOLERANCE_PX = 8;
|
||||
|
||||
const panel = document.getElementById("inspector");
|
||||
const idEl = document.getElementById("inspector-id");
|
||||
const stateEl = document.getElementById("inspector-state");
|
||||
const manifestEl = document.getElementById("inspector-manifest");
|
||||
const taxonomyEl = document.getElementById("inspector-taxonomy");
|
||||
const goalsEl = document.getElementById("inspector-goals");
|
||||
const questionsEl = document.getElementById("inspector-questions");
|
||||
const memoriesEl = document.getElementById("inspector-memories");
|
||||
const closeBtn = document.getElementById("inspector-close");
|
||||
|
||||
closeBtn?.addEventListener("click", () => {
|
||||
panel?.classList.remove("open");
|
||||
});
|
||||
|
||||
let lastPickedId: string | null = null;
|
||||
|
||||
function shortId(id: string): string {
|
||||
return id.length > 12 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id;
|
||||
}
|
||||
|
||||
function bullets(items: string[]): string {
|
||||
if (!items || items.length === 0) return `<span class="muted">…awaiting LLM…</span>`;
|
||||
return `<ul>${items.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}</ul>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fmtMemories(mems: EngramDetail["memories"]): string {
|
||||
if (!mems || mems.length === 0) return `<span class="muted">none yet</span>`;
|
||||
const items = mems.map((m) => {
|
||||
const t = (m.at_ms / 1000).toFixed(1) + "s";
|
||||
let body: string;
|
||||
switch (m.kind.kind) {
|
||||
case "born":
|
||||
body = "born";
|
||||
break;
|
||||
case "introspected":
|
||||
body = "introspected (LLM)";
|
||||
break;
|
||||
case "synapse_formed":
|
||||
body = `↔ ${shortId(m.kind.with ?? "")} (sim ${(m.kind.weight ?? 0).toFixed(2)})`;
|
||||
break;
|
||||
default:
|
||||
body = m.kind.kind;
|
||||
}
|
||||
return `<li><span class="muted">+${t}</span> ${escapeHtml(body)}</li>`;
|
||||
});
|
||||
return `<ul>${items.join("")}</ul>`;
|
||||
}
|
||||
|
||||
function render(d: EngramDetail): void {
|
||||
if (!panel) return;
|
||||
if (idEl) idEl.textContent = d.id;
|
||||
if (stateEl) stateEl.textContent = `${d.state} · age ${d.age} · slot ${d.instance_idx}`;
|
||||
if (manifestEl) {
|
||||
manifestEl.textContent = d.manifest?.content ?? "(synthetic seed — no manifest)";
|
||||
}
|
||||
if (taxonomyEl) taxonomyEl.innerHTML = bullets(d.introspection?.taxonomy ?? []);
|
||||
if (goalsEl) goalsEl.innerHTML = bullets(d.introspection?.goals ?? []);
|
||||
if (questionsEl) questionsEl.innerHTML = bullets(d.introspection?.open_questions ?? []);
|
||||
if (memoriesEl) memoriesEl.innerHTML = fmtMemories(d.memories ?? []);
|
||||
panel.classList.add("open");
|
||||
}
|
||||
|
||||
async function fetchAndRender(galaxyId: string, engramId: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/galaxy/${galaxyId}/engrams/${engramId}`);
|
||||
if (!res.ok) {
|
||||
console.warn(`engram fetch ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const detail = (await res.json()) as EngramDetail;
|
||||
render(detail);
|
||||
} catch (e) {
|
||||
console.warn("engram fetch failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up click-picking on the canvas. The engram point cloud is already in
|
||||
* the scene; we raycast against it and look up the engram id from the hit
|
||||
* vertex index.
|
||||
*/
|
||||
export function attachInspector(opts: {
|
||||
canvas: HTMLCanvasElement;
|
||||
camera: THREE.Camera;
|
||||
engrams: EngramMesh;
|
||||
galaxyId: string;
|
||||
}): void {
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.params.Points = { threshold: PICK_THRESHOLD };
|
||||
const mouse = new THREE.Vector2();
|
||||
let downX = 0, downY = 0;
|
||||
|
||||
// Use mousedown + mouseup with a tiny movement tolerance so dragging the
|
||||
// OrbitControls doesn't trigger a pick.
|
||||
opts.canvas.addEventListener("mousedown", (ev) => {
|
||||
downX = ev.clientX;
|
||||
downY = ev.clientY;
|
||||
});
|
||||
opts.canvas.addEventListener("mouseup", (ev) => {
|
||||
const moved = Math.hypot(ev.clientX - downX, ev.clientY - downY);
|
||||
if (moved > CLICK_DRAG_TOLERANCE_PX) return;
|
||||
const rect = opts.canvas.getBoundingClientRect();
|
||||
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(mouse, opts.camera);
|
||||
const hits = raycaster.intersectObject(opts.engrams.pointsObject(), false);
|
||||
console.debug(`[inspector] click ndc=(${mouse.x.toFixed(2)},${mouse.y.toFixed(2)}) hits=${hits.length}`);
|
||||
if (hits.length === 0) return;
|
||||
// Pick the closest hit.
|
||||
const hit = hits[0];
|
||||
const idx = hit.index;
|
||||
if (idx === undefined) return;
|
||||
const id = opts.engrams.idxToId[idx];
|
||||
if (!id) {
|
||||
console.warn(`[inspector] hit at idx ${idx} has no engram id`);
|
||||
return;
|
||||
}
|
||||
console.debug(`[inspector] picked engram ${id}`);
|
||||
lastPickedId = id;
|
||||
void fetchAndRender(opts.galaxyId, id);
|
||||
});
|
||||
|
||||
// Light auto-refresh — re-fetch the currently-open engram every 3s so the
|
||||
// user sees introspection populate and memories grow without re-clicking.
|
||||
setInterval(() => {
|
||||
if (!panel?.classList.contains("open")) return;
|
||||
if (!lastPickedId) return;
|
||||
void fetchAndRender(opts.galaxyId, lastPickedId);
|
||||
}, 3000);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ const ingestBtn = document.getElementById("ingest-btn") as HTMLButtonElement | n
|
||||
const ingestText = document.getElementById("ingest-text") as HTMLTextAreaElement | null;
|
||||
const torusEl = document.getElementById("hud-torus");
|
||||
const synapsesEl = document.getElementById("hud-synapses");
|
||||
const ticksEl = document.getElementById("hud-ticks");
|
||||
const llmQueueEl = document.getElementById("hud-llm-queue");
|
||||
const fpsEl = document.getElementById("hud-fps");
|
||||
const resizeBtn = document.getElementById("resize-btn") as HTMLButtonElement | null;
|
||||
const majorInput = document.getElementById("resize-major") as HTMLInputElement | null;
|
||||
const minorInput = document.getElementById("resize-minor") as HTMLInputElement | null;
|
||||
@@ -93,8 +96,36 @@ async function bootstrap(): Promise<void> {
|
||||
if (minorInput && document.activeElement !== minorInput) minorInput.value = String(minorR);
|
||||
},
|
||||
onSynapses: (n) => { if (synapsesEl) synapsesEl.textContent = String(n); },
|
||||
onFps: (fps) => { if (fpsEl) fpsEl.textContent = fps.toFixed(0); },
|
||||
});
|
||||
|
||||
// Poll /api/stats once a second for sim ticks/sec + LLM queue depth.
|
||||
// The HUD numbers it drives (sim, llm queue) are server-side state, so
|
||||
// the browser can't compute them locally — keep this loop lightweight
|
||||
// and silent on transient fetch errors so a brief network hiccup doesn't
|
||||
// pollute the console.
|
||||
type StatsResponse = {
|
||||
ticks_per_sec: number;
|
||||
engrams_total: number;
|
||||
synapses_total: number;
|
||||
galaxies: number;
|
||||
llm_queue_depth: number;
|
||||
llm_parallel_cap: number;
|
||||
};
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/stats");
|
||||
if (!r.ok) return;
|
||||
const s = (await r.json()) as StatsResponse;
|
||||
if (ticksEl) ticksEl.textContent = `${s.ticks_per_sec}/s`;
|
||||
if (llmQueueEl) {
|
||||
llmQueueEl.textContent = `${s.llm_queue_depth} / ${s.llm_parallel_cap}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore — server might be restarting
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
if (seedBtn && seedNInput) {
|
||||
seedBtn.addEventListener("click", async () => {
|
||||
const n = Math.max(1, Math.min(5000, parseInt(seedNInput.value, 10) || 200));
|
||||
|
||||
128
web/src/query_panel.ts
Normal file
128
web/src/query_panel.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Stage 5 query panel.
|
||||
*
|
||||
* Owns the bottom-left text input + answer area + SSE consumer. Submission
|
||||
* does `POST /api/galaxy/:id/query` and immediately opens an EventSource on
|
||||
* `/api/query/:id/stream`; the running answer streams in as the integrator
|
||||
* re-runs. Closes the EventSource on terminal status (`done` / `failed`).
|
||||
*
|
||||
* The visual feedback in the 3D scene (responder light-up, Query-Engram
|
||||
* appearing at the donut center) lives in `scene.ts` — driven by separate
|
||||
* WS events. This module only manages the side panel.
|
||||
*/
|
||||
|
||||
import type { QueryStatus } from "./ws_client";
|
||||
|
||||
type QueryUpdate = {
|
||||
query_id: string;
|
||||
version: number;
|
||||
status: QueryStatus;
|
||||
answer: string;
|
||||
responder_count: number;
|
||||
};
|
||||
|
||||
export type QueryPanelOptions = {
|
||||
galaxyId: string;
|
||||
};
|
||||
|
||||
const TERMINAL: QueryStatus[] = ["done", "failed"];
|
||||
|
||||
export function attachQueryPanel(opts: QueryPanelOptions): void {
|
||||
const form = document.getElementById("query-form") as HTMLFormElement | null;
|
||||
const input = document.getElementById("query-input") as HTMLInputElement | null;
|
||||
const submit = document.getElementById("query-submit") as HTMLButtonElement | null;
|
||||
const answerEl = document.getElementById("query-answer");
|
||||
const statusEl = document.getElementById("query-status");
|
||||
if (!form || !input || !submit || !answerEl || !statusEl) {
|
||||
console.warn("query panel markup missing — skipping wireup");
|
||||
return;
|
||||
}
|
||||
|
||||
let activeStream: EventSource | null = null;
|
||||
|
||||
function setBusy(busy: boolean): void {
|
||||
submit!.disabled = busy;
|
||||
input!.disabled = busy;
|
||||
}
|
||||
|
||||
function renderUpdate(u: QueryUpdate): void {
|
||||
answerEl!.textContent = u.answer || "(thinking…)";
|
||||
const phase =
|
||||
u.status === "pending"
|
||||
? "broadcasting"
|
||||
: u.status === "responding"
|
||||
? `synthesising · ${u.responder_count} perspective${u.responder_count === 1 ? "" : "s"}`
|
||||
: u.status === "integrating"
|
||||
? "integrating"
|
||||
: u.status === "done"
|
||||
? `done · ${u.responder_count} perspective${u.responder_count === 1 ? "" : "s"}`
|
||||
: "failed";
|
||||
statusEl!.textContent = `v${u.version} · ${phase}`;
|
||||
}
|
||||
|
||||
function closeStream(): void {
|
||||
if (activeStream) {
|
||||
activeStream.close();
|
||||
activeStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitQuery(text: string): Promise<void> {
|
||||
setBusy(true);
|
||||
closeStream();
|
||||
answerEl!.textContent = "(asking…)";
|
||||
statusEl!.textContent = "submitting";
|
||||
|
||||
let queryId: string;
|
||||
try {
|
||||
const res = await fetch(`/api/galaxy/${opts.galaxyId}/query`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`HTTP ${res.status}: ${body}`);
|
||||
}
|
||||
const body = (await res.json()) as { query_id: string };
|
||||
queryId = body.query_id;
|
||||
} catch (e) {
|
||||
statusEl!.textContent = "failed";
|
||||
answerEl!.textContent = `submit failed: ${(e as Error).message}`;
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
activeStream = new EventSource(`/api/query/${queryId}/stream`);
|
||||
activeStream.onmessage = (ev) => {
|
||||
try {
|
||||
const update = JSON.parse(ev.data) as QueryUpdate;
|
||||
renderUpdate(update);
|
||||
if (TERMINAL.includes(update.status)) {
|
||||
closeStream();
|
||||
setBusy(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("query SSE decode failed", err);
|
||||
}
|
||||
};
|
||||
activeStream.onerror = () => {
|
||||
// EventSource auto-reconnects on transient errors; but once the
|
||||
// server has finished and closed the channel, the reconnect loop
|
||||
// would just pile up 404s. The terminal-status check above already
|
||||
// closed the stream in the success path; this handler covers the
|
||||
// failure path where the channel never opened cleanly.
|
||||
if (activeStream && activeStream.readyState === EventSource.CLOSED) {
|
||||
statusEl!.textContent = "stream closed";
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
form.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
const text = input!.value.trim();
|
||||
if (!text) return;
|
||||
void submitQuery(text);
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
|
||||
import { EngramMesh } from "./engram_mesh";
|
||||
import { EngramTrails } from "./engram_trails";
|
||||
import { SynapseMesh } from "./synapse_mesh";
|
||||
import { attachInspector } from "./inspector";
|
||||
import { attachQueryPanel } from "./query_panel";
|
||||
import type { EngramSnapshot, EventHandlers, SynapseDto } from "./ws_client";
|
||||
import { connectGalaxy } from "./ws_client";
|
||||
|
||||
@@ -16,6 +18,8 @@ export type SceneHooks = {
|
||||
onGalaxyName: (name: string) => void;
|
||||
onTorus: (majorRadius: number, minorRadius: number) => void;
|
||||
onSynapses?: (count: number) => void;
|
||||
/** Rolling FPS, updated about once per second from inside the rAF loop. */
|
||||
onFps?: (fps: number) => void;
|
||||
};
|
||||
|
||||
// State-color table mirrored from EngramMesh; needed here so we can pre-paint
|
||||
@@ -174,6 +178,11 @@ export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: S
|
||||
synapses.applyPositionFrame(frame);
|
||||
hooks.onCount(engrams.count());
|
||||
},
|
||||
onEngramStateChanged: (id, state) => {
|
||||
// Re-paint the dot for an engram whose state flipped (Stage 5
|
||||
// responder light-up + Query-Engram → Memorize on completion).
|
||||
engrams.setStateById(id, state);
|
||||
},
|
||||
onClose: () => {
|
||||
console.warn("ws closed — refresh to reconnect");
|
||||
},
|
||||
@@ -181,6 +190,9 @@ export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: S
|
||||
};
|
||||
connectGalaxy(galaxyId, handlers);
|
||||
|
||||
attachInspector({ canvas, camera, engrams, galaxyId });
|
||||
attachQueryPanel({ galaxyId });
|
||||
|
||||
function onResize(): void {
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
@@ -193,10 +205,22 @@ export function startScene(canvas: HTMLCanvasElement, galaxyId: string, hooks: S
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
const startedAt = performance.now();
|
||||
// FPS: rolling counter — accumulate frames, emit roughly once a second.
|
||||
let fpsWindowStart = startedAt;
|
||||
let fpsFrames = 0;
|
||||
function animate(): void {
|
||||
controls.update();
|
||||
engrams.tick((performance.now() - startedAt) / 1000);
|
||||
const nowMs = performance.now();
|
||||
engrams.tick((nowMs - startedAt) / 1000);
|
||||
composer.render();
|
||||
fpsFrames += 1;
|
||||
const elapsed = nowMs - fpsWindowStart;
|
||||
if (elapsed >= 1000) {
|
||||
const fps = (fpsFrames * 1000) / elapsed;
|
||||
hooks.onFps?.(fps);
|
||||
fpsFrames = 0;
|
||||
fpsWindowStart = nowMs;
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
animate();
|
||||
|
||||
@@ -33,6 +33,13 @@ export type SynapseDto = {
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export type QueryStatus =
|
||||
| "pending"
|
||||
| "responding"
|
||||
| "integrating"
|
||||
| "done"
|
||||
| "failed";
|
||||
|
||||
export type SimEventMsg =
|
||||
| {
|
||||
type: "hello";
|
||||
@@ -47,6 +54,27 @@ export type SimEventMsg =
|
||||
center: [number, number, number];
|
||||
major_radius: number;
|
||||
minor_radius: number;
|
||||
}
|
||||
| { type: "engram_state_changed"; id: string; state: string }
|
||||
| {
|
||||
type: "query_started";
|
||||
query: string;
|
||||
engram: string;
|
||||
position: [number, number, number];
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "query_answer_updated";
|
||||
query: string;
|
||||
version: number;
|
||||
status: QueryStatus;
|
||||
answer: string;
|
||||
}
|
||||
| {
|
||||
type: "query_finished";
|
||||
query: string;
|
||||
status: QueryStatus;
|
||||
responder_count: number;
|
||||
};
|
||||
|
||||
export type PositionFrame = {
|
||||
@@ -69,6 +97,20 @@ export type EventHandlers = {
|
||||
majorRadius: number,
|
||||
minorRadius: number,
|
||||
) => void;
|
||||
onEngramStateChanged?: (id: string, state: string) => void;
|
||||
onQueryStarted?: (
|
||||
queryId: string,
|
||||
engramId: string,
|
||||
position: [number, number, number],
|
||||
text: string,
|
||||
) => void;
|
||||
onQueryAnswerUpdated?: (
|
||||
queryId: string,
|
||||
version: number,
|
||||
status: QueryStatus,
|
||||
answer: string,
|
||||
) => void;
|
||||
onQueryFinished?: (queryId: string, status: QueryStatus, responderCount: number) => void;
|
||||
onPositionFrame?: (frame: PositionFrame) => void;
|
||||
onClose?: (ev: CloseEvent) => void;
|
||||
onError?: (ev: Event) => void;
|
||||
@@ -97,6 +139,18 @@ export function connectGalaxy(galaxyId: string, handlers: EventHandlers): WebSoc
|
||||
case "torus_updated":
|
||||
handlers.onTorusUpdated?.(msg.center, msg.major_radius, msg.minor_radius);
|
||||
break;
|
||||
case "engram_state_changed":
|
||||
handlers.onEngramStateChanged?.(msg.id, msg.state);
|
||||
break;
|
||||
case "query_started":
|
||||
handlers.onQueryStarted?.(msg.query, msg.engram, msg.position, msg.text);
|
||||
break;
|
||||
case "query_answer_updated":
|
||||
handlers.onQueryAnswerUpdated?.(msg.query, msg.version, msg.status, msg.answer);
|
||||
break;
|
||||
case "query_finished":
|
||||
handlers.onQueryFinished?.(msg.query, msg.status, msg.responder_count);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const frame = decodePositionFrame(ev.data as ArrayBuffer);
|
||||
|
||||
Reference in New Issue
Block a user