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:
Sam Valladares 2026-07-25 21:58:03 +08:00
parent f97caff186
commit e7ae768a1c
6 changed files with 82 additions and 33 deletions

View file

@ -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)}

View file

@ -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}

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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>