feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
481
frontend/src/features/embedder/EmbedderSettings.tsx
Normal file
481
frontend/src/features/embedder/EmbedderSettings.tsx
Normal file
@ -0,0 +1,481 @@
|
||||
/**
|
||||
* `EmbedderSettings` — memory/embedder configuration panel (L14 / lot C2).
|
||||
*
|
||||
* Pure presentation: all behaviour comes from {@link useEmbedder}. Styled with
|
||||
* `@/shared` design system tokens; no inline styles, no `invoke()`.
|
||||
*
|
||||
* Product spirit ("Linux: nothing imposed"):
|
||||
* - All strategies are listed on an equal footing: None, ONNX local, Ollama
|
||||
* (localServer), API, Custom.
|
||||
* - `e5-small` (ONNX) is only *pre-selected* as the recommended default — the
|
||||
* cursor sits on it, but nothing is written until the user clicks "Save".
|
||||
* - A strategy whose Cargo feature flag is `false` (vectorOnnxEnabled /
|
||||
* vectorHttpEnabled) is shown disabled with "not available in this build".
|
||||
* `none` is always available.
|
||||
* - "Back to None" is always present.
|
||||
* - The change takes effect at the next app start — the panel says so.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
EmbedderProfile,
|
||||
EmbedderStrategy,
|
||||
RecommendedOnnxEngine,
|
||||
} from "@/domain";
|
||||
import { Button, Field, Input, Panel, Spinner, cn } from "@/shared";
|
||||
import { useEmbedder } from "./useEmbedder";
|
||||
|
||||
/** Default endpoint placeholder for a local embedding server (Ollama). */
|
||||
const LOCAL_SERVER_PLACEHOLDER = "http://localhost:11434/api/embeddings";
|
||||
const API_ENDPOINT_PLACEHOLDER = "https://api.openai.com/v1/embeddings";
|
||||
|
||||
/** The selectable strategies, listed on equal footing. `none` is always last. */
|
||||
interface StrategyOption {
|
||||
strategy: EmbedderStrategy;
|
||||
/** Stable selection key (custom shares the `api` strategy but its own form). */
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const STRATEGY_OPTIONS: StrategyOption[] = [
|
||||
{ strategy: "none", key: "none", label: "None (naïve recall)" },
|
||||
{ strategy: "localOnnx", key: "localOnnx", label: "Local ONNX" },
|
||||
{ strategy: "localServer", key: "localServer", label: "Ollama (local server)" },
|
||||
{ strategy: "api", key: "api", label: "API" },
|
||||
{ strategy: "api", key: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
/** Editable draft of the form, before it is turned into a profile and saved. */
|
||||
interface Draft {
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
endpoint: string;
|
||||
apiKeyEnv: string;
|
||||
dimension: string;
|
||||
/** Selected recommended ONNX engine id (localOnnx). */
|
||||
onnxEngineId: string;
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: Draft = {
|
||||
id: "",
|
||||
name: "",
|
||||
model: "",
|
||||
endpoint: "",
|
||||
apiKeyEnv: "",
|
||||
dimension: "",
|
||||
onnxEngineId: "",
|
||||
};
|
||||
|
||||
/** Builds the initial draft for a selection key, given the available engines. */
|
||||
function draftFor(
|
||||
key: string,
|
||||
recommendedOnnx: RecommendedOnnxEngine[],
|
||||
existing: EmbedderProfile | null,
|
||||
): Draft {
|
||||
if (existing) {
|
||||
return {
|
||||
id: existing.id,
|
||||
name: existing.name,
|
||||
model: existing.model ?? "",
|
||||
endpoint: existing.endpoint ?? "",
|
||||
apiKeyEnv: existing.apiKeyEnv ?? "",
|
||||
dimension: String(existing.dimension),
|
||||
onnxEngineId:
|
||||
key === "localOnnx"
|
||||
? recommendedOnnx.find((e) => e.id === existing.model)?.id ??
|
||||
recommendedOnnx[0]?.id ??
|
||||
""
|
||||
: "",
|
||||
};
|
||||
}
|
||||
switch (key) {
|
||||
case "localOnnx": {
|
||||
const reco =
|
||||
recommendedOnnx.find((e) => e.recommended) ?? recommendedOnnx[0];
|
||||
return {
|
||||
...EMPTY_DRAFT,
|
||||
id: "onnx-local",
|
||||
name: reco?.displayName ?? "Local ONNX",
|
||||
model: reco?.id ?? "",
|
||||
dimension: reco ? String(reco.dimension) : "",
|
||||
onnxEngineId: reco?.id ?? "",
|
||||
};
|
||||
}
|
||||
case "localServer":
|
||||
return {
|
||||
...EMPTY_DRAFT,
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
endpoint: LOCAL_SERVER_PLACEHOLDER,
|
||||
model: "nomic-embed-text",
|
||||
dimension: "768",
|
||||
};
|
||||
case "api":
|
||||
return {
|
||||
...EMPTY_DRAFT,
|
||||
id: "api",
|
||||
name: "API embedder",
|
||||
endpoint: API_ENDPOINT_PLACEHOLDER,
|
||||
apiKeyEnv: "OPENAI_API_KEY",
|
||||
model: "text-embedding-3-small",
|
||||
dimension: "1536",
|
||||
};
|
||||
case "custom":
|
||||
return { ...EMPTY_DRAFT, id: "custom", name: "Custom embedder" };
|
||||
default:
|
||||
return { ...EMPTY_DRAFT };
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a strategy is compiled into this build (None always is). */
|
||||
function strategyEnabled(
|
||||
strategy: EmbedderStrategy,
|
||||
flags: { vectorOnnxEnabled: boolean; vectorHttpEnabled: boolean },
|
||||
): boolean {
|
||||
switch (strategy) {
|
||||
case "none":
|
||||
return true;
|
||||
case "localOnnx":
|
||||
return flags.vectorOnnxEnabled;
|
||||
case "localServer":
|
||||
case "api":
|
||||
return flags.vectorHttpEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
export function EmbedderSettings() {
|
||||
const vm = useEmbedder();
|
||||
|
||||
// Selection cursor; pre-selected on the recommended ONNX engine. Writing
|
||||
// happens only on "Save".
|
||||
const [selectedKey, setSelectedKey] = useState<string>("localOnnx");
|
||||
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
|
||||
|
||||
const recommendedOnnx = vm.engines?.recommendedOnnx ?? [];
|
||||
const flags = {
|
||||
vectorOnnxEnabled: vm.engines?.vectorOnnxEnabled ?? false,
|
||||
vectorHttpEnabled: vm.engines?.vectorHttpEnabled ?? false,
|
||||
};
|
||||
|
||||
// Once the engines load, seed the draft for the current selection (mirroring
|
||||
// an existing active profile if its strategy matches the selection).
|
||||
useEffect(() => {
|
||||
if (!vm.engines) return;
|
||||
const opt = STRATEGY_OPTIONS.find((o) => o.key === selectedKey);
|
||||
const existing =
|
||||
vm.active && opt && vm.active.strategy === opt.strategy ? vm.active : null;
|
||||
setDraft(draftFor(selectedKey, vm.engines.recommendedOnnx, existing));
|
||||
// We intentionally re-seed only when engines / selection / active change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [vm.engines, vm.active, selectedKey]);
|
||||
|
||||
const selectedOption = STRATEGY_OPTIONS.find((o) => o.key === selectedKey)!;
|
||||
const selectedStrategy = selectedOption.strategy;
|
||||
|
||||
function patch(p: Partial<Draft>) {
|
||||
setDraft((d) => ({ ...d, ...p }));
|
||||
}
|
||||
|
||||
function selectOnnxEngine(id: string) {
|
||||
const eng = recommendedOnnx.find((e) => e.id === id);
|
||||
patch({
|
||||
onnxEngineId: id,
|
||||
model: eng?.id ?? id,
|
||||
// Dimension is auto-filled from the chosen model (e5-small ⇒ 384).
|
||||
dimension: eng ? String(eng.dimension) : draft.dimension,
|
||||
});
|
||||
}
|
||||
|
||||
// ── UI-side validation, aligned with the backend (avoid an INVALID error) ──
|
||||
const dimensionNum = Number.parseInt(draft.dimension, 10);
|
||||
const validation = useMemo(() => {
|
||||
if (selectedStrategy === "none") return { ok: true as const };
|
||||
if (!draft.id.trim()) return { ok: false as const, msg: "Id is required." };
|
||||
if (!draft.name.trim())
|
||||
return { ok: false as const, msg: "Name is required." };
|
||||
if (!Number.isFinite(dimensionNum) || dimensionNum <= 0)
|
||||
return { ok: false as const, msg: "Dimension must be a positive number." };
|
||||
if (selectedStrategy === "localServer" || selectedStrategy === "api") {
|
||||
if (!draft.endpoint.trim())
|
||||
return { ok: false as const, msg: "Endpoint is required." };
|
||||
}
|
||||
return { ok: true as const };
|
||||
}, [selectedStrategy, draft, dimensionNum]);
|
||||
|
||||
function toProfile(): EmbedderProfile {
|
||||
return {
|
||||
id: draft.id.trim(),
|
||||
name: draft.name.trim(),
|
||||
strategy: selectedStrategy,
|
||||
dimension: dimensionNum,
|
||||
model: draft.model.trim() || undefined,
|
||||
endpoint:
|
||||
selectedStrategy === "localServer" || selectedStrategy === "api"
|
||||
? draft.endpoint.trim() || undefined
|
||||
: undefined,
|
||||
apiKeyEnv:
|
||||
selectedStrategy === "api"
|
||||
? draft.apiKeyEnv.trim() || undefined
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!validation.ok) return;
|
||||
await vm.saveProfile(toProfile());
|
||||
}
|
||||
|
||||
async function handleBackToNone() {
|
||||
// Delete every configured (non-none) profile so recall falls back to naïve.
|
||||
for (const p of vm.profiles) {
|
||||
await vm.deleteProfile(p.id);
|
||||
}
|
||||
setSelectedKey("none");
|
||||
}
|
||||
|
||||
const selectClass = cn(
|
||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
);
|
||||
|
||||
const hasActive = vm.active !== null;
|
||||
|
||||
return (
|
||||
<Panel title="Memory / Embedder" className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{/* ── Restart notice ── */}
|
||||
<p
|
||||
className="rounded-md border border-border bg-raised px-3 py-2 text-xs text-muted"
|
||||
data-testid="embedder-restart-note"
|
||||
>
|
||||
The embedder change takes effect at the next app start.
|
||||
</p>
|
||||
|
||||
{/* ── Active tier (current) ── */}
|
||||
<p className="text-xs text-faint" data-testid="embedder-active">
|
||||
Current:{" "}
|
||||
{hasActive ? (
|
||||
<span className="text-content">
|
||||
Vector — {vm.active!.strategy}
|
||||
{vm.active!.model ? ` ${vm.active!.model}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-content">Naïve (None)</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* ── Strategy chooser ── */}
|
||||
<fieldset className="flex flex-col gap-2" aria-label="embedder strategy">
|
||||
<legend className="mb-1 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Strategy
|
||||
</legend>
|
||||
{STRATEGY_OPTIONS.map((opt) => {
|
||||
const enabled = strategyEnabled(opt.strategy, flags);
|
||||
const recommended = opt.key === "localOnnx";
|
||||
return (
|
||||
<label
|
||||
key={opt.key}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border px-3 py-2 text-sm",
|
||||
selectedKey === opt.key
|
||||
? "border-primary bg-raised"
|
||||
: "border-border",
|
||||
!enabled && "opacity-50",
|
||||
)}
|
||||
data-testid={`embedder-strategy-${opt.key}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="embedder-strategy"
|
||||
value={opt.key}
|
||||
checked={selectedKey === opt.key}
|
||||
disabled={!enabled}
|
||||
onChange={() => setSelectedKey(opt.key)}
|
||||
aria-label={opt.label}
|
||||
/>
|
||||
<span className="text-content">{opt.label}</span>
|
||||
{recommended && (
|
||||
<span className="rounded border border-primary/40 bg-primary/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-primary">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
{!enabled && (
|
||||
<span className="text-[11px] text-faint">
|
||||
not available in this build
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
{/* ── Conditional fields per strategy ── */}
|
||||
{selectedStrategy !== "none" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* localOnnx → model picker + auto dimension */}
|
||||
{selectedStrategy === "localOnnx" && (
|
||||
<Field label="ONNX model">
|
||||
{({ id }) => (
|
||||
<select
|
||||
id={id}
|
||||
aria-label="onnx model"
|
||||
className={selectClass}
|
||||
value={draft.onnxEngineId}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => selectOnnxEngine(e.target.value)}
|
||||
>
|
||||
{recommendedOnnx.map((eng) => (
|
||||
<option key={eng.id} value={eng.id}>
|
||||
{eng.displayName}
|
||||
{eng.recommended ? " — recommended" : ""} (dim{" "}
|
||||
{eng.dimension}, ~{eng.approxSizeMb} MB)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* localServer / api → endpoint */}
|
||||
{(selectedStrategy === "localServer" ||
|
||||
selectedStrategy === "api") && (
|
||||
<Field label="Endpoint">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="endpoint"
|
||||
placeholder={
|
||||
selectedStrategy === "localServer"
|
||||
? LOCAL_SERVER_PLACEHOLDER
|
||||
: API_ENDPOINT_PLACEHOLDER
|
||||
}
|
||||
value={draft.endpoint}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => patch({ endpoint: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* api → apiKeyEnv (variable NAME, never the key) */}
|
||||
{selectedStrategy === "api" && (
|
||||
<Field
|
||||
label="API key environment variable"
|
||||
hint="Name of the env var holding the key — never the key itself."
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="api key env var"
|
||||
placeholder="OPENAI_API_KEY"
|
||||
aria-describedby={describedBy}
|
||||
value={draft.apiKeyEnv}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => patch({ apiKeyEnv: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* model (localServer / api / custom) */}
|
||||
{selectedStrategy !== "localOnnx" && (
|
||||
<Field label="Model">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="model"
|
||||
placeholder="model name"
|
||||
value={draft.model}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => patch({ model: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* dimension — auto-filled for ONNX, editable everywhere */}
|
||||
<Field
|
||||
label="Dimension"
|
||||
error={!validation.ok ? validation.msg : undefined}
|
||||
>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="dimension"
|
||||
type="number"
|
||||
min={1}
|
||||
value={draft.dimension}
|
||||
aria-describedby={describedBy}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => patch({ dimension: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* id / name (custom & advanced) */}
|
||||
<Field label="Profile id">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="profile id"
|
||||
placeholder="my-embedder"
|
||||
value={draft.id}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => patch({ id: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Profile name">
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-label="profile name"
|
||||
placeholder="My embedder"
|
||||
value={draft.name}
|
||||
disabled={vm.busy}
|
||||
onChange={(e) => patch({ name: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||
{vm.busy && <Spinner size={14} />}
|
||||
{selectedStrategy !== "none" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
aria-label="save embedder"
|
||||
disabled={vm.busy || !validation.ok}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="back to none"
|
||||
disabled={vm.busy}
|
||||
onClick={() => void handleBackToNone()}
|
||||
>
|
||||
Back to None
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
237
frontend/src/features/embedder/embedder.test.tsx
Normal file
237
frontend/src/features/embedder/embedder.test.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* L14 / lot C2 — embedder settings feature wired to the stateful
|
||||
* `MockEmbedderGateway` via the real `DIProvider` (same harness as
|
||||
* `memory.test.tsx`).
|
||||
*
|
||||
* Covers:
|
||||
* - the strategy list renders on an equal footing (None, ONNX, Ollama, API,
|
||||
* Custom) with the "Recommended" badge on ONNX
|
||||
* - conditional fields per strategy (ONNX model picker + auto dimension;
|
||||
* localServer endpoint; api endpoint + apiKeyEnv label; none → no fields)
|
||||
* - a strategy whose build flag is false is shown disabled with
|
||||
* "not available in this build" (None stays available)
|
||||
* - Save calls the gateway with the right profile
|
||||
* - "Back to None" deletes the configured profiles
|
||||
*
|
||||
* Plus the active-tier transparency line in MemoryPanel (Naïve vs Vector).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { MockEmbedderGateway, MockMemoryGateway } from "@/adapters/mock";
|
||||
import type { EmbedderEngines, EmbedderProfile } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { MemoryPanel } from "@/features/memory";
|
||||
import { EmbedderSettings } from "./EmbedderSettings";
|
||||
|
||||
function renderEmbedder(
|
||||
engines?: Partial<EmbedderEngines>,
|
||||
seed: EmbedderProfile[] = [],
|
||||
) {
|
||||
const embedder = new MockEmbedderGateway(engines, seed);
|
||||
const gateways = { embedder } as unknown as Gateways;
|
||||
return {
|
||||
embedder,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<EmbedderSettings />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForEmbedderIdle() {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "back to none" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
describe("EmbedderSettings (with MockEmbedderGateway)", () => {
|
||||
it("renders the strategy list on an equal footing with a Recommended badge", async () => {
|
||||
renderEmbedder();
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
// Every strategy is offered.
|
||||
expect(screen.getByTestId("embedder-strategy-none")).toBeTruthy();
|
||||
expect(screen.getByTestId("embedder-strategy-localOnnx")).toBeTruthy();
|
||||
expect(screen.getByTestId("embedder-strategy-localServer")).toBeTruthy();
|
||||
expect(screen.getByTestId("embedder-strategy-api")).toBeTruthy();
|
||||
expect(screen.getByTestId("embedder-strategy-custom")).toBeTruthy();
|
||||
|
||||
// ONNX carries the "Recommended" badge.
|
||||
expect(screen.getByText("Recommended")).toBeTruthy();
|
||||
|
||||
// The change-takes-effect notice is visible.
|
||||
expect(screen.getByTestId("embedder-restart-note").textContent).toMatch(
|
||||
/next app start/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("pre-selects ONNX with the recommended model and auto-fills its dimension (384)", async () => {
|
||||
renderEmbedder();
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
// ONNX is the default cursor → its model picker is shown.
|
||||
const modelSelect = (await screen.findByLabelText(
|
||||
"onnx model",
|
||||
)) as HTMLSelectElement;
|
||||
expect(modelSelect.value).toBe("e5-small");
|
||||
|
||||
// Dimension auto-filled from the model.
|
||||
expect((screen.getByLabelText("dimension") as HTMLInputElement).value).toBe(
|
||||
"384",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows endpoint for localServer and endpoint + apiKeyEnv (named, not the key) for api", async () => {
|
||||
renderEmbedder();
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
// localServer → endpoint, no apiKeyEnv.
|
||||
fireEvent.click(
|
||||
screen.getByRole("radio", { name: "Ollama (local server)" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByLabelText("endpoint")).toBeTruthy());
|
||||
expect(screen.queryByLabelText("api key env var")).toBeNull();
|
||||
|
||||
// api → endpoint + apiKeyEnv with an explicit "env var" label.
|
||||
fireEvent.click(screen.getByRole("radio", { name: "API" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("api key env var")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText(/environment variable/i)).toBeTruthy();
|
||||
expect(screen.getByText(/never the key itself/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders no conditional fields for None", async () => {
|
||||
renderEmbedder();
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "None (naïve recall)" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("dimension")).toBeNull(),
|
||||
);
|
||||
expect(screen.queryByLabelText("onnx model")).toBeNull();
|
||||
// No Save button in None mode (only "Back to None").
|
||||
expect(screen.queryByRole("button", { name: "save embedder" })).toBeNull();
|
||||
});
|
||||
|
||||
it("disables strategies whose build flag is false with a 'not available' note", async () => {
|
||||
renderEmbedder({ vectorOnnxEnabled: false, vectorHttpEnabled: false });
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
const onnxRadio = screen.getByRole("radio", {
|
||||
name: "Local ONNX",
|
||||
}) as HTMLInputElement;
|
||||
const ollamaRadio = screen.getByRole("radio", {
|
||||
name: "Ollama (local server)",
|
||||
}) as HTMLInputElement;
|
||||
const apiRadio = screen.getByRole("radio", { name: "API" }) as HTMLInputElement;
|
||||
const noneRadio = screen.getByRole("radio", {
|
||||
name: "None (naïve recall)",
|
||||
}) as HTMLInputElement;
|
||||
|
||||
expect(onnxRadio.disabled).toBe(true);
|
||||
expect(ollamaRadio.disabled).toBe(true);
|
||||
expect(apiRadio.disabled).toBe(true);
|
||||
// None is always available.
|
||||
expect(noneRadio.disabled).toBe(false);
|
||||
|
||||
// The honesty note appears.
|
||||
expect(
|
||||
screen.getAllByText("not available in this build").length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("Save sends the recommended ONNX profile (e5-small, dim 384)", async () => {
|
||||
const { embedder } = renderEmbedder();
|
||||
const spy = vi.spyOn(embedder, "saveEmbedderProfile");
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "save embedder" }));
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
|
||||
const saved = spy.mock.calls[0][0];
|
||||
expect(saved.strategy).toBe("localOnnx");
|
||||
expect(saved.model).toBe("e5-small");
|
||||
expect(saved.dimension).toBe(384);
|
||||
expect(saved.id.length).toBeGreaterThan(0);
|
||||
expect(saved.name.length).toBeGreaterThan(0);
|
||||
// ONNX never carries an endpoint/apiKeyEnv.
|
||||
expect(saved.endpoint).toBeUndefined();
|
||||
expect(saved.apiKeyEnv).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Save for api sends endpoint + apiKeyEnv (the env var NAME)", async () => {
|
||||
const { embedder } = renderEmbedder();
|
||||
const spy = vi.spyOn(embedder, "saveEmbedderProfile");
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "API" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("api key env var")).toBeTruthy(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("api key env var"), {
|
||||
target: { value: "MY_KEY_VAR" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "save embedder" }));
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||
const saved = spy.mock.calls.at(-1)![0];
|
||||
expect(saved.strategy).toBe("api");
|
||||
expect(saved.apiKeyEnv).toBe("MY_KEY_VAR");
|
||||
expect(saved.endpoint).toBeTruthy();
|
||||
});
|
||||
|
||||
it("'Back to None' deletes the configured profiles", async () => {
|
||||
const seed: EmbedderProfile[] = [
|
||||
{ id: "onnx-local", name: "Local ONNX", strategy: "localOnnx", model: "e5-small", dimension: 384 },
|
||||
];
|
||||
const { embedder } = renderEmbedder(undefined, seed);
|
||||
const spy = vi.spyOn(embedder, "deleteEmbedderProfile");
|
||||
await waitForEmbedderIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "back to none" }));
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith("onnx-local"));
|
||||
expect(await embedder.listEmbedderProfiles()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MemoryPanel — active recall tier transparency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MemoryPanel active tier (transparency)", () => {
|
||||
function renderPanel(embedder: MockEmbedderGateway) {
|
||||
const gateways = {
|
||||
memory: new MockMemoryGateway(),
|
||||
embedder,
|
||||
} as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<MemoryPanel projectId="p" />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("shows 'Naïve (None)' when no embedder is configured", async () => {
|
||||
renderPanel(new MockEmbedderGateway());
|
||||
const tier = await screen.findByTestId("memory-tier");
|
||||
await waitFor(() => expect(tier.textContent).toMatch(/Naïve \(None\)/));
|
||||
});
|
||||
|
||||
it("shows 'Vector — <strategy> <model>' when an embedder is configured", async () => {
|
||||
const embedder = new MockEmbedderGateway(undefined, [
|
||||
{ id: "onnx-local", name: "Local ONNX", strategy: "localOnnx", model: "e5-small", dimension: 384 },
|
||||
]);
|
||||
renderPanel(embedder);
|
||||
const tier = await screen.findByTestId("memory-tier");
|
||||
await waitFor(() => expect(tier.textContent).toMatch(/Vector — localOnnx e5-small/));
|
||||
});
|
||||
});
|
||||
4
frontend/src/features/embedder/index.ts
Normal file
4
frontend/src/features/embedder/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/** Public surface of the embedder feature (L14 / lot C2). */
|
||||
export { EmbedderSettings } from "./EmbedderSettings";
|
||||
export { useEmbedder } from "./useEmbedder";
|
||||
export type { EmbedderViewModel } from "./useEmbedder";
|
||||
118
frontend/src/features/embedder/useEmbedder.ts
Normal file
118
frontend/src/features/embedder/useEmbedder.ts
Normal file
@ -0,0 +1,118 @@
|
||||
/**
|
||||
* `useEmbedder` — view-model hook for the memory/embedder settings (L14 / C2).
|
||||
*
|
||||
* Owns the configured embedder profiles and the available engines (with the
|
||||
* build-time feature flags). Consumes {@link EmbedderGateway} exclusively; never
|
||||
* touches `invoke()` or `@tauri-apps/api`, keeping the component layer testable
|
||||
* with mock gateways (ARCHITECTURE §1.3).
|
||||
*
|
||||
* Note: the embedder change takes effect at the *next app start* — this hook
|
||||
* just persists the choice; it does not hot-swap the live recall tier.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { EmbedderEngines, EmbedderProfile, GatewayError } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** What the embedder settings UI needs from this hook. */
|
||||
export interface EmbedderViewModel {
|
||||
/** The configured embedder profiles. */
|
||||
profiles: EmbedderProfile[];
|
||||
/** The available engines + build feature flags, or `null` until loaded. */
|
||||
engines: EmbedderEngines | null;
|
||||
/** The single active profile (first configured), or `null` (naïve/None). */
|
||||
active: EmbedderProfile | null;
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Reloads profiles + engines. */
|
||||
refresh: () => Promise<void>;
|
||||
/** Persists a profile (create/replace by id) and refreshes. */
|
||||
saveProfile: (profile: EmbedderProfile) => Promise<void>;
|
||||
/** Deletes a profile by id and refreshes. */
|
||||
deleteProfile: (embedderId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function useEmbedder(): EmbedderViewModel {
|
||||
const { embedder } = useGateways();
|
||||
|
||||
const [profiles, setProfiles] = useState<EmbedderProfile[]>([]);
|
||||
const [engines, setEngines] = useState<EmbedderEngines | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [list, eng] = await Promise.all([
|
||||
embedder.listEmbedderProfiles(),
|
||||
embedder.describeEmbedderEngines(),
|
||||
]);
|
||||
setProfiles(list);
|
||||
setEngines(eng);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [embedder]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const saveProfile = useCallback(
|
||||
async (profile: EmbedderProfile) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await embedder.saveEmbedderProfile(profile);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[embedder, refresh],
|
||||
);
|
||||
|
||||
const deleteProfile = useCallback(
|
||||
async (embedderId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await embedder.deleteEmbedderProfile(embedderId);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[embedder, refresh],
|
||||
);
|
||||
|
||||
// The active embedder is the first configured profile (the backend keeps a
|
||||
// single active embedder); `null` ⇒ naïve recall (None).
|
||||
const active = profiles.find((p) => p.strategy !== "none") ?? null;
|
||||
|
||||
return {
|
||||
profiles,
|
||||
engines,
|
||||
active,
|
||||
error,
|
||||
busy,
|
||||
refresh,
|
||||
saveProfile,
|
||||
deleteProfile,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user