tfrere's picture
tfrere HF Staff
feat(editor): embed studio with data files and agent-aware editing
8fc8501
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,
};