import { tool } from "ai"; import { z } from "zod"; import { buildTiptapTools } from "./tiptap-catalog.js"; /** * Tools exposed to the LLM. These return instructions that the frontend * will execute on the TipTap editor instance. The backend never touches * the document directly - it only produces "edit commands" that are * streamed back and applied client-side. * * All edits from a single agent turn are grouped into one Yjs undo step, * so the user can revert an entire agent action with a single Cmd+Z. * * The editor commands come from a static catalog (`tiptap-catalog.ts`) * that also drives the system prompt's "Selection State Machine" section. * The domain-specific tools (applyDiff, replaceSelection, insertAtCursor, * frontmatter) are defined inline here since they don't map 1:1 to a * native Tiptap command. */ const customEditorTools = { replaceSelection: tool({ description: "Replace the currently selected text in the editor with new content. " + "Use ONLY when text is selected and the user asks to rewrite, fix, or rephrase it.", inputSchema: z.object({ newText: z .string() .describe("The replacement text (plain text or markdown)"), }), }), insertAtCursor: tool({ description: "Insert text at the current cursor position (does not replace anything). " + "Use when the user asks to add or generate new content.", inputSchema: z.object({ text: z.string().describe("The text to insert (plain text or markdown)"), }), }), applyDiff: tool({ description: "The PRIMARY tool for editing prose. Locates a piece of text in the document using " + "context and replaces it. Provide surrounding context to disambiguate when the " + "same text appears multiple times. For multiple edits, call this tool multiple " + "times in a single response. Each call should be a minimal, surgical edit.", inputSchema: z.object({ contextBefore: z .string() .describe( "A few words of text that appear immediately BEFORE the content to delete. " + "Used to locate the correct position. Can be empty if at the start of the document.", ), contentToDelete: z .string() .describe( "The exact verbatim text to remove from the document. Must match perfectly.", ), contentToInsert: z .string() .describe( "The new text to insert in place of the deleted text. " + "Can be empty to just delete without replacing.", ), contextAfter: z .string() .describe( "A few words of text that appear immediately AFTER the content to delete. " + "Used to confirm the correct position. Can be empty if at the end.", ), }), }), }; const frontmatterTools = { updateFrontmatter: tool({ description: "Update article metadata (frontmatter). Use when the user asks to change the title, " + "subtitle, authors, affiliations, publication date, DOI, template, or any article setting. " + "Only include the fields you want to change - omitted fields are left unchanged.", inputSchema: z.object({ title: z.string().optional().describe("Article title"), subtitle: z.string().optional().describe("Subtitle"), description: z.string().optional().describe("Short SEO description"), published: z.string().optional().describe('Publication date, e.g. "Apr. 04, 2026"'), template: z.enum(["article", "paper"]).optional().describe("Layout template"), doi: z.string().optional().describe("DOI identifier"), banner: z.string().optional().describe("Banner embed filename"), showPdf: z.boolean().optional().describe("Show PDF download button"), tableOfContentsAutoCollapse: z.boolean().optional().describe("Auto-collapse TOC"), licence: z.string().optional().describe("Licence text (HTML allowed)"), pdfProOnly: z.boolean().optional().describe("Gate PDF behind HF Pro"), seoThumbImage: z.string().optional().describe("Custom OG image URL"), }), }), addAuthor: tool({ description: "Add an author to the article. Provide the author name and optionally a URL " + "and affiliation indices (1-based). If the affiliation doesn't exist yet, " + "provide newAffiliationName to create it.", inputSchema: z.object({ name: z.string().describe("Author name"), url: z.string().optional().describe("Author URL/homepage"), affiliations: z .array(z.number()) .optional() .describe("1-based affiliation indices"), newAffiliationName: z .string() .optional() .describe("Name of a new affiliation to create (if needed)"), newAffiliationUrl: z .string() .optional() .describe("URL of the new affiliation"), }), }), removeAuthor: tool({ description: "Remove an author by index (0-based).", inputSchema: z.object({ index: z.number().describe("0-based index of the author to remove"), }), }), }; export const editorTools = { ...buildTiptapTools(), ...customEditorTools, ...frontmatterTools, };