import type { Editor } from "@tiptap/core"; /** * Frontend executor for Tiptap commands issued by the AI agent. * * The backend exposes a catalog of commands (see `backend/src/agent/tiptap-catalog.ts`). * When the LLM calls one, `useAgentChat` receives the tool call and * dispatches it here. Most commands map directly to * `editor.chain().focus()[commandName](args).run()`; a few need * specific handling (selectText, setLink). * * Returns a short human-readable status string that is sent back to the * LLM as the tool result so it can plan its next step. * * Inspired by `tiptap-apcore`'s TiptapExecutor but intentionally tiny - * we only cover the commands declared in the catalog. */ const SAFE_URL_PROTOCOLS = /^(https?|mailto|tel):/i; const SAFE_URL_RELATIVE = /^[/#?]/; function isSafeUrl(url: string): boolean { const trimmed = url.trim(); return SAFE_URL_PROTOCOLS.test(trimmed) || SAFE_URL_RELATIVE.test(trimmed); } // --------------------------------------------------------------------------- // selectText: semantic selection by text content // --------------------------------------------------------------------------- /** * Extract all text from the ProseMirror document as a flat string, * keeping a mapping from string offsets to document positions. * * Duplicated from the logic inside `useAgentChat.findTextPosition` so * this module has no React dependency. */ function getDocTextWithPositions(editor: Editor): { text: string; map: number[] } { const chunks: string[] = []; const map: number[] = []; editor.state.doc.descendants((node, pos) => { if (node.isText && node.text) { for (let i = 0; i < node.text.length; i++) { map.push(pos + i); chunks.push(node.text[i]); } } else if (node.isBlock && chunks.length > 0) { map.push(pos); chunks.push("\n"); } }); return { text: chunks.join(""), map }; } interface SelectTextResult { found: boolean; from?: number; to?: number; } function handleSelectText( editor: Editor, args: { text: string; occurrence?: number; contextBefore?: string; contextAfter?: string; }, ): SelectTextResult { const { text, occurrence = 1, contextBefore, contextAfter } = args; if (!text) return { found: false }; const { text: docText, map } = getDocTextWithPositions(editor); // Collect all candidate indices const candidates: number[] = []; let startIdx = 0; while (true) { const idx = docText.indexOf(text, startIdx); if (idx === -1) break; candidates.push(idx); startIdx = idx + 1; } if (candidates.length === 0) return { found: false }; let chosenIdx: number; if (contextBefore || contextAfter) { let bestIdx = candidates[0]; let bestScore = -1; for (const idx of candidates) { let score = 0; if (contextBefore) { const before = docText.slice(Math.max(0, idx - contextBefore.length - 10), idx); if (before.includes(contextBefore)) score += 2; else if (before.toLowerCase().includes(contextBefore.toLowerCase())) score += 1; } if (contextAfter) { const after = docText.slice(idx + text.length, idx + text.length + contextAfter.length + 10); if (after.includes(contextAfter)) score += 2; else if (after.toLowerCase().includes(contextAfter.toLowerCase())) score += 1; } if (score > bestScore) { bestScore = score; bestIdx = idx; } } chosenIdx = bestIdx; } else { const occIdx = Math.max(0, Math.min(occurrence - 1, candidates.length - 1)); chosenIdx = candidates[occIdx]; } const from = map[chosenIdx]; const to = map[chosenIdx + text.length - 1] + 1; editor.chain().focus().setTextSelection({ from, to }).run(); return { found: true, from, to }; } // --------------------------------------------------------------------------- // Catalog command dispatch // --------------------------------------------------------------------------- /** * Tiptap commands that take no arguments. Called as * `editor.chain().focus()[name]().run()`. */ const EMPTY_ARG_COMMANDS = new Set([ "toggleBold", "toggleItalic", "toggleStrike", "toggleCode", "unsetLink", "setParagraph", "toggleBulletList", "toggleOrderedList", "toggleBlockquote", ]); /** * Map a tool call to an `editor.chain()` invocation. * * Returns null when the tool name is not in the Tiptap catalog (so the * caller can fall back to its own handling for applyDiff, frontmatter, etc.). */ export function executeTiptapCommand( editor: Editor, toolName: string, args: Record, ): string | null { // selectText is not a native Tiptap command. if (toolName === "selectText") { const result = handleSelectText(editor, args as Parameters[1]); if (!result.found) { return `selectText: text not found in the document`; } return `selectText: selected range ${result.from}-${result.to}`; } // Empty-arg commands - just call them directly. if (EMPTY_ARG_COMMANDS.has(toolName)) { const chain = editor.chain().focus(); const fn = (chain as unknown as Record typeof chain>)[toolName]; if (typeof fn !== "function") { return `Unknown editor command: ${toolName}`; } const success = fn.call(chain).run(); return success ? `${toolName} applied` : `${toolName} failed (selection state?)`; } // Commands with specific argument shapes. switch (toolName) { case "toggleHeading": { const level = (args as { level?: number }).level; if (!level || level < 1 || level > 3) return "toggleHeading: invalid level"; const ok = editor .chain() .focus() .toggleHeading({ level: level as 1 | 2 | 3 }) .run(); return ok ? `toggleHeading (level ${level}) applied` : "toggleHeading failed"; } case "toggleCodeBlock": { const language = (args as { language?: string }).language; const chain = editor.chain().focus(); const ok = language ? chain.toggleCodeBlock({ language }).run() : chain.toggleCodeBlock().run(); return ok ? "toggleCodeBlock applied" : "toggleCodeBlock failed"; } case "setLink": { const href = (args as { href?: string }).href; if (!href || !isSafeUrl(href)) return `setLink: unsafe or missing URL`; const ok = editor.chain().focus().setLink({ href }).run(); return ok ? `setLink applied (${href})` : "setLink failed (is text selected?)"; } default: return null; } } /** Tool names handled by `executeTiptapCommand`, useful for dispatch. */ export const TIPTAP_TOOL_NAMES = new Set([ "selectText", "toggleBold", "toggleItalic", "toggleStrike", "toggleCode", "setLink", "unsetLink", "toggleHeading", "setParagraph", "toggleBulletList", "toggleOrderedList", "toggleBlockquote", "toggleCodeBlock", ]);