mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-26 23:51:02 +02:00
fix(dashboard): WebGPU adapter-failure fallback + honest page copy
- /graph now falls back to the Classic renderer when requestAdapter/ requestDevice fails (previously a dead 'MEMORY FIELD OFFLINE' panel whose link pointed back at the same page) - /duplicates: remove the no-op 'Merge all' button; copy now points at the MCP dedup tool, which actually performs reversible merges - /contradictions: stat card now reports the memories actually analyzed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f97caff186
commit
e7ae768a1c
6 changed files with 82 additions and 33 deletions
|
|
@ -2,7 +2,9 @@
|
|||
DuplicateCluster — renders a single cosine-similarity cluster from the
|
||||
`find_duplicates` MCP tool. Shows similarity bar (color-coded by severity),
|
||||
stacked memory cards with type/retention/tags/date, and action controls
|
||||
(Merge all → highest-retention winner, Review → expand, Dismiss → hide).
|
||||
(Review → expand, Dismiss → hide). The "Merge all → winner" button renders
|
||||
ONLY when the host wires `onMerge` to a real merge action — today the merge
|
||||
itself lives in the MCP `dedup` tool, so the dashboard page omits it.
|
||||
|
||||
Pure helpers live in `./duplicates-helpers.ts` and are unit-tested there.
|
||||
Keep this file focused on rendering + glue.
|
||||
|
|
@ -160,17 +162,20 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Actions — native <button> elements, fully keyboard-accessible. -->
|
||||
<!-- Actions — native <button> elements, fully keyboard-accessible.
|
||||
Merge renders only when the host actually performs a merge. -->
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleMerge}
|
||||
aria-label="Merge all memories into the highest-retention winner"
|
||||
class="rounded-lg bg-recall/20 px-3 py-1.5 text-xs font-medium text-recall transition hover:bg-recall/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-recall/60"
|
||||
title="Merge all into highest-retention memory ({(winner.retention * 100).toFixed(0)}%)"
|
||||
>
|
||||
Merge all → winner
|
||||
</button>
|
||||
{#if onMerge}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleMerge}
|
||||
aria-label="Merge all memories into the highest-retention winner"
|
||||
class="rounded-lg bg-recall/20 px-3 py-1.5 text-xs font-medium text-recall transition hover:bg-recall/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-recall/60"
|
||||
title="Merge all into highest-retention memory ({(winner.retention * 100).toFixed(0)}%)"
|
||||
>
|
||||
Merge all → winner
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
* (Increment 3 gate, spec §4).
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
import { ObservatoryEngine, type EngineStatus } from '$lib/observatory/engine';
|
||||
import type { DemoMode } from '$lib/observatory/types';
|
||||
|
||||
|
|
@ -19,9 +20,15 @@
|
|||
onframe?: (frame: number, fps: number) => void;
|
||||
/** Fired when the engine is running (the route uploads the graph here). */
|
||||
onready?: (engine: ObservatoryEngine) => void;
|
||||
/**
|
||||
* Fired on every engine status change. Hosts use this to auto-fallback
|
||||
* when WebGPU boot fails (requestAdapter null / requestDevice throw /
|
||||
* device lost) even though `'gpu' in navigator` was true.
|
||||
*/
|
||||
onstatus?: (status: EngineStatus) => void;
|
||||
}
|
||||
|
||||
let { demo, seed, freezeFrame = null, onframe, onready }: Props = $props();
|
||||
let { demo, seed, freezeFrame = null, onframe, onready, onstatus }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement;
|
||||
let engine: ObservatoryEngine | null = null;
|
||||
|
|
@ -38,7 +45,10 @@
|
|||
maxDpr: 2,
|
||||
onFrame: (frame, fps) => onframe?.(frame, fps)
|
||||
});
|
||||
unsubStatus = engine.onStatus((s) => (status = s));
|
||||
unsubStatus = engine.onStatus((s) => {
|
||||
status = s;
|
||||
onstatus?.(s);
|
||||
});
|
||||
|
||||
// Keep the drawing buffer in lockstep with layout size (DPR-clamped).
|
||||
resizeObserver = new ResizeObserver(() => engine?.resize());
|
||||
|
|
@ -71,7 +81,7 @@
|
|||
<div class="fallback-reason">{status.reason}</div>
|
||||
<div class="fallback-hint">
|
||||
WebGPU is required for the Observatory. Chrome 113+, Edge 113+, or Safari 18+ —
|
||||
the classic <a href="./graph">Graph view</a> works everywhere.
|
||||
the classic <a href="{base}/graph">Graph view</a> works everywhere.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
import RescueVerdict from '$lib/observatory/overlays/RescueVerdict.svelte';
|
||||
import ObservatoryCanvas from '$lib/components/ObservatoryCanvas.svelte';
|
||||
import { DEMO_MODES, type DemoMode } from '$lib/observatory/types';
|
||||
import type { ObservatoryEngine } from '$lib/observatory/engine';
|
||||
import type { EngineStatus, ObservatoryEngine } from '$lib/observatory/engine';
|
||||
import { NodeRenderer } from '$lib/observatory/node-renderer';
|
||||
import { BirthRenderer } from '$lib/observatory/birth-renderer';
|
||||
import { RescueRenderer } from '$lib/observatory/rescue-renderer';
|
||||
|
|
@ -62,6 +62,14 @@
|
|||
* back with its memory id (the host opens its inspector panel).
|
||||
*/
|
||||
onpick?: (memoryId: string) => void;
|
||||
/**
|
||||
* Fired when the WebGPU engine cannot run — `'gpu' in navigator` was
|
||||
* true but requestAdapter() returned null, requestDevice() threw, or
|
||||
* the device was lost (Linux/VM/blocklisted GPU). Hosts with a
|
||||
* non-WebGPU renderer switch to it instead of stranding the user on
|
||||
* the offline panel.
|
||||
*/
|
||||
onfallback?: (reason: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -74,7 +82,8 @@
|
|||
onexit,
|
||||
embedded = false,
|
||||
chrome = 'full',
|
||||
onpick
|
||||
onpick,
|
||||
onfallback
|
||||
}: Props = $props();
|
||||
|
||||
// GPU picking — screen px → NDC → NodeRenderer.pickAt (one readback/click).
|
||||
|
|
@ -169,6 +178,13 @@
|
|||
renderer = new NodeRenderer(e);
|
||||
}
|
||||
|
||||
// Adapter/device boot failure or a lost device — let the host swap to a
|
||||
// renderer that works here (the graph page flips to the classic Three.js
|
||||
// inspector) instead of leaving a dead field on screen.
|
||||
function handleStatus(s: EngineStatus) {
|
||||
if (s.state === 'unsupported' || s.state === 'error') onfallback?.(s.reason);
|
||||
}
|
||||
|
||||
// Upload the memory field once the engine AND the graph are both ready.
|
||||
$effect(() => {
|
||||
if (engine && renderer && graphData && !uploaded) {
|
||||
|
|
@ -299,6 +315,7 @@
|
|||
{freezeFrame}
|
||||
onframe={handleFrame}
|
||||
onready={handleReady}
|
||||
onstatus={handleStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
severityColor,
|
||||
severityLabel,
|
||||
truncate,
|
||||
uniqueMemoryCount,
|
||||
avgTrustDelta as avgTrustDeltaFn,
|
||||
} from '$components/contradiction-helpers';
|
||||
|
||||
|
|
@ -22,6 +21,10 @@
|
|||
// System-wide count from the backend, vs. the derived stats below which
|
||||
// reflect only the pairs the page holds.
|
||||
let totalDetected = $state(0);
|
||||
// How many memories the backend actually scanned — it only analyzes the
|
||||
// most recent `limit` memories, so the stat copy must say so instead of
|
||||
// implying a whole-corpus sweep.
|
||||
let memoriesAnalyzed = $state(0);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
|
|
@ -32,10 +35,12 @@
|
|||
const res = await api.contradictions();
|
||||
contradictions = res.contradictions;
|
||||
totalDetected = res.total;
|
||||
memoriesAnalyzed = res.memoriesAnalyzed;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load contradictions';
|
||||
contradictions = [];
|
||||
totalDetected = 0;
|
||||
memoriesAnalyzed = 0;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
|
@ -114,10 +119,10 @@
|
|||
focusedPairIndex = i;
|
||||
}
|
||||
|
||||
// --- Stats. `totalDetected` is the backend's system-wide count; everything
|
||||
// else is derived from the pairs the page actually holds so the numbers are
|
||||
// --- Stats. `totalDetected` + `memoriesAnalyzed` come from the backend
|
||||
// (which scans only the most recent `limit` memories); everything else is
|
||||
// derived from the pairs the page actually holds so the numbers are
|
||||
// self-consistent with what the user sees. ---
|
||||
const totalMemoriesInvolved = $derived(uniqueMemoryCount(contradictions));
|
||||
const avgTrustDelta = $derived(avgTrustDeltaFn(contradictions));
|
||||
|
||||
// Map filtered index -> original index in `contradictions` so the
|
||||
|
|
@ -194,7 +199,7 @@
|
|||
<AnimatedNumber value={totalDetected} />
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-1">
|
||||
contradictions across {totalMemoriesInvolved.toLocaleString()} memories
|
||||
contradictions among the {memoriesAnalyzed.toLocaleString()} most recent memories
|
||||
</div>
|
||||
</div>
|
||||
<div use:reveal={{ delay: 60, y: 12 }} class="p-4 glass rounded-xl lift">
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
Memory Hygiene — Duplicate Detection
|
||||
Dashboard exposure of the `find_duplicates` MCP tool. Threshold slider
|
||||
(0.70-0.95) reruns cosine-similarity clustering. Each cluster renders as a
|
||||
DuplicateCluster with similarity bar, stacked memory cards, and merge /
|
||||
review / dismiss actions.
|
||||
DuplicateCluster with similarity bar, stacked memory cards, and review /
|
||||
dismiss actions. This page is inspection-only: the actual merge lives in
|
||||
the MCP `dedup` tool, so no onMerge is wired here (the merge button only
|
||||
renders when a host provides a real merge action).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
|
@ -58,14 +60,6 @@
|
|||
dismissed = next;
|
||||
}
|
||||
|
||||
function mergeCluster(key: string, winnerId: string, loserIds: string[]) {
|
||||
// TODO: POST /api/duplicates/merge { winner, losers } when backend ships.
|
||||
// For now we optimistically dismiss the cluster so the UI reflects the
|
||||
// action and rerun counts stay consistent.
|
||||
console.log('Merge cluster', key, { winnerId, loserIds });
|
||||
dismissCluster(key);
|
||||
}
|
||||
|
||||
const visibleClusters = $derived(
|
||||
clusters
|
||||
.map((c) => ({ c, key: clusterKey(c.memories) }))
|
||||
|
|
@ -92,7 +86,7 @@
|
|||
<PageHeader
|
||||
icon="duplicates"
|
||||
title="Memory Hygiene — Duplicate Detection"
|
||||
subtitle="Cosine-similarity clustering over embeddings. Merges reinforce the winner's FSRS state; losers inherit into the merged node. Dismissed clusters are hidden for this session only."
|
||||
subtitle="Cosine-similarity clustering over embeddings. Inspect each cluster, expand to review, and dismiss false positives (hidden for this session only). To actually merge a cluster, run the MCP dedup tool — it keeps the highest-retention winner."
|
||||
accent="synapse"
|
||||
>
|
||||
<span
|
||||
|
|
@ -218,7 +212,6 @@
|
|||
memories={c.memories}
|
||||
suggestedAction={c.suggestedAction}
|
||||
onDismiss={() => dismissCluster(key)}
|
||||
onMerge={(winnerId, loserIds) => mergeCluster(key, winnerId, loserIds)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -80,6 +80,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
// `'gpu' in navigator` only proves the API exists — requestAdapter() can
|
||||
// still return null and requestDevice() can still throw (Linux/VM/
|
||||
// blocklisted GPU). The engine reports that through onfallback: switch to
|
||||
// the classic Three.js renderer automatically instead of stranding the
|
||||
// user on a dead "MEMORY FIELD OFFLINE" panel. Deliberately NOT persisted
|
||||
// to localStorage — a machine whose GPU works next session gets the field
|
||||
// back by default.
|
||||
function handleFieldFallback(reason: string) {
|
||||
console.warn(`[graph] WebGPU field unavailable (${reason}) — using the classic renderer.`);
|
||||
renderMode = 'classic';
|
||||
}
|
||||
|
||||
// Filtered graph data based on temporal mode
|
||||
let displayNodes = $derived.by((): GraphNode[] => {
|
||||
if (!graphData) return [];
|
||||
|
|
@ -324,6 +336,7 @@ disown</code>
|
|||
demo="recall-path"
|
||||
showSwitcher={false}
|
||||
onpick={onNodeSelect}
|
||||
onfallback={handleFieldFallback}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -652,6 +665,12 @@ disown</code>
|
|||
demo={obsDemo}
|
||||
ondemochange={(d) => (obsDemo = d)}
|
||||
onexit={() => (showObservatory = false)}
|
||||
onfallback={(reason) => {
|
||||
// WebGPU boot failed inside the takeover: close it (its offline
|
||||
// panel would link back to this very page) and drop to classic.
|
||||
showObservatory = false;
|
||||
handleFieldFallback(reason);
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue