carbon-tokenization / backend /src /agent /tiptap-catalog.ts
tfrere's picture
tfrere HF Staff
feat(editor): embed studio with data files and agent-aware editing
8fc8501
Raw
History Blame Contribute Delete
8.27 kB
import { tool, type Tool } from "ai";
import { z } from "zod";
/**
* Catalog of Tiptap editor commands exposed to the AI agent.
*
* Each entry pairs:
* - an AI SDK tool definition (description + zod input schema)
* - safety metadata used to build the system prompt dynamically
* (category, selectionEffect, destructive)
*
* The frontend's agent executor dispatches on `name` and calls
* `editor.chain()[command](args).run()`, so the catalog is the single
* source of truth for what the agent can do.
*
* Inspired by `tiptap-apcore`'s Registry + AnnotationCatalog pattern,
* but intentionally pared down to the ~15 commands that make sense for
* a research article editor.
*/
type Category = "query" | "selection" | "format" | "content";
/**
* How a command interacts with the current text selection.
*
* - `require`: must have a non-collapsed selection (mark toggles)
* - `preserve`: selection survives execution (most mark toggles, alignment)
* - `destroy`: cursor collapses after execution (insertContent)
* - `none`: operates on the block at cursor, no selection needed
*/
type SelectionEffect = "require" | "preserve" | "destroy" | "none";
// `tool()` returns a strongly-typed Tool<InputSchema, Output>; storing them
// in a homogeneous array requires the most permissive Tool generic.
type AnyTool = Tool<any, any>;
export interface TiptapCommandEntry {
name: string;
category: Category;
selectionEffect: SelectionEffect;
destructive: boolean;
tool: AnyTool;
}
// ---------------------------------------------------------------------------
// Command entries
// ---------------------------------------------------------------------------
export const TIPTAP_COMMANDS: TiptapCommandEntry[] = [
// -- Selection ---------------------------------------------------------
{
name: "selectText",
category: "selection",
selectionEffect: "preserve",
destructive: false,
tool: tool({
description:
"Find a text substring in the document and select it. Use this BEFORE applying " +
"mark-level formatting commands (toggleBold, setLink, etc.). Returns { found, from, to } " +
"so you can verify the match. When the same text appears multiple times, use `occurrence` " +
"(1-based) or provide `contextBefore` / `contextAfter` to disambiguate.",
inputSchema: z.object({
text: z.string().describe("The exact text to select (substring match)"),
occurrence: z
.number()
.int()
.min(1)
.optional()
.describe(
"Which occurrence to select (1-based). Defaults to 1. Ignored if contextBefore/After are provided.",
),
contextBefore: z
.string()
.optional()
.describe("A few words appearing just before the target text, used to disambiguate."),
contextAfter: z
.string()
.optional()
.describe("A few words appearing just after the target text, used to disambiguate."),
}),
}),
},
// -- Format: marks (require selection) --------------------------------
{
name: "toggleBold",
category: "format",
selectionEffect: "require",
destructive: false,
tool: tool({
description:
"Toggle bold on the current selection. MUST be called after selectText - has no effect without an active text range.",
inputSchema: z.object({}),
}),
},
{
name: "toggleItalic",
category: "format",
selectionEffect: "require",
destructive: false,
tool: tool({
description:
"Toggle italic on the current selection. MUST be called after selectText - has no effect without an active text range.",
inputSchema: z.object({}),
}),
},
{
name: "toggleStrike",
category: "format",
selectionEffect: "require",
destructive: false,
tool: tool({
description:
"Toggle strikethrough on the current selection. MUST be called after selectText.",
inputSchema: z.object({}),
}),
},
{
name: "toggleCode",
category: "format",
selectionEffect: "require",
destructive: false,
tool: tool({
description:
"Toggle inline code formatting on the current selection. MUST be called after selectText.",
inputSchema: z.object({}),
}),
},
{
name: "setLink",
category: "format",
selectionEffect: "require",
destructive: false,
tool: tool({
description:
"Turn the current selection into a hyperlink. MUST be called after selectText. " +
"Only http(s)/mailto/tel/relative URLs are accepted.",
inputSchema: z.object({
href: z.string().describe("Target URL (http/https/mailto/tel or relative)"),
}),
}),
},
{
name: "unsetLink",
category: "format",
selectionEffect: "require",
destructive: false,
tool: tool({
description: "Remove hyperlink formatting from the current selection.",
inputSchema: z.object({}),
}),
},
// -- Format: node-level (no selection needed) -------------------------
{
name: "toggleHeading",
category: "format",
selectionEffect: "none",
destructive: false,
tool: tool({
description:
"Convert the block at the cursor to a heading (or back to a paragraph if already that level). " +
"Operates on the containing block - no text selection needed.",
inputSchema: z.object({
level: z.number().int().min(1).max(3).describe("Heading level (1, 2 or 3)"),
}),
}),
},
{
name: "setParagraph",
category: "format",
selectionEffect: "none",
destructive: false,
tool: tool({
description: "Convert the block at the cursor to a plain paragraph.",
inputSchema: z.object({}),
}),
},
{
name: "toggleBulletList",
category: "format",
selectionEffect: "none",
destructive: false,
tool: tool({
description: "Toggle the block at the cursor between a bullet list item and a paragraph.",
inputSchema: z.object({}),
}),
},
{
name: "toggleOrderedList",
category: "format",
selectionEffect: "none",
destructive: false,
tool: tool({
description: "Toggle the block at the cursor between an ordered (numbered) list item and a paragraph.",
inputSchema: z.object({}),
}),
},
{
name: "toggleBlockquote",
category: "format",
selectionEffect: "none",
destructive: false,
tool: tool({
description: "Toggle blockquote on the block at the cursor.",
inputSchema: z.object({}),
}),
},
{
name: "toggleCodeBlock",
category: "format",
selectionEffect: "none",
destructive: false,
tool: tool({
description:
"Toggle code block on the block at the cursor. Use for multi-line code snippets.",
inputSchema: z.object({
language: z
.string()
.optional()
.describe("Language hint for syntax highlighting (e.g. 'python', 'typescript')"),
}),
}),
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** AI SDK tool definitions keyed by command name. Pass directly to streamText. */
export function buildTiptapTools(): Record<string, AnyTool> {
const out: Record<string, AnyTool> = {};
for (const entry of TIPTAP_COMMANDS) {
out[entry.name] = entry.tool;
}
return out;
}
/** Commands grouped by how they interact with the selection. */
export function classifyBySelectionEffect(): Record<SelectionEffect, string[]> {
const groups: Record<SelectionEffect, string[]> = {
require: [],
preserve: [],
destroy: [],
none: [],
};
for (const entry of TIPTAP_COMMANDS) {
groups[entry.selectionEffect].push(entry.name);
}
return groups;
}
/** Commands grouped by category, used to build a concise command list in the prompt. */
export function groupByCategory(): Record<Category, string[]> {
const groups: Record<Category, string[]> = {
query: [],
selection: [],
format: [],
content: [],
};
for (const entry of TIPTAP_COMMANDS) {
groups[entry.category].push(entry.name);
}
return groups;
}