Spaces:
Sleeping
Sleeping
| 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, unknown>, | |
| ): string | null { | |
| // selectText is not a native Tiptap command. | |
| if (toolName === "selectText") { | |
| const result = handleSelectText(editor, args as Parameters<typeof handleSelectText>[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<string, () => 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<string>([ | |
| "selectText", | |
| "toggleBold", | |
| "toggleItalic", | |
| "toggleStrike", | |
| "toggleCode", | |
| "setLink", | |
| "unsetLink", | |
| "toggleHeading", | |
| "setParagraph", | |
| "toggleBulletList", | |
| "toggleOrderedList", | |
| "toggleBlockquote", | |
| "toggleCodeBlock", | |
| ]); | |