- 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>
482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
/**
|
|
* `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>
|
|
);
|
|
}
|