diff --git a/components/graph-context-control.tsx b/components/graph-context-control.tsx index 7ac60eb..b0b1227 100644 --- a/components/graph-context-control.tsx +++ b/components/graph-context-control.tsx @@ -1,11 +1,14 @@ "use client"; +import { useCallback, useEffect, useState } from "react"; import { BookOpenIcon } from "lucide-react"; import { GRAPH_CONTEXT_TOKENS_DEFAULT, GRAPH_CONTEXT_TOKENS_STOPS, type ChatSettings, } from "@/src/core"; +import { useSettingsSaved } from "@/components/settings-signal"; +import { useSettingsDialog } from "./settings/settings-dialog.tsx"; /** * The knowledge-context budget control (vision-context-injection.md) — the @@ -36,6 +39,50 @@ export function GraphContextControl({ onChange: (tokens: number) => void; testId?: string; }) { + // Wave 6: context injection rides the embedding model — with none + // configured (graph.embeddingModel null) the control is inert, so it + // grays out and its hint deep-links to the Graph section's picker. + // Loading renders the normal control (the searchStatus precedent: + // never gray on null/unknown). Every settings save re-reads. + const { open: openSettingsSection } = useSettingsDialog(); + const [modelUnset, setModelUnset] = useState(false); + const [known, setKnown] = useState(false); + const readModel = useCallback(async () => { + try { + const resp = await fetch("/api/settings"); + if (!resp.ok) return; + const body = (await resp.json()) as { settings?: { graph?: { embeddingModel?: string | null } } }; + setModelUnset(!body.settings?.graph?.embeddingModel); + setKnown(true); + } catch { + // leave the last known state + } + }, []); + useEffect(() => { + void readModel(); + }, [readModel]); + useSettingsSaved(() => { + void readModel(); + }); + + if (known && modelUnset) { + return ( +
+ + +
+ ); + } + // Snap for display only: a legacy non-stop value shows its nearest stop // and the next drag lands on a real one. const stop = nearestStop(value); diff --git a/components/graph-view.tsx b/components/graph-view.tsx index ed57497..66158dd 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -279,6 +279,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { ["Extraction", costs.stages.extraction], ["Audit", costs.stages.audit], ["Cluster naming", costs.stages.naming], + ["Embeddings", costs.stages.embedding], ]; return (
diff --git a/components/settings/appearance-section.tsx b/components/settings/appearance-section.tsx index 18bd4fd..ea85696 100644 --- a/components/settings/appearance-section.tsx +++ b/components/settings/appearance-section.tsx @@ -16,10 +16,39 @@ * persist but resolve inert — the switch shows stored, the dim effective). */ -import type { AppearanceSettings, GraphSettings } from "@/src/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { MenuShell } from "@/components/menu-shell"; +import { useSettingsSaved } from "@/components/settings-signal"; +import { EMBEDDING_PRICES, type AppearanceSettings, type GraphSettings } from "@/src/core"; import { useAppearance } from "./appearance.tsx"; +import { useSettingsDialog } from "./settings-dialog.tsx"; import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx"; +/** One row of GET /api/models/embeddings (the live-federated BYOK + * catalog). The server twin is src/server/embeddings.ts's + * EmbeddingCatalogModel — components may not import the server layer, + * and the shape is exactly these two strings. */ +interface EmbeddingCatalogModel { + id: string; + name: string; +} + +/** Pure: the price line for a catalog row — USD per 1M INPUT tokens + * (embeddings emit no output). Unlisted models stay honestly "—" + * (never zero). The label convention is the model menu's. */ +export function embeddingPriceLabel(id: string): string { + const price = EMBEDDING_PRICES[id]; + if (!price) return "—"; + const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; + return `${fmt(price.input)}/1M in`; +} + +/** Pure: which ladder rungs need a configured embedding model to do + * anything — everything but the master switch. */ +export function needsEmbeddingModel(key: "enabled" | "embedding" | "clustering" | "labeling"): boolean { + return key !== "enabled"; +} + export function AppearanceSection({ appearance, onSectionChange, @@ -156,6 +185,19 @@ export function GraphSection({ graph: GraphSettings | undefined; onSectionChange: (next: GraphSettings) => void; }) { + const { open: openSettingsSection } = useSettingsDialog(); + // The BYOK embedding catalog — null until the first fetch lands. + const [catalog, setCatalog] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const anchorRef = useRef(null); + // The re-embed gate: a pick that differs from the stored model, held + // until the user answers the confirm strip (or the graph turns out to + // be empty — nothing to re-embed, the write lands directly). + const [pending, setPending] = useState(null); + // null while the count fetch is in flight; -1 when it failed (the + // confirm line then omits the number rather than guessing one). + const [pendingCount, setPendingCount] = useState(null); + // Stored values (the switches show these) vs EFFECTIVE values (a // stored-on rung under a broken chain is inert — the schema's ladder). const stored = { @@ -170,22 +212,253 @@ export function GraphSection({ clustering: stored.enabled && stored.embedding && stored.clustering, labeling: stored.enabled && stored.embedding && stored.clustering && stored.labeling, }; + // No embedding model = the whole ladder under the master switch is + // inert (the embedding port refuses without one) — those rows gray + // out with a hint pointing at the picker below (owner, wave 6). + const modelUnset = !graph?.embeddingModel; + + const commitPickRef = useRef(onSectionChange); + commitPickRef.current = onSectionChange; + const commitRef = useRef<(id: string | null) => void>(null); + commitRef.current = (id) => commitPickRef.current({ ...graph, embeddingModel: id }); + + // Catalog: fetch on mount and re-read on any settings save — keying a + // provider changes what this list can offer. + const refreshCatalog = useCallback(async () => { + try { + const resp = await fetch("/api/models/embeddings"); + if (!resp.ok) return; + const list = (await resp.json()) as EmbeddingCatalogModel[]; + if (Array.isArray(list)) setCatalog(list); + } catch { + // leave the last known list; the empty-add path stays clickable + } + }, []); + useEffect(() => { + void refreshCatalog(); + }, [refreshCatalog]); + useSettingsSaved(() => { + void refreshCatalog(); + }); + + // Dismissal: the agent-section contract — outside pointerdown closes + // unless it lands inside the menu or on the anchor; Escape is CLAIMED + // so the dialog behind stays put. + useEffect(() => { + if (!menuOpen) return; + const onPointer = (e: PointerEvent) => { + const t = e.target as HTMLElement; + if (t.closest("[data-lh-menu]") || anchorRef.current?.contains(t)) return; + setMenuOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + setMenuOpen(false); + } + }; + document.addEventListener("pointerdown", onPointer); + document.addEventListener("keydown", onKey, true); + return () => { + document.removeEventListener("pointerdown", onPointer); + document.removeEventListener("keydown", onKey, true); + }; + }, [menuOpen]); + + // A held pick: count the graph's propositions. Zero = nothing to + // re-embed, so the write lands without a confirm (the strip would be + // noise). Otherwise the strip shows the honest number. + useEffect(() => { + if (!pending) return; + let cancelled = false; + (async () => { + let n = -1; + try { + const resp = await fetch("/api/graph"); + const snap = (await resp.json()) as { nodes?: unknown }; + if (Array.isArray(snap.nodes)) n = snap.nodes.length; + } catch { + // count unknown — the strip reads "all propositions" + } + if (cancelled) return; + if (n === 0) { + setPending(null); + setPendingCount(null); + commitRef.current?.(pending.id); + } else { + setPendingCount(n); + } + })(); + return () => { + cancelled = true; + }; + }, [pending]); + + const pick = (m: EmbeddingCatalogModel | null) => { + setMenuOpen(false); + if (m === null) { + // Unset never re-embeds — the port just stops resolving. + if (graph?.embeddingModel != null) commitRef.current?.(null); + return; + } + if (m.id === graph?.embeddingModel) return; + setPending(m); + setPendingCount(null); + }; + + // The trigger's label: the catalog row's name when known, else the raw + // slug (a key may have been removed after the model was set). + const currentName = useMemo(() => { + const id = graph?.embeddingModel ?? null; + if (!id) return null; + return catalog?.find((m) => m.id === id)?.name ?? id; + }, [graph?.embeddingModel, catalog]); + return ( <> {LADDER.map(({ key, label, needs }) => { - // Touchable only when every ancestor is EFFECTIVELY on. + // Touchable only when every ancestor is EFFECTIVELY on, and + // (below the master switch) only when a model is configured. const parentOn = needs === null ? true : effective[needs]; + const inert = !parentOn || (needsEmbeddingModel(key) && modelUnset); return ( - + onSectionChange({ ...graph, [key]: on })} /> ); })} + + {/* The embedding-model picker — the setting every rung above + depends on. Empty catalog means no provider key is set: the + trigger BECOMES the deep link to the Keys section. */} + + {catalog === null ? ( + Loading… + ) : catalog.length === 0 ? ( + + ) : ( + + )} + + + {/* The re-embed confirm — switching models rewrites every vector + in the graph (the staleness machinery, next background pass). + Honesty over comfort: the count is the user's decision input. */} + {pending && ( +
+

Switch to {pending.name}?

+

+ {pendingCount === null + ? "Counting the graph’s propositions…" + : pendingCount > 0 + ? `All ${pendingCount.toLocaleString()} propositions in the graph will be re-embedded with the new model (runs with the next background pass).` + : "All propositions in the graph will be re-embedded with the new model (runs with the next background pass)."} +

+ {pendingCount !== null && ( +
+ + +
+ )} +
+ )} + + {menuOpen && catalog !== null && catalog.length > 0 && ( + + + {catalog.map((m) => ( + + ))} + + )} ); } \ No newline at end of file diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 4e7acc9..cee6818 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -150,6 +150,13 @@ export function SettingsDialogBody({ }; }, []); const [active, setActive] = useState(initialSection); + // A deep-link fired while the dialog is ALREADY open (wave 6: the + // embedding picker's "Add a provider key first…" lands on Keys from + // inside the dialog) changes initialSection under our feet — follow it, + // or the link silently does nothing. + useEffect(() => { + setActive(initialSection); + }, [initialSection]); // Fetch once per dialog mount. 200 {settings: null} is FIRST BOOT // (no record = factory everything), not a failure — the error state is diff --git a/src/core/index.ts b/src/core/index.ts index 03a100e..3a01cb1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -42,8 +42,9 @@ export * from "./fuzzy.ts"; export * from "./settings.ts"; // graph-deletion.ts (wave 6): the whole module is PURE but storage-port // driven — the deletion RUNS server-side only. The shell needs exactly one -// thing from it: the ChatDeleteMode union (the delete dialog's two-option -// design + the DELETE query param), hence this type-only re-export. +// two type-only things from it: the ChatDeleteMode union (the delete +// dialog's two-option design + the DELETE query param) and the stats +// shape its success line describes, hence this type-only re-export. export type { ChatDeleteMode, ChatDeletionStats } from "./graph-deletion.ts"; export * from "./settings-resolution.ts"; export * from "./search.ts"; // search port (search wave, 2026-09-20) diff --git a/tests/embedding-settings.test.tsx b/tests/embedding-settings.test.tsx new file mode 100644 index 0000000..f53b554 --- /dev/null +++ b/tests/embedding-settings.test.tsx @@ -0,0 +1,91 @@ +import React from "react"; +(globalThis as Record).React = React; + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + embeddingPriceLabel, + GraphSection, + needsEmbeddingModel, +} from "../components/settings/appearance-section.tsx"; +import { EMBEDDING_PRICES, type GraphSettings } from "../src/core/index.ts"; + +/** + * Wave 6 chunk B — the embeddings settings seams: the price line for + * catalog rows (EMBEDDING_PRICES lookup, honestly "—" when unlisted), + * the model-dependency predicate that drives the ladder gray-out, and + * GraphSection's SSR shape with and without a configured model. + * + * The fetches (catalog, node count) live in effects — dead under + * renderToStaticMarkup, so the render pins the STORED-state markup only; + * the interactive round-trips are live-verified on the dev server. + */ + +describe("embeddingPriceLabel", () => { + it("prices a listed model: USD per 1M input tokens", () => { + // Straight from the hand-maintained table (openai small = $0.02). + assert.equal(embeddingPriceLabel("openai:text-embedding-3-small"), "$0.02/1M in"); + }); + + it("prices every entry in EMBEDDING_PRICES without zero or 'NaN' leaking", () => { + for (const id of Object.keys(EMBEDDING_PRICES)) { + const label = embeddingPriceLabel(id); + assert.match(label, /^\$\d+(\.\d+)?\/1M in$/); + assert.notEqual(label, "$0/1M in"); + } + }); + + it("trims a trailing zero the model-menu way ($0.2, not $0.20)", () => { + assert.equal(embeddingPriceLabel("google:gemini-embedding-2"), "$0.2/1M in"); + }); + + it("stays honestly '—' for unlisted models (never zero)", () => { + assert.equal(embeddingPriceLabel("openrouter:some/model-not-in-table"), "—"); + }); +}); + +describe("needsEmbeddingModel", () => { + it("spares only the master switch", () => { + assert.equal(needsEmbeddingModel("enabled"), false); + assert.equal(needsEmbeddingModel("embedding"), true); + assert.equal(needsEmbeddingModel("clustering"), true); + assert.equal(needsEmbeddingModel("labeling"), true); + }); +}); + +describe("GraphSection (SSR)", () => { + const render = (graph: GraphSettings | undefined) => + renderToStaticMarkup( {}} />); + + it("grays out + hints the model-dependent rungs when no model is set", () => { + const html = render({ enabled: true } as GraphSettings); + // Master switch: never model-dimmed. + const enabledRow = html.split('data-testid="graph-row-enabled"')[1]?.split("
")[0] ?? ""; + assert.ok(!enabledRow.includes("opacity-40"), "master switch must not dim on unset model"); + // The three dependent rungs dim and each carries the hint. + for (const key of ["embedding", "clustering", "labeling"] as const) { + const row = html.split(`data-testid="graph-row-${key}"`)[1]?.split('data-testid="graph-row-')[0] ?? ""; + assert.ok(row.includes("opacity-40"), `${key} row dims without a model`); + assert.ok(row.includes("Needs an embedding model"), `${key} row carries the hint`); + } + assert.ok(html.includes('data-testid="graph-row-embedding-model"')); + }); + + it("keeps the ladder touchable once a model is set", () => { + const html = render({ + enabled: true, + embeddingModel: "openai:text-embedding-3-small", + } as GraphSettings); + for (const key of ["embedding", "clustering", "labeling"] as const) { + const row = html.split(`data-testid="graph-row-${key}"`)[1]?.split('data-testid="graph-row-')[0] ?? ""; + assert.ok(!row.includes("opacity-40"), `${key} row stays lit with a model`); + assert.ok(!row.includes("Needs an embedding model"), "no hint with a model"); + } + }); + + it("renders the picker row (catalog fetch is an effect — inert in SSR)", () => { + const html = render(undefined); + assert.ok(html.includes("Loading…"), "pre-fetch trigger state renders"); + }); +}); \ No newline at end of file diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index a8948e7..859fb1f 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -16,6 +16,7 @@ import { TOGGLE_TOOLS, AgentSection } from "../components/settings/agent-section import { AppearanceSection, GraphSection } from "../components/settings/appearance-section.tsx"; import { KeysSection } from "../components/settings/keys-section.tsx"; import { KeyboardSection } from "../components/settings/keyboard-section.tsx"; +import type { GraphSettings } from "../src/core/index.ts"; import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx"; import { KEYBOARD_ACTIONS } from "../src/core/index.ts"; @@ -134,8 +135,13 @@ describe("AgentSection (thin SSR)", () => { }); describe("GraphSection — the dependency ladder", () => { - it("defaults: all four rungs on, none disabled", () => { - const html = renderToStaticMarkup( {}} />); + // Wave 6: without an embedding model the three dependent rungs are + // inert (dimmed, disabled, hinted) — pinned in embedding-settings. + // These ladder pins isolate the CHAIN logic with a model configured. + const MODEL: GraphSettings = { enabled: true, embeddingModel: "openai:text-embedding-3-small" }; + + it("defaults with a model set: all four rungs on, none disabled", () => { + const html = renderToStaticMarkup( {}} />); for (const key of ["enabled", "embedding", "clustering", "labeling"]) { assert.ok(html.includes(`data-testid="graph-row-${key}"`), key); assert.ok(!tagOf(html, `toggle-graph-${key}`).includes("disabled"), `${key} touchable`); @@ -144,7 +150,7 @@ describe("GraphSection — the dependency ladder", () => { it("master off grays EVERY dependent (stored values persist, resolve inert)", () => { const html = renderToStaticMarkup( - {}} />, + {}} />, ); for (const key of ["embedding", "clustering", "labeling"]) { assert.ok(tagOf(html, `toggle-graph-${key}`).includes("disabled"), `${key} disabled`); @@ -154,7 +160,7 @@ describe("GraphSection — the dependency ladder", () => { it("a mid-ladder break grays only what's below it", () => { const html = renderToStaticMarkup( - {}} />, + {}} />, ); assert.ok(!tagOf(html, "toggle-graph-embedding").includes("disabled"), "embedding touchable"); assert.ok(!tagOf(html, "toggle-graph-clustering").includes("disabled"), "clustering touchable");