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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user