500 lines
24 KiB
TypeScript
500 lines
24 KiB
TypeScript
import { formatErrorMessage } from "@common/errors";
|
|
import { Type, type Static, type TObject } from '@common/typebox';
|
|
import { CharacterRole, LocationScale, type AppState, type Character, type Location } from "../contexts/state";
|
|
import type LLM from "./llm";
|
|
|
|
const VALID_SCALES = Object.values(LocationScale);
|
|
const VALID_ROLES = Object.values(CharacterRole);
|
|
|
|
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> = {
|
|
'get_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`;
|
|
}
|
|
let result = `# Character: ${character.name}\n\n`;
|
|
result += `**Role:** ${character.role}\n\n`;
|
|
if (character.nicknames && character.nicknames.length > 0) {
|
|
result += `**Nicknames:** ${character.nicknames.join(', ')}\n\n`;
|
|
}
|
|
result += `**Short Description:** ${character.shortDescription}\n\n`;
|
|
if (character.description) {
|
|
result += `**Description:** ${character.description}\n\n`;
|
|
}
|
|
if (character.relations && character.relations.length > 0) {
|
|
result += `**Relations:**\n`;
|
|
for (const rel of character.relations) {
|
|
result += `- ${rel.name}: ${rel.relation}\n`;
|
|
}
|
|
}
|
|
return result.trim();
|
|
},
|
|
description: 'Get full information about a character by name',
|
|
parameters: Type.Object({
|
|
name: Type.String({ description: "The character's full name" }),
|
|
}),
|
|
}),
|
|
'set_character': tool({
|
|
handler: async (args, appState) => {
|
|
if (!appState.currentStory) {
|
|
return 'Error: No story selected';
|
|
}
|
|
const existingCharacter = appState.currentStory.characters.find(c => c.name === args.name.trim());
|
|
|
|
if (existingCharacter) {
|
|
// Edit existing character
|
|
const definedUpdates: Partial<Character> = {};
|
|
if (args.shortDescription !== undefined) {
|
|
definedUpdates.shortDescription = args.shortDescription;
|
|
}
|
|
if (args.description !== undefined) {
|
|
definedUpdates.description = args.description;
|
|
}
|
|
if (args.nicknames !== undefined) {
|
|
definedUpdates.nicknames = args.nicknames;
|
|
}
|
|
if (args.role !== undefined) {
|
|
definedUpdates.role = args.role;
|
|
}
|
|
if (args.relations !== undefined) {
|
|
return 'Error: set_character does not support updating relations. Use set_character_relation instead.';
|
|
}
|
|
appState.dispatch({
|
|
type: 'EDIT_CHARACTER',
|
|
storyId: appState.currentStory.id,
|
|
characterId: existingCharacter.id,
|
|
updates: definedUpdates,
|
|
});
|
|
appState.dispatch({
|
|
type: 'SET_CURRENT_TAB',
|
|
id: appState.currentStory.id,
|
|
tab: 'characters'
|
|
});
|
|
return `Character "${args.name.trim()}" updated successfully`;
|
|
} else {
|
|
// Add new character - validate required fields
|
|
if (!args.shortDescription) {
|
|
return 'Error: shortDescription is required when adding a new character';
|
|
}
|
|
if (args.role === undefined) {
|
|
return 'Error: role is required when adding a new character';
|
|
}
|
|
const existingCharacterNames = new Set(appState.currentStory.characters.map(c => c.name));
|
|
const invalidRelations: string[] = [];
|
|
for (const rel of args.relations || []) {
|
|
if (!existingCharacterNames.has(rel.name)) {
|
|
invalidRelations.push(`${rel.name} (${rel.relation})`);
|
|
}
|
|
}
|
|
appState.dispatch({
|
|
type: 'ADD_CHARACTER',
|
|
storyId: appState.currentStory.id,
|
|
character: {
|
|
id: crypto.randomUUID(),
|
|
name: args.name.trim(),
|
|
role: args.role,
|
|
nicknames: args.nicknames || [],
|
|
shortDescription: args.shortDescription.trim(),
|
|
description: args.description || '',
|
|
relations: args.relations || [],
|
|
},
|
|
});
|
|
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 += `.\nNote: found invalid relations to non-existent characters: ${invalidRelations.join(', ')}`;
|
|
}
|
|
return message;
|
|
}
|
|
},
|
|
description: 'Add or edit a character. If character exists, updates it; otherwise creates new one.',
|
|
parameters: Type.Object({
|
|
name: Type.String({ description: "The character's full name" }),
|
|
shortDescription: Type.Optional(Type.String({ description: 'A brief description of the character (one line). Required on a new character.' })),
|
|
role: Type.Optional(Type.Enum(VALID_ROLES, { description: `The character role in the story. Required when adding a new character.` })),
|
|
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 (allowed only when adding a new character)' }
|
|
)),
|
|
}),
|
|
}),
|
|
'set_character_relation': tool({
|
|
handler: async (args, appState) => {
|
|
if (!appState.currentStory) {
|
|
return 'Error: No story selected';
|
|
}
|
|
const character = appState.currentStory.characters.find(c => c.name === args.character_name.trim());
|
|
if (!character) {
|
|
return `Error: Character "${args.character_name.trim()}" not found`;
|
|
}
|
|
const targetCharacter = appState.currentStory.characters.find(c => c.name === args.target_name.trim());
|
|
if (!targetCharacter) {
|
|
return `Error: Target character "${args.target_name.trim()}" not found, please add it first.`;
|
|
}
|
|
const existingRelationIndex = character.relations.findIndex(r => r.name === args.target_name.trim());
|
|
if (existingRelationIndex !== -1) {
|
|
// Edit existing relation
|
|
appState.dispatch({
|
|
type: 'EDIT_CHARACTER_RELATION',
|
|
storyId: appState.currentStory.id,
|
|
characterId: character.id,
|
|
targetName: args.target_name.trim(),
|
|
updates: { relation: args.relation.trim() },
|
|
});
|
|
appState.dispatch({
|
|
type: 'SET_CURRENT_TAB',
|
|
id: appState.currentStory.id,
|
|
tab: 'characters'
|
|
});
|
|
return `Relation updated: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`;
|
|
} else {
|
|
// Add new relation
|
|
appState.dispatch({
|
|
type: 'ADD_CHARACTER_RELATION',
|
|
storyId: appState.currentStory.id,
|
|
characterId: character.id,
|
|
relation: {
|
|
name: args.target_name.trim(),
|
|
relation: args.relation.trim(),
|
|
},
|
|
});
|
|
appState.dispatch({
|
|
type: 'SET_CURRENT_TAB',
|
|
id: appState.currentStory.id,
|
|
tab: 'characters'
|
|
});
|
|
return `Relation added: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`;
|
|
}
|
|
},
|
|
description: 'Add or edit a relationship between two characters. If relation exists, updates it; otherwise creates new one.',
|
|
parameters: Type.Object({
|
|
character_name: Type.String({ description: "The character's full name to add/edit the relation for" }),
|
|
target_name: Type.String({ description: "The target character's full name" }),
|
|
relation: Type.String({ description: 'The relationship type (e.g., friend, enemy, sibling)' }),
|
|
}),
|
|
}),
|
|
'get_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`;
|
|
}
|
|
let result = `# Location: ${location.name}\n\n`;
|
|
result += `**Short Description:** ${location.shortDescription}\n\n`;
|
|
if (location.description) {
|
|
result += `**Description:** ${location.description}\n\n`;
|
|
}
|
|
result += `**Scale:** ${location.scale}\n`;
|
|
return result.trim();
|
|
},
|
|
description: 'Get full information about a location by name',
|
|
parameters: Type.Object({
|
|
name: Type.String({ description: "The location's full name" }),
|
|
}),
|
|
}),
|
|
'set_location': tool({
|
|
handler: async (args, appState) => {
|
|
if (!appState.currentStory) {
|
|
return 'Error: No story selected';
|
|
}
|
|
const existingLocation = appState.currentStory.locations.find(l => l.name === args.name.trim());
|
|
if (existingLocation) {
|
|
// Edit existing location
|
|
const definedUpdates: Partial<Location> = {};
|
|
if (args.shortDescription) {
|
|
definedUpdates.shortDescription = args.shortDescription;
|
|
}
|
|
if (args.description) {
|
|
definedUpdates.description = args.description;
|
|
}
|
|
if (args.scale != null) {
|
|
definedUpdates.scale = args.scale;
|
|
}
|
|
appState.dispatch({
|
|
type: 'EDIT_LOCATION',
|
|
storyId: appState.currentStory.id,
|
|
locationId: existingLocation.id,
|
|
updates: definedUpdates,
|
|
});
|
|
appState.dispatch({
|
|
type: 'SET_CURRENT_TAB',
|
|
id: appState.currentStory.id,
|
|
tab: 'locations'
|
|
});
|
|
return `Location "${args.name.trim()}" updated successfully`;
|
|
} else {
|
|
// Add new location - validate required fields
|
|
if (!args.shortDescription) {
|
|
return 'Error: shortDescription is required when adding a new location';
|
|
}
|
|
if (args.scale == null) {
|
|
return 'Error: scale is required when adding a new location';
|
|
}
|
|
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 or edit a location. If location exists, updates it; otherwise creates new one.',
|
|
parameters: Type.Object({
|
|
name: Type.String({ description: "The location's full name" }),
|
|
shortDescription: Type.Optional(Type.String({ description: 'A brief description of the location (one line). Required when adding a new location.' })),
|
|
description: Type.Optional(Type.String({ description: 'Optional full location description' })),
|
|
scale: Type.Optional(Type.Enum(VALID_SCALES, {
|
|
description: `Location scale (enum): ${VALID_SCALES.join(', ')}`,
|
|
})),
|
|
}),
|
|
}),
|
|
'set_lore_entry': tool({
|
|
handler: async (args, appState) => {
|
|
if (!appState.currentStory) {
|
|
return 'Error: No story selected';
|
|
}
|
|
const existingEntry = appState.currentStory.lore.find(e => e.title.toLowerCase() === args.title.trim().toLowerCase());
|
|
|
|
if (existingEntry) {
|
|
appState.dispatch({
|
|
type: 'EDIT_LORE_ENTRY',
|
|
storyId: appState.currentStory.id,
|
|
entryId: existingEntry.id,
|
|
updates: { text: args.text },
|
|
});
|
|
appState.dispatch({
|
|
type: 'SET_CURRENT_TAB',
|
|
id: appState.currentStory.id,
|
|
tab: 'lore'
|
|
});
|
|
return `Lore entry "${existingEntry.title}" updated successfully`;
|
|
} else {
|
|
appState.dispatch({
|
|
type: 'ADD_LORE_ENTRY',
|
|
storyId: appState.currentStory.id,
|
|
entry: {
|
|
id: crypto.randomUUID(),
|
|
title: args.title.trim(),
|
|
text: args.text,
|
|
},
|
|
});
|
|
appState.dispatch({
|
|
type: 'SET_CURRENT_TAB',
|
|
id: appState.currentStory.id,
|
|
tab: 'lore'
|
|
});
|
|
return `Lore entry "${args.title.trim()}" added successfully`;
|
|
}
|
|
},
|
|
description: 'Add or edit a lore entry. If entry with matching title exists, updates it; otherwise creates new one.',
|
|
parameters: Type.Object({
|
|
title: Type.String({ description: "The lore entry's title" }),
|
|
text: Type.String({ description: 'The lore entry content.' }),
|
|
}),
|
|
}),
|
|
'edit_text': tool({
|
|
handler: async (args, appState) => {
|
|
if (!appState.currentStory) {
|
|
return 'Error: No story selected';
|
|
}
|
|
|
|
// Append mode: when old_text is not provided, append new_text to the story
|
|
if (args.old_text == null) {
|
|
appState.dispatch({
|
|
type: 'EDIT_STORY',
|
|
id: appState.currentStory.id,
|
|
text: appState.currentStory.text + '\n' + args.new_text,
|
|
});
|
|
appState.dispatch({
|
|
type: 'SET_CURRENT_TAB',
|
|
id: appState.currentStory.id,
|
|
tab: 'story'
|
|
});
|
|
return 'Text appended to story successfully';
|
|
}
|
|
|
|
// Replace mode: find and replace old_text with new_text in story
|
|
const source = appState.currentStory.text;
|
|
const occurrences = source.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 story. When old_text is omitted, appends new_text to the story's end. Case-sensitive.",
|
|
parameters: Type.Object({
|
|
new_text: Type.String({ description: 'The new text to replace old_text with, or to append if old_text is omitted' }),
|
|
old_text: Type.Optional(Type.String({ description: 'The text to find and replace. If omitted, new_text will be appended' })),
|
|
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 sources: { name: string; content: string }[] = [
|
|
{ name: 'story', content: appState.currentStory.text },
|
|
...appState.currentStory.lore.flatMap(e => [
|
|
{ name: `lore:${e.title}`, content: e.title },
|
|
{ name: `lore:${e.title}`, content: e.text },
|
|
]),
|
|
...appState.currentStory.characters.flatMap(c => [
|
|
{ name: `character:${c.name}`, content: c.name },
|
|
{ name: `character:${c.name}`, content: c.shortDescription },
|
|
{ name: `character:${c.name}`, content: c.description || '' },
|
|
]),
|
|
...appState.currentStory.locations.flatMap(l => [
|
|
{ name: `location:${l.name}`, content: l.name },
|
|
{ name: `location:${l.name}`, content: l.shortDescription },
|
|
{ name: `location:${l.name}`, content: l.description || '' },
|
|
]),
|
|
];
|
|
|
|
const allMatches: { source: string; line: number; content: string }[] = [];
|
|
const pattern = new RegExp(args.pattern, args.case_sensitive ? 'g' : 'gi');
|
|
|
|
for (const src of sources) {
|
|
const lines = src.content.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (pattern.test(lines[i])) {
|
|
allMatches.push({
|
|
source: src.name,
|
|
line: i + 1,
|
|
content: lines[i].trim(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allMatches.length === 0) {
|
|
return `No matches found for pattern: ${args.pattern}`;
|
|
}
|
|
|
|
const limit = args.limit ?? 20;
|
|
const truncated = allMatches.length > limit;
|
|
const displayed = truncated ? allMatches.slice(0, limit) : allMatches;
|
|
|
|
let result = `Found ${allMatches.length} match(es) for pattern "${args.pattern}":\n`;
|
|
result += displayed.map(m => `[${m.source}] Line ${m.line}: ${m.content}`).join('\n');
|
|
if (truncated) {
|
|
result += `\n... and ${allMatches.length - limit} more match(es)`;
|
|
}
|
|
return result;
|
|
},
|
|
description: 'Search for a pattern in the story text, lore, characters, and locations',
|
|
parameters: Type.Object({
|
|
pattern: Type.String({ description: 'The JS 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 formatErrorMessage(err);
|
|
}
|
|
}
|
|
}
|