295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
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<string, any>, appState: AppState): unknown;
|
|
}
|
|
|
|
const TOOLS: Record<string, Tool> = {
|
|
'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<Character> = {};
|
|
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<string> {
|
|
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');
|
|
}
|
|
}
|
|
}
|