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:
2026-06-09 09:24:51 +02:00
parent 32398827fb
commit 785e9935fd
118 changed files with 5793 additions and 866 deletions

View 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>
);
}

View 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/));
});
});

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

View 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,
};
}