diff --git a/src/app/api/ask/memories/[id]/route.test.ts b/src/app/api/ask/memories/[id]/route.test.ts new file mode 100644 index 0000000..0268957 --- /dev/null +++ b/src/app/api/ask/memories/[id]/route.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const resolveActorMock = vi.fn(); +vi.mock("@/lib/agent/context", () => ({ + resolveActor: (...args: unknown[]) => resolveActorMock(...args), +})); + +const forgetMock = vi.fn(); +vi.mock("@/lib/agent/memory", () => ({ + forget: (...args: unknown[]) => forgetMock(...args), +})); + +import { DELETE } from "./route"; + +const params = Promise.resolve({ id: "m1" }); + +describe("DELETE /api/ask/memories/[id]", () => { + beforeEach(() => { + resolveActorMock.mockReset(); + forgetMock.mockReset(); + resolveActorMock.mockResolvedValue({ id: "u1", name: "Dashboard" }); + }); + + it("forgets a memory", async () => { + forgetMock.mockResolvedValue({ ok: true }); + const res = await DELETE(new Request("http://x", { method: "DELETE" }), { params }); + expect(await res.json()).toEqual({ ok: true }); + expect(forgetMock).toHaveBeenCalledWith({ actor: { id: "u1", name: "Dashboard" } }, { id: "m1" }); + }); + + it("400s with the reason when forget refuses", async () => { + forgetMock.mockResolvedValue({ ok: false, error: "Only the owner can forget a personal memory." }); + const res = await DELETE(new Request("http://x", { method: "DELETE" }), { params }); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain("owner"); + }); +}); diff --git a/src/app/api/ask/memories/[id]/route.ts b/src/app/api/ask/memories/[id]/route.ts new file mode 100644 index 0000000..0084b78 --- /dev/null +++ b/src/app/api/ask/memories/[id]/route.ts @@ -0,0 +1,19 @@ +/** DELETE /api/ask/memories/[id] — forget one (kept as status "undone"). */ +import { resolveActor, type AgentContext } from "@/lib/agent/context"; +import { forget } from "@/lib/agent/memory"; + +export const dynamic = "force-dynamic"; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const actor = await resolveActor(request); + if (actor instanceof Response) return actor; + const { id } = await params; + + const ctx: AgentContext = { actor }; + const result = await forget(ctx, { id }); + if (!result.ok) return Response.json({ error: result.error }, { status: 400 }); + return Response.json({ ok: true }); +} diff --git a/src/app/api/ask/memories/route.test.ts b/src/app/api/ask/memories/route.test.ts new file mode 100644 index 0000000..655cee2 --- /dev/null +++ b/src/app/api/ask/memories/route.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const resolveActorMock = vi.fn(); +vi.mock("@/lib/agent/context", () => ({ + resolveActor: (...args: unknown[]) => resolveActorMock(...args), +})); + +const listMemoriesMock = vi.fn(); +const rememberMock = vi.fn(); +vi.mock("@/lib/agent/memory", () => ({ + listMemories: (...args: unknown[]) => listMemoriesMock(...args), + remember: (...args: unknown[]) => rememberMock(...args), +})); + +import { GET, POST } from "./route"; + +function req(method: string, body?: unknown): Request { + return new Request("http://x/api/ask/memories", { + method, + headers: { "Content-Type": "application/json" }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); +} + +describe("/api/ask/memories", () => { + beforeEach(() => { + resolveActorMock.mockReset(); + listMemoriesMock.mockReset(); + rememberMock.mockReset(); + resolveActorMock.mockResolvedValue({ id: "u1", name: "Dashboard" }); + }); + + it("GET requires an actor", async () => { + resolveActorMock.mockResolvedValue(Response.json({ error: "Pick who is using Ask" }, { status: 400 })); + const res = await GET(req("GET")); + expect(res.status).toBe(400); + }); + + it("GET lists memories for the actor", async () => { + listMemoriesMock.mockResolvedValue([{ id: "m1", text: "hi" }]); + const res = await GET(req("GET")); + expect(await res.json()).toEqual({ memories: [{ id: "m1", text: "hi" }] }); + expect(listMemoriesMock).toHaveBeenCalledWith({ actor: { id: "u1", name: "Dashboard" } }); + }); + + it("POST adds a memory manually", async () => { + rememberMock.mockResolvedValue({ id: "m2", deduped: false }); + const res = await POST(req("POST", { text: "hello", shared: true })); + expect(await res.json()).toEqual({ id: "m2", deduped: false }); + expect(rememberMock.mock.calls[0][1]).toMatchObject({ text: "hello", shared: true }); + }); + + it("POST rejects empty text", async () => { + const res = await POST(req("POST", { text: " " })); + expect(res.status).toBe(400); + expect(rememberMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/ask/memories/route.ts b/src/app/api/ask/memories/route.ts new file mode 100644 index 0000000..b33500a --- /dev/null +++ b/src/app/api/ask/memories/route.ts @@ -0,0 +1,41 @@ +/** + * GET /api/ask/memories — the actor's active memories plus active shared + * ones, for the chat UI's "Memory" drawer (a plain REST view of what + * src/lib/agent/memory.ts already stores — the drawer doesn't need to + * go through the model to read or add a memory by hand). + * POST /api/ask/memories — add one manually: { text, site?, shared? }. + */ +import { resolveActor, type AgentContext } from "@/lib/agent/context"; +import { remember, listMemories } from "@/lib/agent/memory"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const actor = await resolveActor(request); + if (actor instanceof Response) return actor; + const ctx: AgentContext = { actor }; + const memories = await listMemories(ctx); + return Response.json({ memories }); +} + +export async function POST(request: Request) { + const actor = await resolveActor(request); + if (actor instanceof Response) return actor; + + let body: Record; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Body must be JSON" }, { status: 400 }); + } + const text = typeof body.text === "string" ? body.text.trim() : ""; + if (!text) return Response.json({ error: "text is required" }, { status: 400 }); + + const ctx: AgentContext = { actor }; + const result = await remember(ctx, { + text, + site: typeof body.site === "string" ? body.site : undefined, + shared: body.shared === true, + }); + return Response.json(result); +} diff --git a/src/app/api/chat/route.test.ts b/src/app/api/chat/route.test.ts index 6f0c8ad..7fc0996 100644 --- a/src/app/api/chat/route.test.ts +++ b/src/app/api/chat/route.test.ts @@ -71,6 +71,15 @@ vi.mock("@/lib/chat/threads", () => ({ appendMessage: (...args: unknown[]) => appendMessageMock(...args), })); +const listMemoriesMock = vi.fn<(...args: unknown[]) => Promise<{ text: string }[]>>( + async () => [], +); +vi.mock("@/lib/agent/memory", () => ({ + listMemories: (...args: unknown[]) => listMemoriesMock(...args), + formatMemoriesForPrompt: (memories: { text: string }[]) => + memories.map((m) => `- ${m.text}`).join("\n"), +})); + describe("POST /api/chat", () => { it("streams chart specs to the browser and only ids/titles to the model", async () => { const res = await POST( @@ -113,6 +122,18 @@ describe("POST /api/chat — threaded", () => { resolveActorMock.mockResolvedValue({ id: "u1", name: "Dashboard" }); loadThreadEntriesMock.mockResolvedValue([]); appendMessageMock.mockResolvedValue("m1"); + listMemoriesMock.mockClear(); + listMemoriesMock.mockResolvedValue([]); + }); + + it("embeds the actor's memories in the system prompt and offers the remember tool", async () => { + createThreadMock.mockResolvedValue({ id: "t9", title: "Report" }); + listMemoriesMock.mockResolvedValue([{ text: "The Hills works 45-hour weeks" }]); + await POST(threadedRequest({ message: "How many hours does The Hills work?" })); + + const last = seen[seen.length - 1]; + expect(last.system).toContain("The Hills works 45-hour weeks"); + expect(last.tools.map((t) => t.name)).toContain("remember"); }); it("rejects a request with no x-ask-user (via resolveActor)", async () => { diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index ae876c1..acbc718 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -16,10 +16,13 @@ * - Legacy: `{ history }` with no `threadId` — kept working for one * release so a tab open across a deploy doesn't break. Not persisted. */ -import { tools } from "@/lib/agent/tools"; +import { tools, type AgentTool } from "@/lib/agent/tools"; +import { actionTools } from "@/lib/agent/tools-actions"; +import { listMemories, formatMemoriesForPrompt } from "@/lib/agent/memory"; import { extractCharts } from "@/lib/reports/chart-spec"; import type { ChartSpec } from "@/lib/reports/chart-spec"; import { resolveActor } from "@/lib/agent/context"; +import type { AgentContext } from "@/lib/agent/context"; import { autoTitle, createThread, @@ -41,8 +44,6 @@ import { export const dynamic = "force-dynamic"; -const toolByName = new Map(tools.map((t) => [t.name, t])); - /** * Coerce whatever the browser posted into well-formed ChatEntry values. * @@ -103,10 +104,15 @@ function streamAnswer(args: { threadId?: string; ownerId?: string; threadTitle?: string; + system?: string; + extraTools?: AgentTool[]; }) { const { history, threadId, ownerId, threadTitle } = args; const provider = getProvider(); - const toolDefs = toChatToolDefs(tools); + const allTools = [...tools, ...(args.extraTools ?? [])]; + const toolByName = new Map(allTools.map((t) => [t.name, t])); + const toolDefs = toChatToolDefs(allTools); + const system = args.system ?? buildSystemPrompt(new Date()); const encoder = new TextEncoder(); const stream = new ReadableStream({ @@ -129,7 +135,7 @@ function streamAnswer(args: { provider, history, tools: toolDefs, - system: buildSystemPrompt(new Date()), + system, onTextDelta: (delta) => { lastText += delta; send("delta", { text: delta }); @@ -264,5 +270,23 @@ export async function POST(request: Request) { ...(body.attachmentIds ? { attachmentIds: body.attachmentIds } : {}), }); - return streamAnswer({ history, threadId, ownerId: actor.id, threadTitle }); + const ctx: AgentContext = { actor, threadId }; + // Memories and action tools both need the actor — the legacy path above + // has none, so it gets neither, matching its pre-threads behaviour. + let memoriesBlock = ""; + try { + memoriesBlock = formatMemoriesForPrompt(await listMemories(ctx)); + } catch { + // ask_log may not exist yet (schema not applied) — answer without + // memories rather than failing the whole turn. + } + + return streamAnswer({ + history, + threadId, + ownerId: actor.id, + threadTitle, + system: buildSystemPrompt(new Date(), memoriesBlock), + extraTools: actionTools(ctx), + }); } diff --git a/src/app/dashboard/chat/chat-view.tsx b/src/app/dashboard/chat/chat-view.tsx index e287944..d75b71c 100644 --- a/src/app/dashboard/chat/chat-view.tsx +++ b/src/app/dashboard/chat/chat-view.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { Suspense } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { ArrowUp, Camera, Database, Menu, Paperclip, Plus, Sparkles } from "lucide-react"; +import { ArrowUp, Brain, Camera, Database, Menu, Paperclip, Plus, Sparkles, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { AnswerMarkdown } from "./answer-markdown"; import { UploadCard, type UploadState } from "./upload-card"; @@ -39,6 +39,14 @@ interface ThreadSummary { last_message_at?: string; } +interface UiMemory { + id: string; + text: string; + shared?: boolean; + site?: string; + owner?: string; +} + const ACTOR_STORAGE_KEY = "mega-ask-user"; function loadStoredActor(): Actor | null { @@ -180,6 +188,74 @@ function ChatViewInner() { } }, []); + // ---- Memory drawer ----------------------------------------------------- + const [memoryOpen, setMemoryOpen] = React.useState(false); + const [memories, setMemories] = React.useState([]); + const [memoryText, setMemoryText] = React.useState(""); + const [memoryShared, setMemoryShared] = React.useState(false); + + const refreshMemories = React.useCallback(async (forActor: Actor) => { + try { + const res = await fetch("/api/ask/memories", { headers: { "x-ask-user": forActor.id } }); + if (!res.ok) return; + const data = await res.json(); + setMemories(data.memories ?? []); + } catch { + /* the drawer just stays as-is */ + } + }, []); + + React.useEffect(() => { + if (!memoryOpen || !actor) return; + // Inlined (rather than calling refreshMemories) so the setState below + // runs inside the fetch's async continuation, not synchronously in + // the effect body — see the identical note on the threads effect. + let cancelled = false; + fetch("/api/ask/memories", { headers: { "x-ask-user": actor.id } }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!cancelled && data) setMemories(data.memories ?? []); + }) + .catch(() => { + /* the drawer just stays as-is */ + }); + return () => { + cancelled = true; + }; + }, [memoryOpen, actor]); + + const addMemory = React.useCallback(async () => { + const text = memoryText.trim(); + if (!text || !actor) return; + setMemoryText(""); + try { + await fetch("/api/ask/memories", { + method: "POST", + headers: { "Content-Type": "application/json", "x-ask-user": actor.id }, + body: JSON.stringify({ text, shared: memoryShared }), + }); + await refreshMemories(actor); + } catch { + /* best-effort */ + } + }, [memoryText, memoryShared, actor, refreshMemories]); + + const deleteMemory = React.useCallback( + async (id: string) => { + if (!actor) return; + setMemories((prev) => prev.filter((m) => m.id !== id)); + try { + await fetch(`/api/ask/memories/${id}`, { + method: "DELETE", + headers: { "x-ask-user": actor.id }, + }); + } catch { + /* best-effort */ + } + }, + [actor], + ); + // ---- Threads: list, load, switch -------------------------------------- const [threads, setThreads] = React.useState([]); const [activeThreadId, setActiveThreadId] = React.useState(null); @@ -802,6 +878,17 @@ function ChatViewInner() { )} + {actor && ( + + )}
@@ -1109,6 +1196,86 @@ function ChatViewInner() {

+ + {memoryOpen && ( + <> + + + +
+