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

@ -28,7 +28,7 @@ import type {
} from "@/ports";
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
import { useGateways } from "@/app/di";
import { normalizeWeights, resizeAdjacent } from "./layout";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
interface LayoutGridProps {
@ -56,6 +56,7 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
</div>
);
}
const visibleNodeIds = new Set(leaves(vm.layout).map((l) => l.id));
return (
<div
@ -73,6 +74,7 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
vm={vm}
parentSplit={null}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
</div>
);
@ -85,9 +87,10 @@ interface NodeViewProps {
/** The enclosing split + this node's index in it, for the merge action. */
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
visibleNodeIds: Set<string>;
}
function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: NodeViewProps) {
switch (node.type) {
case "leaf":
return (
@ -101,12 +104,29 @@ function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
vm={vm}
parentSplit={parentSplit}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
case "split":
return <SplitView split={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
return (
<SplitView
split={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
case "grid":
return <GridView grid={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
return (
<GridView
grid={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
}
}
@ -120,6 +140,7 @@ interface LeafViewProps {
vm: LayoutViewModel;
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
visibleNodeIds: Set<string>;
}
/**
@ -135,7 +156,7 @@ interface PendingResume {
reject: (e: unknown) => void;
}
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId }: LeafViewProps) {
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, visibleNodeIds }: LeafViewProps) {
// A cell can be closed only when it lives inside a (binary) split: closing it
// collapses the parent split, keeping the *sibling*. Splits are always binary
// in this model (a split wraps a leaf into a 2-child container), so the kept
@ -204,13 +225,25 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
// Build the terminal opener based on whether an agent is pinned.
const agentId = agent ?? null;
/**
* Whether `candidate` is currently running in a cell *other than this one*.
* Such an agent cannot be launched here (one live session per agent), so the
* dropdown disables it and `onChange` rejects selecting it.
*/
const isLiveElsewhere = (candidate: string): boolean =>
liveAgents.some((la) => la.agentId === candidate && la.nodeId !== id);
/** The live session for `candidate`, if any. */
const liveFor = (candidate: string): LiveAgent | undefined =>
liveAgents.find((la) => la.agentId === candidate);
/** True when the live session is already displayed by another visible cell. */
const visibleElsewhere = (candidate: string): LiveAgent | undefined => {
const live = liveFor(candidate);
return live && live.nodeId !== id && visibleNodeIds.has(live.nodeId)
? live
: undefined;
};
/** A live session whose previous host cell no longer exists in the layout. */
const backgroundLive = (candidate: string): LiveAgent | undefined => {
const live = liveFor(candidate);
return live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId)
? live
: undefined;
};
// A transient notice shown when an action is blocked by the singleton
// invariant (selecting / launching an agent already live in another cell).
@ -231,6 +264,16 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
onData: (bytes: Uint8Array) => void,
convId: string | undefined,
): Promise<TerminalHandle> => {
const background = backgroundLive(agentId!);
if (background?.sessionId && agentGateway!.attachLiveAgent) {
const attached = await agentGateway!.attachLiveAgent(projectId, agentId!, id);
const sessionId = attached.sessionId ?? background.sessionId;
void vm.setSession(id, sessionId);
const result = await agentGateway!.reattach(sessionId, onData);
refreshLive();
return result.handle;
}
const handle = await agentGateway!.launchAgent(
projectId,
agentId!,
@ -328,15 +371,39 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
value={agentId ?? ""}
onChange={(e) => {
const val = e.target.value;
// Reject pinning an agent that is already running in another cell.
// (The dropdown also disables such options, but guard the change in
// case it is set programmatically.)
if (val !== "" && isLiveElsewhere(val)) {
setBusyNotice("Cet agent tourne déjà dans une autre cellule.");
if (val === "") {
setBusyNotice(null);
void vm.setCellAgent(id, null);
return;
}
setBusyNotice(null);
void vm.setCellAgent(id, val === "" ? null : val);
void (async () => {
const current = agentGateway?.listLiveAgents
? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
: liveAgents;
setLiveAgents(current);
const live = current.find((la) => la.agentId === val);
const isVisible =
live && live.nodeId !== id && visibleNodeIds.has(live.nodeId);
if (isVisible) {
setBusyNotice("Cet agent est déjà visible dans une autre cellule.");
return;
}
const isBackground =
live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId);
if (isBackground) {
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
setBusyNotice("Session active introuvable pour cet agent.");
return;
}
setBusyNotice(null);
const attached = await agentGateway.attachLiveAgent(projectId, val, id);
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
refreshLive();
return;
}
setBusyNotice(null);
await vm.setCellAgent(id, val);
})().catch((err: unknown) => setBusyNotice(describeNotice(err)));
}}
style={{
fontSize: 11,
@ -350,13 +417,12 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
>
<option value="">Plain</option>
{agents.map((a) => {
// An agent already running in another cell cannot be pinned here.
// The agent pinned on THIS cell stays selectable (same node).
const elsewhere = isLiveElsewhere(a.id);
const elsewhere = visibleElsewhere(a.id);
const background = backgroundLive(a.id);
return (
<option key={a.id} value={a.id} disabled={elsewhere}>
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
{a.name}
{elsewhere ? " (en cours ailleurs)" : ""}
{elsewhere ? " (visible ailleurs)" : background ? " (arrière-plan)" : ""}
</option>
);
})}
@ -437,9 +503,10 @@ interface SplitViewProps {
cwd: string;
vm: LayoutViewModel;
projectId: string;
visibleNodeIds: Set<string>;
}
function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps) {
const isRow = split.direction === "row";
const baseWeights = split.children.map((c) => c.weight);
const containerRef = useRef<HTMLDivElement | null>(null);
@ -480,6 +547,7 @@ function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
parentSplit={{
container: split.id,
index: i,
@ -571,9 +639,10 @@ interface GridViewProps {
cwd: string;
vm: LayoutViewModel;
projectId: string;
visibleNodeIds: Set<string>;
}
function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) {
const cols = normalizeWeights(grid.colWeights)
.map((p) => `${p}fr`)
.join(" ");
@ -604,7 +673,14 @@ function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
overflow: "hidden",
}}
>
<NodeView node={cell.node} cwd={cwd} vm={vm} parentSplit={null} projectId={projectId} />
<NodeView
node={cell.node}
cwd={cwd}
vm={vm}
parentSplit={null}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
</div>
))}
</div>
@ -615,3 +691,10 @@ function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
function keyOf(node: LayoutNode, fallback: number): string {
return node.node.id ?? String(fallback);
}
function describeNotice(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}