mono/packages/ui/src/modules/ai/markdownTools.ts
2026-03-21 20:18:25 +01:00

318 lines
14 KiB
TypeScript

/**
* Markdown Tools — AI tool definitions for markdown content manipulation
*
* These tools allow the AI to manipulate the markdown document currently
* being edited. The actual mutations are performed by callbacks supplied
* at construction time (via the consumer hook), keeping this module
* stateless and reusable.
*
* Uses manual JSON schemas (same pattern as searchTools.ts) to guarantee
* OpenAI-compatible `type: "object"` at the top level.
*/
import React from 'react';
import { z } from 'zod';
import type { RunnableToolFunctionWithParse } from 'openai/lib/RunnableFunction';
type LogFunction = (level: string, message: string, data?: any) => void;
const defaultLog: LogFunction = (level, message, data) => console.log(`[${level}] ${message}`, data);
// ── Mutation callback type ───────────────────────────────────────────────
export type MarkdownAction =
| { type: 'replace_content'; content: string }
| { type: 'append_content'; content: string; separator?: string }
| { type: 'insert_at_cursor'; content: string }
| { type: 'replace_selection'; content: string };
export type MarkdownMutator = (action: MarkdownAction) => boolean;
// ── Input schemas ────────────────────────────────────────────────────────
interface ReplaceContentArgs {
content: string;
}
interface AppendContentArgs {
content: string;
separator?: string;
}
interface InsertAtCursorArgs {
content: string;
}
interface ReplaceSelectionArgs {
content: string;
}
const replaceContentSchema = z.object({
content: z.string(),
});
const appendContentSchema = z.object({
content: z.string(),
separator: z.string().optional(),
});
const insertAtCursorSchema = z.object({
content: z.string(),
});
const replaceSelectionSchema = z.object({
content: z.string(),
});
// ── Tool: Replace Content ────────────────────────────────────────────────
export const replaceContentTool = (
mutatorRef: React.MutableRefObject<MarkdownMutator | null>,
addLog: LogFunction = defaultLog,
): RunnableToolFunctionWithParse<ReplaceContentArgs> => ({
type: 'function',
function: {
name: 'replace_content',
description:
'Replace the ENTIRE markdown document with new content. ' +
'Use this when the user asks for a complete rewrite, or when generating content from scratch. ' +
'Do NOT use this to make partial edits — use replace_selection or append_content instead.',
parameters: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'The complete new markdown content for the document.',
},
},
required: ['content'],
} as any,
parse(input: string): ReplaceContentArgs {
return replaceContentSchema.parse(JSON.parse(input));
},
function: async (args: ReplaceContentArgs) => {
try {
addLog('info', '[MD-TOOLS] replace_content called', { length: args.content.length });
const ok = mutatorRef.current?.({ type: 'replace_content', content: args.content });
if (ok) {
addLog('info', '[MD-TOOLS] replace_content succeeded');
return { success: true, action: 'replace_content', message: `Document replaced (${args.content.length} chars)` };
}
return { success: false, error: 'Mutator not available' };
} catch (error: any) {
addLog('error', '[MD-TOOLS] replace_content failed', { error: error.message });
return { success: false, error: error.message };
}
},
},
});
// ── Tool: Append Content ─────────────────────────────────────────────────
export const appendContentTool = (
mutatorRef: React.MutableRefObject<MarkdownMutator | null>,
addLog: LogFunction = defaultLog,
): RunnableToolFunctionWithParse<AppendContentArgs> => ({
type: 'function',
function: {
name: 'append_content',
description:
'Append markdown content at the END of the current document. ' +
'Use this when the user asks to add more content, extend, or continue writing. ' +
'By default a horizontal rule separator (---) is added between existing and new content.',
parameters: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'The markdown content to append.',
},
separator: {
type: 'string',
description: 'Separator between existing and new content. Default: "\\n\\n---\\n\\n". Use "\\n" for a tight join.',
},
},
required: ['content'],
} as any,
parse(input: string): AppendContentArgs {
return appendContentSchema.parse(JSON.parse(input));
},
function: async (args: AppendContentArgs) => {
try {
addLog('info', '[MD-TOOLS] append_content called', { length: args.content.length });
const ok = mutatorRef.current?.({
type: 'append_content',
content: args.content,
separator: args.separator,
});
if (ok) {
addLog('info', '[MD-TOOLS] append_content succeeded');
return { success: true, action: 'append_content', message: `Appended ${args.content.length} chars` };
}
return { success: false, error: 'Mutator not available' };
} catch (error: any) {
addLog('error', '[MD-TOOLS] append_content failed', { error: error.message });
return { success: false, error: error.message };
}
},
},
});
// ── Tool: Insert at Cursor ───────────────────────────────────────────────
export const insertAtCursorTool = (
mutatorRef: React.MutableRefObject<MarkdownMutator | null>,
addLog: LogFunction = defaultLog,
): RunnableToolFunctionWithParse<InsertAtCursorArgs> => ({
type: 'function',
function: {
name: 'insert_at_cursor',
description:
'Insert markdown content at the current cursor position in the editor. ' +
'Use this when the user asks to insert content at a specific point, ' +
'or when no text is selected but the user wants content added where they are.',
parameters: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'The markdown content to insert at the cursor position.',
},
},
required: ['content'],
} as any,
parse(input: string): InsertAtCursorArgs {
return insertAtCursorSchema.parse(JSON.parse(input));
},
function: async (args: InsertAtCursorArgs) => {
try {
addLog('info', '[MD-TOOLS] insert_at_cursor called', { length: args.content.length });
const ok = mutatorRef.current?.({ type: 'insert_at_cursor', content: args.content });
if (ok) {
addLog('info', '[MD-TOOLS] insert_at_cursor succeeded');
return { success: true, action: 'insert_at_cursor', message: `Inserted ${args.content.length} chars at cursor` };
}
return { success: false, error: 'Mutator not available or no cursor position' };
} catch (error: any) {
addLog('error', '[MD-TOOLS] insert_at_cursor failed', { error: error.message });
return { success: false, error: error.message };
}
},
},
});
// ── Tool: Replace Selection ──────────────────────────────────────────────
export const replaceSelectionTool = (
mutatorRef: React.MutableRefObject<MarkdownMutator | null>,
addLog: LogFunction = defaultLog,
): RunnableToolFunctionWithParse<ReplaceSelectionArgs> => ({
type: 'function',
function: {
name: 'replace_selection',
description:
'Replace the currently selected text in the editor with new content. ' +
'Use this when the user has selected text and asks to rewrite, rephrase, translate, or transform it. ' +
'If no text is selected, this will fail — use append_content or insert_at_cursor instead.',
parameters: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'The new markdown content to replace the current selection with.',
},
},
required: ['content'],
} as any,
parse(input: string): ReplaceSelectionArgs {
return replaceSelectionSchema.parse(JSON.parse(input));
},
function: async (args: ReplaceSelectionArgs) => {
try {
addLog('info', '[MD-TOOLS] replace_selection called', { length: args.content.length });
const ok = mutatorRef.current?.({ type: 'replace_selection', content: args.content });
if (ok) {
addLog('info', '[MD-TOOLS] replace_selection succeeded');
return { success: true, action: 'replace_selection', message: `Selection replaced with ${args.content.length} chars` };
}
return { success: false, error: 'No text selected or mutator not available' };
} catch (error: any) {
addLog('error', '[MD-TOOLS] replace_selection failed', { error: error.message });
return { success: false, error: error.message };
}
},
},
});
// ── Tool: Insert Gallery ─────────────────────────────────────────────────
interface InsertGalleryArgs {
picture_ids: string[];
}
const insertGallerySchema = z.object({
picture_ids: z.array(z.string()).min(1),
});
export const insertGalleryTool = (
mutatorRef: React.MutableRefObject<MarkdownMutator | null>,
addLog: LogFunction = defaultLog,
): RunnableToolFunctionWithParse<InsertGalleryArgs> => ({
type: 'function',
function: {
name: 'insert_gallery',
description:
'Insert a picture gallery into the markdown document. ' +
'Use the find_pictures tool first to search for pictures and get their IDs, ' +
'then pass the IDs to this tool. The gallery is rendered as an interactive ' +
'image grid with thumbnails.',
parameters: {
type: 'object',
properties: {
picture_ids: {
type: 'array',
items: { type: 'string' },
description: 'Array of picture IDs to include in the gallery. Use find_pictures to get these.',
minItems: 1,
},
},
required: ['picture_ids'],
} as any,
parse(input: string): InsertGalleryArgs {
return insertGallerySchema.parse(JSON.parse(input));
},
function: async (args: InsertGalleryArgs) => {
try {
const ids = args.picture_ids.filter(Boolean);
addLog('info', `[MD-TOOLS] insert_gallery called with ${ids.length} pictures`);
// Build the gallery fence block (append_content adds \n\n separator)
const galleryBlock = '```custom-gallery\n' + ids.join(',') + '\n```';
const ok = mutatorRef.current?.({
type: 'append_content',
content: galleryBlock,
});
if (ok) {
addLog('info', '[MD-TOOLS] insert_gallery succeeded');
return { success: true, action: 'insert_gallery', message: `Gallery inserted with ${ids.length} pictures` };
}
return { success: false, error: 'Mutator not available' };
} catch (error: any) {
addLog('error', '[MD-TOOLS] insert_gallery failed', { error: error.message });
return { success: false, error: error.message };
}
},
},
});
// ── Preset ───────────────────────────────────────────────────────────────
/**
* Create all markdown manipulation tools.
* The mutatorRef is called by the tools to actually update the editor content.
*/
export const createMarkdownTools = (
mutatorRef: React.MutableRefObject<MarkdownMutator | null>,
addLog: LogFunction = defaultLog,
) => [
replaceContentTool(mutatorRef, addLog),
appendContentTool(mutatorRef, addLog),
insertAtCursorTool(mutatorRef, addLog),
replaceSelectionTool(mutatorRef, addLog),
insertGalleryTool(mutatorRef, addLog),
];