From abdf9846efa834005f24c7bf757d6b6edc2ecc67 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sun, 11 Oct 2026 16:13:11 +0800 Subject: [PATCH] fix(ai): support poster queries and complete note operations --- apps/api/src/companion-agent-tools.ts | 5 ++++ apps/api/src/companion-prepare.ts | 2 +- apps/api/src/companion-tool-actions.test.mjs | 23 ++++++++++++++++++ apps/api/src/mcp-management-tools.test.mjs | 25 ++++++++++++++++++++ apps/api/src/mcp-tool-service.ts | 4 ++++ apps/api/src/mcp-tools.ts | 8 ++++--- apps/api/src/memo-list-service.ts | 2 ++ apps/api/src/memo-service.ts | 21 +++++++++++----- apps/desktop/src/main/acp-host.mjs | 2 +- 9 files changed, 81 insertions(+), 11 deletions(-) diff --git a/apps/api/src/companion-agent-tools.ts b/apps/api/src/companion-agent-tools.ts index 523edb57..0074fe6d 100644 --- a/apps/api/src/companion-agent-tools.ts +++ b/apps/api/src/companion-agent-tools.ts @@ -492,6 +492,10 @@ export function createCompanionTools(args: { db: DatabaseAdapter; scope: Compani message: "This is an editable diagram. Change it with update_diagram, not update_memo.", }); } + if (memo.poster) { + return done({ id: memo.id, title: memo.title, notebookId: memo.notebookId, tags: memo.tags, revision: memo.revision, + poster: true, message: "This is an editable poster. Read its canvas with get_poster and edit with update_poster; do not decode or replace Markdown." }); + } const content = takeNoteText(memo.contentMarkdown, 8000); if (content.length === memo.contentMarkdown.length) inspected.set(memo.id, memo.revision); else inspected.delete(memo.id); return done({ id: memo.id, title: memo.title, notebookId: memo.notebookId, tags: memo.tags, revision: memo.revision, @@ -517,6 +521,7 @@ export function createCompanionTools(args: { db: DatabaseAdapter; scope: Compani notebookId: memo.notebookId, notebookName: await notebookName(memo.notebookId), tags: memo.tags, + poster: memo.poster, createdAt: memo.createdAt, updatedAt: memo.updatedAt, excerpt: takeNoteText(memo.excerpt, 180), diff --git a/apps/api/src/companion-prepare.ts b/apps/api/src/companion-prepare.ts index 940b85a4..560dabfd 100644 --- a/apps/api/src/companion-prepare.ts +++ b/apps/api/src/companion-prepare.ts @@ -50,7 +50,7 @@ When the user asks for a mind map, 思维导图, flowchart, 流程图, architect When the user asks to generate a poster or 海报笔记, call create_poster_memo with concise copy. It creates the actual editable poster type. Never substitute poster-style Markdown, manually encode source, or search local files for the format. Report tool failure honestly. If the user did not name a notebook, omit notebookId. Reply with the new note link. When the user asks for an infographic, 信息图, 資訊圖, or インフォグラフィック, call create_infographic_memo. Do not use create_diagram_memo, create_memo, Markdown, or Mermaid for that request. A share, proportion, or 占比 is a pie infographic: template chart-pie-donut-plain-text and numeric data.values, not a mind map. If the user did not supply the figures, say in data.desc that they are illustrative and are not an official disclosure. If the user did not name a notebook, omit notebookId. Reply with the new note link. When the user asks for a 多维表格, 多維表格, or structured table, call create_table_memo with explicit fields. Do not use create_memo or a Markdown table. If no records were supplied or requested, leave it empty. If records were requested, call get_table_records for the new table's field IDs and revision before add_table_record. Omit notebookId when no notebook was named. Reply with the new note link. -To change a poster/海报, call get_poster then update_poster. Edit only requested text, colors, typography or positions; preserve other elements and locked elements. Never replace poster content with update_memo. +To find or list posters/海报笔记, call search_memos or list_memos with poster=true; the type is not a title keyword. To read a poster's canvas, call get_poster. To change a poster/海报, call get_poster then update_poster. Edit only requested text, colors, typography or positions; preserve other elements and locked elements. Never replace poster content with update_memo. To delete a poster, use trash_memos with exact IDs from the query results; it goes to the recycle bin and can be restored with restore_memos. To change an existing diagram, call get_diagram then update_diagram. Do not create a second diagram unless the user asked for a new note. Do not use update_memo or Mermaid for diagrams. A structured table cannot be replaced with update_memo. Use get_table_records to read its structure and records. Do not rewrite its Markdown. You can list, create, update, use, and delete note templates, and list, create, update, and delete AI instructions. use_note_template creates a memo immediately. You cannot empty the trash, share notes, or upload files. Do not claim those tools exist. diff --git a/apps/api/src/companion-tool-actions.test.mjs b/apps/api/src/companion-tool-actions.test.mjs index d116aabf..b3e673c2 100644 --- a/apps/api/src/companion-tool-actions.test.mjs +++ b/apps/api/src/companion-tool-actions.test.mjs @@ -48,6 +48,29 @@ async function setup() { } describe("shared companion MCP adapter", () => { + test("poster CRUD reuses scoped queries, guarded edits, recoverable trash and restoration", async () => { + const f = await setup(); + const tools = createCompanionTools({ ...f, scope, signal: new AbortController().signal, assertActive: async () => {}, sources: [] }); + const created = await tools.create_poster_memo.execute({ notebookId: "ideas", title: "Energy poster", heading: "Protect your energy" }); + const listed = await tools.list_memos.execute({ poster: true }); + expect(listed.memos.map(memo => memo.id)).toContain(created.id); + expect(listed.memos.find(memo => memo.id === created.id).poster).toBe(true); + expect(await tools.get_memo.execute({ memoId: created.id })).toMatchObject({ poster: true, message: expect.stringContaining("get_poster") }); + await tools.get_poster.execute({ memoId: created.id }); + const poster = await getMemoDetail(f.db, scope.workspaceId, created.id); + const heading = parsePosterDocument(poster.contentMarkdown).elements.find(element => element.role === "title"); + await tools.update_poster.execute({ memoId: created.id, expectedRevision: poster.revision, edits: [{ id: heading.id, patch: { text: "Focus on what matters" } }] }); + expect(parsePosterDocument((await getMemoDetail(f.db, scope.workspaceId, created.id)).contentMarkdown).copy.title).toBe("Focus on what matters"); + await tools.trash_memos.execute({ memoIds: [created.id], dryRun: true }); + expect(await getMemoDetail(f.db, scope.workspaceId, created.id)).not.toBeNull(); + await tools.trash_memos.execute({ memoIds: [created.id] }); + expect(await getMemoDetail(f.db, scope.workspaceId, created.id)).toBeNull(); + expect((await tools.search_memos.execute({ poster: true })).memos.map(memo => memo.id)).not.toContain(created.id); + const trashed = await tools.list_memos.execute({ poster: true, includeDeleted: true }); + expect(trashed.memos.map(memo => memo.id)).toContain(created.id); + await tools.restore_memos.execute({ memoIds: [created.id] }); + expect(parsePosterDocument((await getMemoDetail(f.db, scope.workspaceId, created.id)).contentMarkdown).copy.title).toBe("Focus on what matters"); + }); test("reuses the exact reviewed MCP definitions, with no administration or upload tools", () => { expect(COMPANION_MCP_TOOLS).toHaveLength(50); for (const definition of COMPANION_MCP_TOOLS) expect(MCP_TOOLS.includes(definition)).toBe(true); diff --git a/apps/api/src/mcp-management-tools.test.mjs b/apps/api/src/mcp-management-tools.test.mjs index cd58ee40..1164bf01 100644 --- a/apps/api/src/mcp-management-tools.test.mjs +++ b/apps/api/src/mcp-management-tools.test.mjs @@ -72,6 +72,31 @@ const createFixture = (scopes = ["read:memos", "write:memos"]) => { }; describe("MCP template and AI instruction management", () => { + test("poster queries filter before pagination and include existing posters without matching prose titles", async () => { + const { sqlite, auth, context } = createFixture(); + sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)").run("nb_posters", "ws_mcp", "Posters"); + const create = input => callMcpTool(context, auth, "create_memo", { notebookId: "nb_posters", ...input }); + const first = await create({ title: "精力管理", contentMarkdown: serializePosterDocument(createPosterDocument("精力管理")) }); + const second = await callMcpTool(context, auth, "create_poster_memo", { notebookId: "nb_posters", title: "精力分配", heading: "保护精力" }); + const prose = await create({ title: "精力管理海报说明", contentMarkdown: "这只是普通笔记" }); + const page = await callMcpTool(context, auth, "list_memos", { poster: true, limit: 1 }); + expect(page.memos).toHaveLength(1); + expect(page.memos[0].poster).toBe(true); + expect(page.hasMore).toBe(true); + const next = await callMcpTool(context, auth, "list_memos", { poster: true, limit: 1, offset: page.nextOffset }); + expect(new Set([page.memos[0].id, next.memos[0].id])).toEqual(new Set([first.memo.id, second.memo.id])); + expect(next.hasMore).toBe(false); + const full = await callMcpTool(context, auth, "list_memos", { poster: true, includeContent: true }); + expect(full.memos.every(memo => parsePosterDocument(memo.contentMarkdown))).toBe(true); + const found = await callMcpTool(context, auth, "search_memos", { poster: true, query: "精力" }); + expect(new Set(found.memos.map(memo => memo.id))).toEqual(new Set([first.memo.id, second.memo.id])); + expect(found.memos.every(memo => memo.poster)).toBe(true); + const excluded = await callMcpTool(context, auth, "search_memos", { poster: false }); + expect(excluded.memos.map(memo => memo.id)).toEqual([prose.memo.id]); + expect((await callMcpTool(context, auth, "list_memos", { poster: false })).memos.map(memo => memo.id)).toEqual([prose.memo.id]); + expect((await callMcpTool(context, { ...auth, workspaceId: "ws_other" }, "search_memos", { poster: true })).memos).toEqual([]); + expect((await callMcpTool(context, { ...auth, workspaceId: "ws_other" }, "list_memos", { poster: true })).memos).toEqual([]); + }); test("creates an actual editable poster from copy without encoded source", async () => { const { sqlite, auth, context } = createFixture(); sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)").run("nb_posters", "ws_mcp", "Posters"); diff --git a/apps/api/src/mcp-tool-service.ts b/apps/api/src/mcp-tool-service.ts index 506d4752..b654a33f 100644 --- a/apps/api/src/mcp-tool-service.ts +++ b/apps/api/src/mcp-tool-service.ts @@ -164,6 +164,7 @@ export type McpToolDependencies = { offset: number; includeContent: boolean; includeDeleted: boolean; + poster?: boolean | null; }, ) => Promise; mergeMemosRecord: ( @@ -200,6 +201,7 @@ export type McpToolDependencies = { updatedBefore?: string | null; isPinned?: boolean | null; hasResources?: boolean | null; + poster?: boolean | null; limit: number; }, ) => Promise; @@ -282,6 +284,7 @@ export const callMcpTool = async ( updatedAfter: getOptionalString(args.updatedAfter), updatedBefore: getOptionalString(args.updatedBefore), isPinned: typeof args.isPinned === "boolean" ? args.isPinned : null, + poster: typeof args.poster === "boolean" ? args.poster : null, hasResources: typeof args.hasResources === "boolean" ? args.hasResources : null, limit: clampNumber(Number(args.limit ?? 20), 1, 50), }), @@ -296,6 +299,7 @@ export const callMcpTool = async ( offset: clampNumber(Number(args.offset ?? 0), 0, 100_000), includeContent: args.includeContent === true, includeDeleted: args.includeDeleted === true, + poster: typeof args.poster === "boolean" ? args.poster : null, }); } case "get_memo": { diff --git a/apps/api/src/mcp-tools.ts b/apps/api/src/mcp-tools.ts index 56f04b48..9f76f048 100644 --- a/apps/api/src/mcp-tools.ts +++ b/apps/api/src/mcp-tools.ts @@ -156,7 +156,7 @@ const MCP_TOOL_DEFINITIONS = [ }, { name: "search_memos", - description: "Search active EdgeEver memos by text, tag, notebook, time range, pin state, or resource presence. query is optional. For recently created or added notes, pass createdAfter and omit query; do not put this week/最近/新增 in query. Time bounds accept YYYY-MM-DD or ISO date-time.", + description: "Search active EdgeEver memos by text, tag, notebook, time range, pin state, resource presence, or poster type. Use poster=true to find editable poster notes (海报笔记); do not search for the word 海报 to identify the type. query is optional. For recently created or added notes, pass createdAfter and omit query; do not put this week/最近/新增 in query. Time bounds accept YYYY-MM-DD or ISO date-time.", inputSchema: { type: "object", additionalProperties: false, @@ -170,13 +170,14 @@ const MCP_TOOL_DEFINITIONS = [ updatedBefore: { type: "string", description: "ISO 8601 date (YYYY-MM-DD) or date-time. Date-only means 23:59:59.999Z." }, isPinned: { type: "boolean" }, hasResources: { type: "boolean" }, + poster: { type: "boolean", description: "true: only editable posters; false: exclude posters; omitted: all types." }, limit: { type: "integer", minimum: 1, maximum: 50 }, }, }, }, { name: "list_memos", - description: "List EdgeEver memos with pagination. Use includeContent when full Markdown is needed.", + description: "List EdgeEver memos with pagination. Use poster=true to list only editable poster notes (海报笔记), false to exclude them. Use includeContent when full Markdown is needed.", inputSchema: { type: "object", additionalProperties: false, @@ -186,6 +187,7 @@ const MCP_TOOL_DEFINITIONS = [ offset: { type: "integer", minimum: 0 }, includeContent: { type: "boolean" }, includeDeleted: { type: "boolean" }, + poster: { type: "boolean", description: "Filter by editable poster type before pagination." }, }, }, }, @@ -538,7 +540,7 @@ const MCP_TOOL_DEFINITIONS = [ }, { name: "trash_memos", - description: "Move one or more active memos to trash. Use dryRun to preview affected memos.", + description: "Move one or more active memos, including editable posters, to trash. This is recoverable through restore_memos. Find the exact IDs with search_memos or list_memos (poster=true for posters); never guess IDs. Use dryRun to preview affected memos.", inputSchema: { type: "object", required: ["memoIds"], diff --git a/apps/api/src/memo-list-service.ts b/apps/api/src/memo-list-service.ts index f468d36d..b274f3d3 100644 --- a/apps/api/src/memo-list-service.ts +++ b/apps/api/src/memo-list-service.ts @@ -18,6 +18,7 @@ export type MemoSummaryRow = { title: string | null; excerpt: string; content_text?: string | null; + is_poster?: number; content_markdown?: string | null; tags_json: string; is_pinned: number; @@ -75,6 +76,7 @@ export const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({ ...getDiagramSummary(row.content_markdown), ...getInfographicSummary(row.content_markdown), ...getPosterSummary(row.content_markdown), + ...(row.is_poster !== undefined ? { poster: Boolean(row.is_poster) } : {}), ...getTableSummary(row.content_markdown), tags: parseJsonArray(row.tags_json), isPinned: Boolean(row.is_pinned), diff --git a/apps/api/src/memo-service.ts b/apps/api/src/memo-service.ts index 371c362b..a1de01a9 100644 --- a/apps/api/src/memo-service.ts +++ b/apps/api/src/memo-service.ts @@ -169,6 +169,7 @@ export const searchMemoSummaries = async ( updatedBefore?: string | null; isPinned?: boolean | null; hasResources?: boolean | null; + poster?: boolean | null; limit: number; } ): Promise => { @@ -226,6 +227,11 @@ export const searchMemoSummaries = async ( ); } + if (typeof options.poster === "boolean") { + filters.push("(instr(c.content_markdown, '