import { formatError } from "@common/errors"; import type { AppState, Character } from "../contexts/state"; import type LLM from "./llm"; export namespace Tools { interface Tool { description: string; parameters: LLM.ToolObjectParameter; handler(args: string | Record, appState: AppState): unknown; } const TOOLS: Record = { 'append_to_story': { handler: async (args, appState) => { if (!args || typeof args !== 'object' || !('text' in args)) { return 'Error: Missing required argument "text"'; } const { text } = args as { text: string }; if (typeof text !== 'string') { return 'Error: Argument "text" must be a string'; } if (!appState.currentStory) { return 'Error: No story selected'; } appState.dispatch({ type: 'EDIT_STORY', id: appState.currentStory.id, text: appState.currentStory.text + text }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'story' }); return 'Text appended successfully'; }, description: 'Append text to the current story', parameters: { type: 'object', properties: { text: { type: 'string', description: 'The text to append to the story', }, }, required: ['text'], }, }, 'append_to_lore': { handler: async (args, appState) => { if (!args || typeof args !== 'object' || !('text' in args)) { return 'Error: Missing required argument "text"'; } const { text } = args as { text: string }; if (typeof text !== 'string') { return 'Error: Argument "text" must be a string'; } if (!appState.currentStory) { return 'Error: No story selected'; } appState.dispatch({ type: 'EDIT_LORE', id: appState.currentStory.id, lore: appState.currentStory.lore + text }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'lore' }); return 'Text appended to lore successfully'; }, description: 'Append text to the story lore', parameters: { type: 'object', properties: { text: { type: 'string', description: 'The text to append to the lore', }, }, required: ['text'], }, }, 'add_character': { handler: async (args, appState) => { if (!args || typeof args !== 'object') { return 'Error: Missing required arguments'; } const { name, shortDescription, nicknames, description, relations } = args as { name: string; shortDescription: string; nicknames?: string[]; description?: string; relations?: { name: string; relation: string }[]; }; if (typeof name !== 'string' || !name.trim()) { return 'Error: Argument "name" must be a non-empty string'; } if (typeof shortDescription !== 'string' || !shortDescription.trim()) { return 'Error: Argument "shortDescription" must be a non-empty string'; } if (!appState.currentStory) { return 'Error: No story selected'; } // Filter out relations that reference non-existent characters const existingCharacterNames = appState.currentStory.characters.map(c => c.name); const invalidRelations: string[] = []; const validRelations = (relations || []).filter(rel => { if (!existingCharacterNames.includes(rel.name)) { invalidRelations.push(`${rel.name} (${rel.relation})`); return false; } return true; }); appState.dispatch({ type: 'ADD_CHARACTER', storyId: appState.currentStory.id, character: { id: crypto.randomUUID(), name: name.trim(), nicknames: nicknames || [], shortDescription: shortDescription.trim(), description: description || '', relations: validRelations, }, }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'characters' }); let message = `Character "${name.trim()}" added successfully`; if (invalidRelations.length > 0) { message += `. Removed invalid relations to non-existent characters: ${invalidRelations.join(', ')}`; } return message; }, description: 'Add a new character to the story', parameters: { type: 'object', properties: { name: { type: 'string', description: 'The character\'s full name', }, shortDescription: { type: 'string', description: 'A brief description of the character (one line)', }, nicknames: { type: 'array', items: { type: 'string' }, description: 'Optional list of nicknames', }, description: { type: 'string', description: 'Optional full character description', }, relations: { type: 'array', items: { type: 'object', properties: { name: { type: 'string', description: 'Related character name' }, relation: { type: 'string', description: 'Relationship type' }, }, }, description: 'Optional list of relationships with other characters', }, }, required: ['name', 'shortDescription'], }, }, 'edit_character': { handler: async (args, appState) => { if (!args || typeof args !== 'object') { return 'Error: Missing required arguments'; } const { name, shortDescription, description } = args as { name: string; shortDescription?: string; description?: string; }; if (typeof name !== 'string' || !name.trim()) { return 'Error: Argument "name" must be a non-empty string'; } if (!appState.currentStory) { return 'Error: No story selected'; } const character = appState.currentStory.characters.find(c => c.name === name.trim()); if (!character) { return `Error: Character "${name.trim()}" not found`; } // Only include defined values to avoid setting fields to undefined const definedUpdates: Partial = {}; if (shortDescription !== undefined) { definedUpdates.shortDescription = shortDescription; } if (description !== undefined) { definedUpdates.description = description; } appState.dispatch({ type: 'EDIT_CHARACTER', storyId: appState.currentStory.id, characterId: character.id, updates: definedUpdates, }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'characters' }); return `Character "${name.trim()}" updated successfully`; }, description: 'Edit an existing character\'s description', parameters: { type: 'object', properties: { name: { type: 'string', description: 'The character\'s full name to identify which character to edit', }, shortDescription: { type: 'string', description: 'Brief description of the character (one line)', }, description: { type: 'string', description: 'Full character description', }, }, required: ['name'], }, } }; export function getTools(): LLM.Tool[] { return Object.entries(TOOLS).map(([key, tool]) => { return { type: 'function', function: { name: key, description: tool.description, parameters: tool.parameters, }, }; }); } function parseArg(arg: unknown): unknown { if (typeof arg !== 'string') return arg; try { const obj = JSON.parse(arg); if (obj) { if (Array.isArray(obj)) { return obj.map(parseArg); } if (typeof obj === 'object') { return Object.fromEntries( Object.entries(obj) .map(([key, value]) => [key, parseArg(value)]) ) } } } catch { } return arg; } export async function executeTool(appState: AppState, toolCall: LLM.ToolCall): Promise { const { function: fn } = toolCall; const args = parseArg(fn.arguments); if (!args || typeof args !== 'object') { return 'Error: Arguments must be an object'; } const handler = TOOLS[fn.name]?.handler; if (!handler) { return `Unknown tool: ${fn.name}`; } try { const result = await handler(args, appState); if (typeof result === 'string') { return result; } return JSON.stringify(result); } catch (err) { return formatError(err, 'Error executing tool'); } } }