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:
115
frontend/src/features/projects/ProjectContextPanel.tsx
Normal file
115
frontend/src/features/projects/ProjectContextPanel.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* `ProjectContextPanel` — edits the shared project context stored in
|
||||
* `.ideai/CONTEXT.md`.
|
||||
*
|
||||
* This context is injected into every agent launch before the agent persona,
|
||||
* regardless of the selected AI profile/model.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { GatewayError } from "@/domain";
|
||||
import { Button, Panel, Spinner, cn } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
export interface ProjectContextPanelProps {
|
||||
/** Project whose `.ideai/CONTEXT.md` should be edited. */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function ProjectContextPanel({ projectId }: ProjectContextPanelProps) {
|
||||
const { project } = useGateways();
|
||||
const [content, setContent] = useState("");
|
||||
const [savedContent, setSavedContent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
project
|
||||
.readProjectContext(projectId)
|
||||
.then((text) => {
|
||||
if (!cancelled) {
|
||||
setContent(text);
|
||||
setSavedContent(text);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(describe(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setBusy(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [project, projectId]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await project.updateProjectContext(projectId, content);
|
||||
setSavedContent(content);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const dirty = content !== savedContent;
|
||||
|
||||
return (
|
||||
<Panel title="Project Context" className="flex flex-col gap-0">
|
||||
{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"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<code className="text-xs text-muted">.ideai/CONTEXT.md</code>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<textarea
|
||||
aria-label="project context"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={18}
|
||||
className={cn(
|
||||
"w-full rounded-md bg-raised px-3 py-2 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary placeholder:text-faint",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 resize-y font-mono",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{busy && <Spinner size={14} />}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || !dirty}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@ -36,13 +36,16 @@ import { AgentsPanel } from "@/features/agents";
|
||||
import { TemplatesPanel } from "@/features/templates";
|
||||
import { SkillsPanel } from "@/features/skills";
|
||||
import { MemoryPanel } from "@/features/memory";
|
||||
import { EmbedderSettings } from "@/features/embedder";
|
||||
import { GitPanel, GitGraphView } from "@/features/git";
|
||||
import { Button, Input, Panel, Tabs, cn } from "@/shared";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||
import { useProjects } from "./useProjects";
|
||||
|
||||
type SidebarTab =
|
||||
| "projects"
|
||||
| "context"
|
||||
| "agents"
|
||||
| "templates"
|
||||
| "skills"
|
||||
@ -51,6 +54,7 @@ type SidebarTab =
|
||||
|
||||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||
{ id: "projects", label: "Projects" },
|
||||
{ id: "context", label: "Context" },
|
||||
{ id: "agents", label: "Agents" },
|
||||
{ id: "templates", label: "Templates" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
@ -253,6 +257,14 @@ export function ProjectsView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project context panel */}
|
||||
{sidebarTab === "context" && active && (
|
||||
<ProjectContextPanel projectId={active.id} />
|
||||
)}
|
||||
{sidebarTab === "context" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to edit context.</p>
|
||||
)}
|
||||
|
||||
{/* Agents panel — only rendered when active project exists */}
|
||||
{sidebarTab === "agents" && active && (
|
||||
<AgentsPanel projectId={active.id} projectRoot={active.root} />
|
||||
@ -277,9 +289,12 @@ export function ProjectsView() {
|
||||
<p className="text-sm text-muted">Open a project to manage skills.</p>
|
||||
)}
|
||||
|
||||
{/* Memory panel */}
|
||||
{/* Memory panel + embedder settings */}
|
||||
{sidebarTab === "memory" && active && (
|
||||
<MemoryPanel projectId={active.id} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<MemoryPanel projectId={active.id} />
|
||||
<EmbedderSettings />
|
||||
</div>
|
||||
)}
|
||||
{sidebarTab === "memory" && !active && (
|
||||
<p className="text-sm text-muted">Open a project to manage memory.</p>
|
||||
|
||||
@ -181,6 +181,13 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
);
|
||||
expect(screen.queryByLabelText("project name")).toBeNull();
|
||||
|
||||
// Switch to Context tab — shared project context editor should be visible.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Context" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("project context")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("agent name")).toBeNull();
|
||||
|
||||
// Switch to Templates tab — the "New template" button should be visible.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Templates" }));
|
||||
await waitFor(() =>
|
||||
@ -199,6 +206,31 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
expect(screen.getByLabelText("project name")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("edits the shared project context stored under .ideai", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("alpha", "/p/a");
|
||||
await project.updateProjectContext(created.id, "# Existing context");
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Context" }));
|
||||
|
||||
const editor = (await screen.findByLabelText(
|
||||
"project context",
|
||||
)) as HTMLTextAreaElement;
|
||||
expect(editor.value).toBe("# Existing context");
|
||||
|
||||
fireEvent.change(editor, { target: { value: "# Shared\n\nUse pnpm." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
await expect(project.readProjectContext(created.id)).resolves.toBe(
|
||||
"# Shared\n\nUse pnpm.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("Browse… button calls pickFolder and fills the root field with the returned path", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
renderView(new MockProjectGateway(), system);
|
||||
|
||||
Reference in New Issue
Block a user