File size: 5,230 Bytes
561e6f0
 
8fc8501
561e6f0
 
 
 
 
 
 
 
 
8fc8501
 
 
 
 
 
561e6f0
 
8fc8501
561e6f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fc8501
561e6f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fc8501
561e6f0
8fc8501
561e6f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8fc8501
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
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,
};