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:
@ -59,4 +59,14 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
agentId: "a",
|
||||
});
|
||||
});
|
||||
|
||||
it("attach_live_agent wraps project, agent and target node in the request DTO", async () => {
|
||||
invoke.mockResolvedValueOnce([
|
||||
{ agentId: "agent-2", nodeId: "node-3", sessionId: "session-4" },
|
||||
]);
|
||||
await new TauriAgentGateway().attachLiveAgent("proj-1", "agent-2", "node-3");
|
||||
expect(invoke).toHaveBeenCalledWith("attach_live_agent", {
|
||||
request: { projectId: "proj-1", agentId: "agent-2", nodeId: "node-3" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -45,6 +45,22 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
return invoke<LiveAgent[]>("list_live_agents", { projectId });
|
||||
}
|
||||
|
||||
attachLiveAgent(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
nodeId: string,
|
||||
): Promise<LiveAgent> {
|
||||
return invoke<LiveAgent[]>("attach_live_agent", {
|
||||
request: { projectId, agentId, nodeId },
|
||||
}).then((list) => {
|
||||
const attached = list[0];
|
||||
if (!attached) {
|
||||
throw new Error(`running session for agent ${agentId} was not returned`);
|
||||
}
|
||||
return attached;
|
||||
});
|
||||
}
|
||||
|
||||
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||
// The `create_agent` command takes a single `request` DTO; `projectId` must
|
||||
// live *inside* it (camelCase), not at the top level.
|
||||
|
||||
35
frontend/src/adapters/embedder.ts
Normal file
35
frontend/src/adapters/embedder.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Tauri adapter for {@link EmbedderGateway} (L14 / lot C2). Like the sibling
|
||||
* adapters this is one of the *only* places that calls `invoke()`; components
|
||||
* reach it exclusively through the port.
|
||||
*
|
||||
* Commands use snake_case (Tauri convention); payload keys are camelCase
|
||||
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`). The mutating
|
||||
* command `save_embedder_profile` wraps its field under a `request` key (as
|
||||
* `save_profile` does); `delete_embedder_profile` passes its arg flat.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { EmbedderEngines, EmbedderProfile } from "@/domain";
|
||||
import type { EmbedderGateway } from "@/ports";
|
||||
|
||||
export class TauriEmbedderGateway implements EmbedderGateway {
|
||||
listEmbedderProfiles(): Promise<EmbedderProfile[]> {
|
||||
return invoke<EmbedderProfile[]>("list_embedder_profiles");
|
||||
}
|
||||
|
||||
saveEmbedderProfile(profile: EmbedderProfile): Promise<EmbedderProfile> {
|
||||
return invoke<EmbedderProfile>("save_embedder_profile", {
|
||||
request: { profile },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEmbedderProfile(embedderId: string): Promise<void> {
|
||||
await invoke("delete_embedder_profile", { embedderId });
|
||||
}
|
||||
|
||||
describeEmbedderEngines(): Promise<EmbedderEngines> {
|
||||
return invoke<EmbedderEngines>("describe_embedder_engines");
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,7 @@ import { TauriProfileGateway } from "./profile";
|
||||
import { TauriTemplateGateway } from "./template";
|
||||
import { TauriSkillGateway } from "./skill";
|
||||
import { TauriMemoryGateway } from "./memory";
|
||||
import { TauriEmbedderGateway } from "./embedder";
|
||||
import { TauriGitGateway } from "./git";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
@ -51,6 +52,7 @@ export function createTauriGateways(): Gateways {
|
||||
template: new TauriTemplateGateway(),
|
||||
skill: new TauriSkillGateway(),
|
||||
memory: new TauriMemoryGateway(),
|
||||
embedder: new TauriEmbedderGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -64,5 +66,6 @@ export {
|
||||
TauriTemplateGateway,
|
||||
TauriSkillGateway,
|
||||
TauriMemoryGateway,
|
||||
TauriEmbedderGateway,
|
||||
TauriGitGateway,
|
||||
};
|
||||
|
||||
@ -9,6 +9,8 @@ import type {
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
DomainEvent,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
FirstRunState,
|
||||
GatewayError,
|
||||
GitBranches,
|
||||
@ -38,6 +40,7 @@ import type {
|
||||
CreateAgentInput,
|
||||
CreateMemoryInput,
|
||||
CreateSkillInput,
|
||||
EmbedderGateway,
|
||||
CreateTemplateInput,
|
||||
Gateways,
|
||||
LiveAgent,
|
||||
@ -178,6 +181,8 @@ export class MockAgentGateway implements AgentGateway {
|
||||
* Only populated when the caller supplies a `nodeId`.
|
||||
*/
|
||||
private liveByAgent = new Map<string, string>();
|
||||
/** Live PTY session id per agent (`agentId → sessionId`). */
|
||||
private liveSessionByAgent = new Map<string, string>();
|
||||
|
||||
private getAgents(projectId: string): Agent[] {
|
||||
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
||||
@ -198,7 +203,36 @@ export class MockAgentGateway implements AgentGateway {
|
||||
const known = new Set(this.getAgents(projectId).map((a) => a.id));
|
||||
return [...this.liveByAgent.entries()]
|
||||
.filter(([agentId]) => known.has(agentId))
|
||||
.map(([agentId, nodeId]) => ({ agentId, nodeId }));
|
||||
.map(([agentId, nodeId]) => ({
|
||||
agentId,
|
||||
nodeId,
|
||||
sessionId: this.liveSessionByAgent.get(agentId),
|
||||
}));
|
||||
}
|
||||
|
||||
async attachLiveAgent(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
nodeId: string,
|
||||
): Promise<LiveAgent> {
|
||||
const list = this.getAgents(projectId);
|
||||
if (!list.some((a) => a.id === agentId)) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent ${agentId} not found in project ${projectId}`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
const sessionId = this.liveSessionByAgent.get(agentId);
|
||||
if (!sessionId || !this.sessions.has(sessionId)) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent ${agentId} has no live session`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
this.liveByAgent.set(agentId, nodeId);
|
||||
return { agentId, nodeId, sessionId };
|
||||
}
|
||||
|
||||
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||
@ -356,7 +390,10 @@ export class MockAgentGateway implements AgentGateway {
|
||||
this.sessions.set(sessionId, session);
|
||||
// Record liveness for `listLiveAgents` + the guard above (only when the
|
||||
// caller pins a node).
|
||||
if (options.nodeId) this.liveByAgent.set(agentId, options.nodeId);
|
||||
if (options.nodeId) {
|
||||
this.liveByAgent.set(agentId, options.nodeId);
|
||||
this.liveSessionByAgent.set(agentId, sessionId);
|
||||
}
|
||||
// Greet so something is visible immediately (mirrors MockTerminalGateway).
|
||||
queueMicrotask(() =>
|
||||
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
||||
@ -365,6 +402,7 @@ export class MockAgentGateway implements AgentGateway {
|
||||
this.sessions.delete(sessionId);
|
||||
if (this.liveByAgent.get(agentId) === options.nodeId) {
|
||||
this.liveByAgent.delete(agentId);
|
||||
this.liveSessionByAgent.delete(agentId);
|
||||
}
|
||||
});
|
||||
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
|
||||
@ -393,7 +431,15 @@ export class MockAgentGateway implements AgentGateway {
|
||||
}
|
||||
const scrollback = session.reattach(onData);
|
||||
return {
|
||||
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
|
||||
handle: makeMockHandle(session, () => {
|
||||
this.sessions.delete(sessionId);
|
||||
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
|
||||
if (liveSessionId === sessionId) {
|
||||
this.liveSessionByAgent.delete(agentId);
|
||||
this.liveByAgent.delete(agentId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
scrollback,
|
||||
};
|
||||
}
|
||||
@ -513,6 +559,7 @@ export class MockTerminalGateway implements TerminalGateway {
|
||||
|
||||
export class MockProjectGateway implements ProjectGateway {
|
||||
private projects: Project[] = [];
|
||||
private contexts = new Map<string, string>();
|
||||
|
||||
async listProjects(): Promise<Project[]> {
|
||||
return [...this.projects];
|
||||
@ -550,6 +597,28 @@ export class MockProjectGateway implements ProjectGateway {
|
||||
}
|
||||
|
||||
async closeProject(): Promise<void> {}
|
||||
|
||||
async readProjectContext(projectId: string): Promise<string> {
|
||||
if (!this.projects.some((p) => p.id === projectId)) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `project ${projectId} not found`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
return this.contexts.get(projectId) ?? "";
|
||||
}
|
||||
|
||||
async updateProjectContext(projectId: string, content: string): Promise<void> {
|
||||
if (!this.projects.some((p) => p.id === projectId)) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `project ${projectId} not found`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
this.contexts.set(projectId, content);
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal per-project layout store entry. */
|
||||
@ -1321,6 +1390,80 @@ export class MockMemoryGateway implements MemoryGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory {@link EmbedderGateway} (L14 / lot C2). Seeds a curated set of
|
||||
* recommended ONNX engines (e5-small recommended) with both feature flags on by
|
||||
* default; tests can pass overrides to simulate a build where a strategy is not
|
||||
* compiled in.
|
||||
*/
|
||||
export class MockEmbedderGateway implements EmbedderGateway {
|
||||
private profiles = new Map<string, EmbedderProfile>();
|
||||
private engines: EmbedderEngines;
|
||||
|
||||
constructor(
|
||||
engines?: Partial<EmbedderEngines>,
|
||||
seedProfiles: EmbedderProfile[] = [],
|
||||
) {
|
||||
this.engines = {
|
||||
recommendedOnnx: [
|
||||
{
|
||||
id: "e5-small",
|
||||
displayName: "E5 Small (multilingual)",
|
||||
dimension: 384,
|
||||
approxSizeMb: 120,
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "bge-small",
|
||||
displayName: "BGE Small",
|
||||
dimension: 384,
|
||||
approxSizeMb: 130,
|
||||
recommended: false,
|
||||
},
|
||||
],
|
||||
ollamaDetected: false,
|
||||
onnxCachedModels: [],
|
||||
vectorHttpEnabled: true,
|
||||
vectorOnnxEnabled: true,
|
||||
...engines,
|
||||
};
|
||||
for (const p of seedProfiles) this.profiles.set(p.id, p);
|
||||
}
|
||||
|
||||
async listEmbedderProfiles(): Promise<EmbedderProfile[]> {
|
||||
return structuredClone([...this.profiles.values()]);
|
||||
}
|
||||
|
||||
async saveEmbedderProfile(
|
||||
profile: EmbedderProfile,
|
||||
): Promise<EmbedderProfile> {
|
||||
if (!profile.id.trim() || !profile.name.trim()) {
|
||||
const err: GatewayError = {
|
||||
code: "INVALID",
|
||||
message: "embedder profile id/name must not be empty",
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
if (profile.dimension <= 0) {
|
||||
const err: GatewayError = {
|
||||
code: "INVALID",
|
||||
message: "embedder profile dimension must be > 0",
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
this.profiles.set(profile.id, structuredClone(profile));
|
||||
return structuredClone(profile);
|
||||
}
|
||||
|
||||
async deleteEmbedderProfile(embedderId: string): Promise<void> {
|
||||
this.profiles.delete(embedderId);
|
||||
}
|
||||
|
||||
async describeEmbedderEngines(): Promise<EmbedderEngines> {
|
||||
return structuredClone(this.engines);
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the full set of mock gateways. */
|
||||
export function createMockGateways(): Gateways {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
@ -1336,6 +1479,7 @@ export function createMockGateways(): Gateways {
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
skill: new MockSkillGateway(agentGateway),
|
||||
memory: new MockMemoryGateway(),
|
||||
embedder: new MockEmbedderGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -12,9 +12,10 @@ import { createMockGateways, MockSystemGateway } from "./index";
|
||||
const gateways: Gateways = createMockGateways();
|
||||
|
||||
describe("createMockGateways", () => {
|
||||
it("exposes all eleven gateways", () => {
|
||||
it("exposes all twelve gateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"embedder",
|
||||
"git",
|
||||
"layout",
|
||||
"memory",
|
||||
|
||||
27
frontend/src/adapters/project.test.ts
Normal file
27
frontend/src/adapters/project.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invoke(...args),
|
||||
}));
|
||||
|
||||
import { TauriProjectGateway } from "./project";
|
||||
|
||||
describe("TauriProjectGateway invoke payloads", () => {
|
||||
beforeEach(() => invoke.mockReset().mockResolvedValue({}));
|
||||
|
||||
it("reads and updates the project context under .ideai", async () => {
|
||||
invoke.mockResolvedValueOnce("# shared");
|
||||
await expect(new TauriProjectGateway().readProjectContext("proj-1")).resolves.toBe(
|
||||
"# shared",
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith("read_project_context", {
|
||||
projectId: "proj-1",
|
||||
});
|
||||
|
||||
await new TauriProjectGateway().updateProjectContext("proj-1", "# next");
|
||||
expect(invoke).toHaveBeenCalledWith("update_project_context", {
|
||||
request: { projectId: "proj-1", content: "# next" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -27,4 +27,14 @@ export class TauriProjectGateway implements ProjectGateway {
|
||||
async closeProject(projectId: string): Promise<void> {
|
||||
await invoke("close_project", { projectId });
|
||||
}
|
||||
|
||||
readProjectContext(projectId: string): Promise<string> {
|
||||
return invoke<string>("read_project_context", { projectId });
|
||||
}
|
||||
|
||||
async updateProjectContext(projectId: string, content: string): Promise<void> {
|
||||
await invoke("update_project_context", {
|
||||
request: { projectId, content },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user