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

384 lines
17 KiB
TypeScript

import { formatError } from "@common/errors";
import { Type, type Static, type TObject } from '@common/typebox';
import { LocationScale, type AppState, type Character, type Location } from "../contexts/state";
import type LLM from "./llm";
const SCALE_DESCRIPTION = Object.entries(LocationScale)
.filter(([, value]) => typeof value === 'number')
.map(([key, value]) => `${value}=${key}`)
.join(', ');
const VALID_SCALES = Object.values(LocationScale).filter(v => typeof v === 'number');
export namespace Tools {
interface Tool<T extends TObject = TObject> {
description: string;
parameters: T;
handler(args: Static<T>, appState: AppState): unknown;
}
const tool = <T extends TObject = TObject>(t: Tool<T>): Tool<T> => t;
const TOOLS: Record<string, Tool> = {
'append_to_story': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
appState.dispatch({
type: 'EDIT_STORY',
id: appState.currentStory.id,
text: appState.currentStory.text + args.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({
text: Type.String({ description: 'The text to append to the story' }),
}),
}),
'append_to_lore': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
appState.dispatch({
type: 'EDIT_LORE',
id: appState.currentStory.id,
lore: appState.currentStory.lore + args.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({
text: Type.String({ description: 'The text to append to the lore' }),
}),
}),
'add_character': tool({
handler: async (args, appState) => {
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 = (args.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: args.name.trim(),
nicknames: args.nicknames || [],
shortDescription: args.shortDescription.trim(),
description: args.description || '',
relations: validRelations,
},
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'characters'
});
let message = `Character "${args.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({
name: Type.String({ description: "The character's full name" }),
shortDescription: Type.String({ description: 'A brief description of the character (one line)' }),
nicknames: Type.Optional(Type.Array(Type.String(), { description: 'Optional list of nicknames' })),
description: Type.Optional(Type.String({ description: 'Optional full character description' })),
relations: Type.Optional(Type.Array(
Type.Object({
name: Type.String({ description: 'Related character name' }),
relation: Type.String({ description: 'Relationship type' }),
}),
{ description: 'Optional list of relationships with other characters' }
)),
}),
}),
'edit_character': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
const character = appState.currentStory.characters.find(c => c.name === args.name.trim());
if (!character) {
return `Error: Character "${args.name.trim()}" not found`;
}
// Only include defined values to avoid setting fields to undefined
const definedUpdates: Partial<Character> = {};
if (args.shortDescription !== undefined) {
definedUpdates.shortDescription = args.shortDescription;
}
if (args.description !== undefined) {
definedUpdates.description = args.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 "${args.name.trim()}" updated successfully`;
},
description: "Edit an existing character's description",
parameters: Type.Object({
name: Type.String({ description: "The character's full name to identify which character to edit" }),
shortDescription: Type.Optional(Type.String({ description: 'Brief description of the character (one line)' })),
description: Type.Optional(Type.String({ description: 'Full character description' })),
}),
}),
'add_location': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
appState.dispatch({
type: 'ADD_LOCATION',
storyId: appState.currentStory.id,
location: {
id: crypto.randomUUID(),
name: args.name.trim(),
shortDescription: args.shortDescription.trim(),
description: args.description || '',
scale: args.scale,
},
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'locations'
});
return `Location "${args.name.trim()}" added successfully`;
},
description: 'Add a new location to the story',
parameters: Type.Object({
name: Type.String({ description: "The location's full name" }),
shortDescription: Type.String({ description: 'A brief description of the location (one line)' }),
description: Type.Optional(Type.String({ description: 'Optional full location description' })),
scale: Type.Enum(VALID_SCALES, {
description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
}),
}),
}),
'edit_location': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
const location = appState.currentStory.locations.find(l => l.name === args.name.trim());
if (!location) {
return `Error: Location "${args.name.trim()}" not found`;
}
const definedUpdates: Partial<Location> = {};
if (args.shortDescription !== undefined) {
definedUpdates.shortDescription = args.shortDescription;
}
if (args.description !== undefined) {
definedUpdates.description = args.description;
}
if (args.scale !== undefined) {
definedUpdates.scale = args.scale;
}
appState.dispatch({
type: 'EDIT_LOCATION',
storyId: appState.currentStory.id,
locationId: location.id,
updates: definedUpdates,
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'locations'
});
return `Location "${args.name.trim()}" updated successfully`;
},
description: "Edit an existing location's description",
parameters: Type.Object({
name: Type.String({ description: "The location's full name to identify which location to edit" }),
shortDescription: Type.Optional(Type.String({ description: 'Brief description of the location (one line)' })),
description: Type.Optional(Type.String({ description: 'Full location description' })),
scale: Type.Optional(Type.Enum(VALID_SCALES, {
description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
})),
}),
}),
'edit_story': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
const occurrences = appState.currentStory.text.split(args.old_text).length - 1;
if (occurrences === 0) {
return 'Error: old_text not found in story';
}
if (occurrences > 1 && !args.replace_all) {
return 'Error: old_text appears multiple times in story';
}
appState.dispatch({
type: 'EDIT_STORY',
id: appState.currentStory.id,
text: appState.currentStory.text.replaceAll(args.old_text, args.new_text),
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'story'
});
return 'Story edited successfully';
},
description: 'Replace text in the current story',
parameters: Type.Object({
old_text: Type.String({ description: 'The text to find and replace in the story' }),
new_text: Type.String({ description: 'The new text to replace old_text with' }),
replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })),
}),
}),
'edit_lore': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
const occurrences = appState.currentStory.lore.split(args.old_text).length - 1;
if (occurrences === 0) {
return 'Error: old_text not found in lore';
}
if (occurrences > 1 && !args.replace_all) {
return 'Error: old_text appears multiple times in lore';
}
appState.dispatch({
type: 'EDIT_LORE',
id: appState.currentStory.id,
lore: appState.currentStory.lore.replaceAll(args.old_text, args.new_text),
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'lore'
});
return 'Lore edited successfully';
},
description: 'Replace text in the story lore',
parameters: Type.Object({
old_text: Type.String({ description: 'The text to find and replace in the lore' }),
new_text: Type.String({ description: 'The new text to replace old_text with' }),
replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })),
}),
}),
'grep': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
const lines = appState.currentStory.text.split('\n');
const matches: { line: number; content: string }[] = [];
const pattern = new RegExp(args.pattern, args.case_sensitive ? 'g' : 'gi');
for (let i = 0; i < lines.length; i++) {
if (pattern.test(lines[i])) {
matches.push({ line: i + 1, content: lines[i].trim() });
}
}
if (matches.length === 0) {
return `No matches found for pattern: ${args.pattern}`;
}
const limit = args.limit ?? 20;
const truncated = matches.length > limit;
const displayed = truncated ? matches.slice(0, limit) : matches;
let result = `Found ${matches.length} match(es) for pattern "${args.pattern}":\n`;
result += displayed.map(m => `Line ${m.line}: ${m.content}`).join('\n');
if (truncated) {
result += `\n... and ${matches.length - limit} more match(es)`;
}
return result;
},
description: 'Search for a pattern in the story text',
parameters: Type.Object({
pattern: Type.String({ description: 'The regex pattern to search for' }),
case_sensitive: Type.Optional(Type.Boolean({ description: 'If true, search is case-sensitive (default: false)' })),
limit: Type.Optional(Type.Integer({ description: 'Maximum number of matches to return (default: 20)' })),
}),
})
};
export function getTools(): LLM.Tool[] {
return Object.entries(TOOLS).map(([key, tool]) => ({
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 (Array.isArray(obj)) {
return obj.map(parseArg);
}
if (typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj)
.map(([key, value]) => [key, parseArg(value)])
)
}
return obj;
} catch { }
return arg;
}
export async function executeTool(appState: AppState, toolCall: LLM.ToolCall): Promise<string> {
const { function: fn } = toolCall;
const args = parseArg(fn.arguments);
const tool = TOOLS[fn.name];
if (!tool) {
return `Unknown tool: ${fn.name}`;
}
const errors = Type.Check(tool.parameters, args);
if (errors.length > 0) {
return errors.map(e => e.message).join('\n');
}
try {
const result = await tool.handler(args as any, appState);
if (typeof result === 'string') {
return result;
}
return JSON.stringify(result);
} catch (err) {
return formatError(err, 'Error executing tool');
}
}
}