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

@ -10,7 +10,7 @@
import { useCallback, useEffect, useState } from "react";
import type { Agent, AgentProfile, GatewayError } from "@/domain";
import type { OpenTerminalOptions, TerminalHandle } from "@/ports";
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
import { useGateways } from "@/app/di";
/** What the agents UI needs from this hook. */
@ -23,6 +23,8 @@ export interface AgentsViewModel {
context: string;
/** Profiles available for assigning to a new agent. */
profiles: AgentProfile[];
/** Agents that currently own a live PTY session, as tracked by IdeA. */
liveAgents: LiveAgent[];
/** Last error message, or `null`. */
error: string | null;
/** Whether a request is in flight. */
@ -34,6 +36,8 @@ export interface AgentsViewModel {
runningAgentId: string | null;
/** Reloads the agent list. */
refresh: () => Promise<void>;
/** Reloads the currently-live agent sessions. */
refreshLiveAgents: () => Promise<void>;
/** Creates a new agent and refreshes the list. */
createAgent: (name: string, profileId: string, initialContent?: string) => Promise<void>;
/** Selects an agent and loads its context. */
@ -52,8 +56,8 @@ export interface AgentsViewModel {
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
) => Promise<TerminalHandle>;
/** Clears `runningAgentId` (called by the Stop button to unmount the terminal). */
stopAgent: () => void;
/** Stops an agent PTY explicitly by closing its live session id. */
stopAgent: (agentId?: string) => Promise<void>;
}
function describe(e: unknown): string {
@ -64,12 +68,13 @@ function describe(e: unknown): string {
}
export function useAgents(projectId: string): AgentsViewModel {
const { agent, profile, system } = useGateways();
const { agent, profile, system, terminal } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [context, setContext] = useState<string>("");
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [liveAgents, setLiveAgents] = useState<LiveAgent[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [runningAgentId, setRunningAgentId] = useState<string | null>(null);
@ -91,10 +96,22 @@ export function useAgents(projectId: string): AgentsViewModel {
}
}, [agent, profile, projectId]);
const refreshLiveAgents = useCallback(async () => {
try {
setLiveAgents(await agent.listLiveAgents(projectId));
} catch {
setLiveAgents([]);
}
}, [agent, projectId]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
void refreshLiveAgents();
}, [refreshLiveAgents]);
// Live refresh: the agents list is otherwise only reloaded on mount or after a
// UI-driven CRUD. An agent created/launched/stopped *out of band* — most
// notably by the orchestrator (`spawn_agent`, ARCHITECTURE §14.3) when an AI
@ -109,6 +126,7 @@ export function useAgents(projectId: string): AgentsViewModel {
.onDomainEvent((event) => {
if (event.type === "agentLaunched" || event.type === "agentExited") {
void refresh();
void refreshLiveAgents();
}
})
.then((un) => {
@ -119,7 +137,7 @@ export function useAgents(projectId: string): AgentsViewModel {
cancelled = true;
unsubscribe?.();
};
}, [system, refresh]);
}, [system, refresh, refreshLiveAgents]);
const createAgent = useCallback(
async (name: string, profileId: string, initialContent?: string) => {
@ -205,28 +223,51 @@ export function useAgents(projectId: string): AgentsViewModel {
try {
const handle = await agent.launchAgent(projectId, agentId, options, onData);
setRunningAgentId(agentId);
void refreshLiveAgents();
return handle;
} catch (e) {
setError(describe(e));
throw e;
}
},
[agent, projectId],
[agent, projectId, refreshLiveAgents],
);
const stopAgent = useCallback(() => {
setRunningAgentId(null);
}, []);
const stopAgent = useCallback(
async (agentId?: string) => {
const targetAgentId = agentId ?? runningAgentId;
const live = targetAgentId
? liveAgents.find((candidate) => candidate.agentId === targetAgentId)
: null;
setRunningAgentId((current) =>
current === targetAgentId || !targetAgentId ? null : current,
);
if (live?.sessionId) {
try {
await terminal?.closeTerminal(live.sessionId);
setLiveAgents((current) =>
current.filter((candidate) => candidate.agentId !== targetAgentId),
);
} catch (e) {
setError(describe(e));
await refreshLiveAgents();
}
}
},
[liveAgents, refreshLiveAgents, runningAgentId, terminal],
);
return {
agents,
selectedAgentId,
context,
profiles,
liveAgents,
error,
busy,
runningAgentId,
refresh,
refreshLiveAgents,
createAgent,
selectAgent,
saveContext,