1
0
Fork 0
tsgames/src/games/storywriter/utils/tools.ts

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');
}
}
}