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");