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