import { tool, type Tool } from "ai"; import { z } from "zod"; /** * Catalog of Tiptap editor commands exposed to the AI agent. * * Each entry pairs: * - an AI SDK tool definition (description + zod input schema) * - safety metadata used to build the system prompt dynamically * (category, selectionEffect, destructive) * * The frontend's agent executor dispatches on `name` and calls * `editor.chain()[command](args).run()`, so the catalog is the single * source of truth for what the agent can do. * * Inspired by `tiptap-apcore`'s Registry + AnnotationCatalog pattern, * but intentionally pared down to the ~15 commands that make sense for * a research article editor. */ type Category = "query" | "selection" | "format" | "content"; /** * How a command interacts with the current text selection. * * - `require`: must have a non-collapsed selection (mark toggles) * - `preserve`: selection survives execution (most mark toggles, alignment) * - `destroy`: cursor collapses after execution (insertContent) * - `none`: operates on the block at cursor, no selection needed */ type SelectionEffect = "require" | "preserve" | "destroy" | "none"; // `tool()` returns a strongly-typed Tool; storing them // in a homogeneous array requires the most permissive Tool generic. type AnyTool = Tool; export interface TiptapCommandEntry { name: string; category: Category; selectionEffect: SelectionEffect; destructive: boolean; tool: AnyTool; } // --------------------------------------------------------------------------- // Command entries // --------------------------------------------------------------------------- export const TIPTAP_COMMANDS: TiptapCommandEntry[] = [ // -- Selection --------------------------------------------------------- { name: "selectText", category: "selection", selectionEffect: "preserve", destructive: false, tool: tool({ description: "Find a text substring in the document and select it. Use this BEFORE applying " + "mark-level formatting commands (toggleBold, setLink, etc.). Returns { found, from, to } " + "so you can verify the match. When the same text appears multiple times, use `occurrence` " + "(1-based) or provide `contextBefore` / `contextAfter` to disambiguate.", inputSchema: z.object({ text: z.string().describe("The exact text to select (substring match)"), occurrence: z .number() .int() .min(1) .optional() .describe( "Which occurrence to select (1-based). Defaults to 1. Ignored if contextBefore/After are provided.", ), contextBefore: z .string() .optional() .describe("A few words appearing just before the target text, used to disambiguate."), contextAfter: z .string() .optional() .describe("A few words appearing just after the target text, used to disambiguate."), }), }), }, // -- Format: marks (require selection) -------------------------------- { name: "toggleBold", category: "format", selectionEffect: "require", destructive: false, tool: tool({ description: "Toggle bold on the current selection. MUST be called after selectText - has no effect without an active text range.", inputSchema: z.object({}), }), }, { name: "toggleItalic", category: "format", selectionEffect: "require", destructive: false, tool: tool({ description: "Toggle italic on the current selection. MUST be called after selectText - has no effect without an active text range.", inputSchema: z.object({}), }), }, { name: "toggleStrike", category: "format", selectionEffect: "require", destructive: false, tool: tool({ description: "Toggle strikethrough on the current selection. MUST be called after selectText.", inputSchema: z.object({}), }), }, { name: "toggleCode", category: "format", selectionEffect: "require", destructive: false, tool: tool({ description: "Toggle inline code formatting on the current selection. MUST be called after selectText.", inputSchema: z.object({}), }), }, { name: "setLink", category: "format", selectionEffect: "require", destructive: false, tool: tool({ description: "Turn the current selection into a hyperlink. MUST be called after selectText. " + "Only http(s)/mailto/tel/relative URLs are accepted.", inputSchema: z.object({ href: z.string().describe("Target URL (http/https/mailto/tel or relative)"), }), }), }, { name: "unsetLink", category: "format", selectionEffect: "require", destructive: false, tool: tool({ description: "Remove hyperlink formatting from the current selection.", inputSchema: z.object({}), }), }, // -- Format: node-level (no selection needed) ------------------------- { name: "toggleHeading", category: "format", selectionEffect: "none", destructive: false, tool: tool({ description: "Convert the block at the cursor to a heading (or back to a paragraph if already that level). " + "Operates on the containing block - no text selection needed.", inputSchema: z.object({ level: z.number().int().min(1).max(3).describe("Heading level (1, 2 or 3)"), }), }), }, { name: "setParagraph", category: "format", selectionEffect: "none", destructive: false, tool: tool({ description: "Convert the block at the cursor to a plain paragraph.", inputSchema: z.object({}), }), }, { name: "toggleBulletList", category: "format", selectionEffect: "none", destructive: false, tool: tool({ description: "Toggle the block at the cursor between a bullet list item and a paragraph.", inputSchema: z.object({}), }), }, { name: "toggleOrderedList", category: "format", selectionEffect: "none", destructive: false, tool: tool({ description: "Toggle the block at the cursor between an ordered (numbered) list item and a paragraph.", inputSchema: z.object({}), }), }, { name: "toggleBlockquote", category: "format", selectionEffect: "none", destructive: false, tool: tool({ description: "Toggle blockquote on the block at the cursor.", inputSchema: z.object({}), }), }, { name: "toggleCodeBlock", category: "format", selectionEffect: "none", destructive: false, tool: tool({ description: "Toggle code block on the block at the cursor. Use for multi-line code snippets.", inputSchema: z.object({ language: z .string() .optional() .describe("Language hint for syntax highlighting (e.g. 'python', 'typescript')"), }), }), }, ]; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** AI SDK tool definitions keyed by command name. Pass directly to streamText. */ export function buildTiptapTools(): Record { const out: Record = {}; for (const entry of TIPTAP_COMMANDS) { out[entry.name] = entry.tool; } return out; } /** Commands grouped by how they interact with the selection. */ export function classifyBySelectionEffect(): Record { const groups: Record = { require: [], preserve: [], destroy: [], none: [], }; for (const entry of TIPTAP_COMMANDS) { groups[entry.selectionEffect].push(entry.name); } return groups; } /** Commands grouped by category, used to build a concise command list in the prompt. */ export function groupByCategory(): Record { const groups: Record = { query: [], selection: [], format: [], content: [], }; for (const entry of TIPTAP_COMMANDS) { groups[entry.category].push(entry.name); } return groups; }