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