1
0
Fork 0

Proper typechecking for tools

This commit is contained in:
Pabloader 2026-03-25 07:56:51 +00:00
parent d1325b33b7
commit af59e03212
5 changed files with 362 additions and 290 deletions

View File

@ -14,11 +14,11 @@
"ace-builds": "1.36.3", "ace-builds": "1.36.3",
"clsx": "2.1.1", "clsx": "2.1.1",
"delay": "6.0.0", "delay": "6.0.0",
"lucide-preact": "^0.577.0", "lucide-preact": "0.577.0",
"preact": "10.22.0", "preact": "10.22.0",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.10", "@types/bun": "latest",
"@types/html-minifier": "4.0.5", "@types/html-minifier": "4.0.5",
"@types/inquirer": "9.0.7", "@types/inquirer": "9.0.7",
"@types/web-bluetooth": "0.0.21", "@types/web-bluetooth": "0.0.21",
@ -49,7 +49,7 @@
"@inquirer/type": ["@inquirer/type@1.4.0", "", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-AjOqykVyjdJQvtfkNDGUyMYGF8xN50VUxftCQWsOyIo4DFRLr6VQhW0VItGI1JIyQGCGgIpKa7hMMwNhZb4OIw=="], "@inquirer/type": ["@inquirer/type@1.4.0", "", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-AjOqykVyjdJQvtfkNDGUyMYGF8xN50VUxftCQWsOyIo4DFRLr6VQhW0VItGI1JIyQGCGgIpKa7hMMwNhZb4OIw=="],
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/clean-css": ["@types/clean-css@4.2.11", "", { "dependencies": { "@types/node": "*", "source-map": "^0.6.0" } }, "sha512-Y8n81lQVTAfP2TOdtJJEsCoYl1AnOkqDqMvXb9/7pfgZZ7r8YrEyurrAvAoAjHOGXKRybay+5CsExqIH6liccw=="], "@types/clean-css": ["@types/clean-css@4.2.11", "", { "dependencies": { "@types/node": "*", "source-map": "^0.6.0" } }, "sha512-Y8n81lQVTAfP2TOdtJJEsCoYl1AnOkqDqMvXb9/7pfgZZ7r8YrEyurrAvAoAjHOGXKRybay+5CsExqIH6liccw=="],
@ -81,7 +81,7 @@
"browser-detect": ["browser-detect@0.2.28", "", { "dependencies": { "core-js": "^2.5.7" } }, "sha512-KeWGHqYQmHDkCFG2dIiX/2wFUgqevbw/rd6wNi9N6rZbaSJFtG5kel0HtprRwCGp8sqpQP79LzDJXf/WCx4WAw=="], "browser-detect": ["browser-detect@0.2.28", "", { "dependencies": { "core-js": "^2.5.7" } }, "sha512-KeWGHqYQmHDkCFG2dIiX/2wFUgqevbw/rd6wNi9N6rZbaSJFtG5kel0HtprRwCGp8sqpQP79LzDJXf/WCx4WAw=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"camel-case": ["camel-case@3.0.0", "", { "dependencies": { "no-case": "^2.2.0", "upper-case": "^1.1.1" } }, "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w=="], "camel-case": ["camel-case@3.0.0", "", { "dependencies": { "no-case": "^2.2.0", "upper-case": "^1.1.1" } }, "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w=="],
@ -163,8 +163,6 @@
"@types/through/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], "@types/through/@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="],
"bun-types/@types/node": ["@types/node@20.14.10", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ=="],
"html-minifier/uglify-js": ["uglify-js@3.18.0", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A=="], "html-minifier/uglify-js": ["uglify-js@3.18.0", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A=="],
"@inquirer/core/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@inquirer/core/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
@ -174,7 +172,5 @@
"@types/mute-stream/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@types/mute-stream/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@types/through/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "@types/through/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
} }
} }

258
src/common/typebox.ts Normal file
View File

@ -0,0 +1,258 @@
const optional = Symbol();
const GlobalObject = Object;
const GlobalNumber = Number;
const GlobalArray = Array;
export namespace Type {
export function String(args: { description?: string, enum?: string[] } = {}) {
const result: TString = {
type: 'string',
};
if (args.enum) {
result.enum = args.enum;
}
if (args.description) {
result.description = args.description;
}
return result;
}
export function Number(args: { description?: string, enum?: number[] } = {}) {
const result: TNumber = {
type: 'number',
};
if (args.enum) {
result.enum = args.enum;
}
if (args.description) {
result.description = args.description;
}
return result;
}
export function Integer(args: { description?: string, enum?: number[] } = {}) {
const result: TNumber = {
type: 'integer',
};
if (args.enum) {
result.enum = args.enum;
}
if (args.description) {
result.description = args.description;
}
return result;
}
export function Boolean(args: { description?: string } = {}) {
const result: TBoolean = {
type: 'boolean',
};
if (args.description) {
result.description = args.description;
}
return result;
}
export function Array<T extends TScheme = TScheme>(items: T, args: { description?: string } = {}) {
const result: TArray<T> = {
type: 'array',
items,
}
if (args.description) {
result.description = args.description;
}
return result;
}
export function Optional<T extends TScheme = TScheme>(scheme: T): TOptional<T> {
const result = { ...scheme };
GlobalObject.defineProperty(result, optional, { value: true, enumerable: false, writable: false, configurable: false });
return result as unknown as TOptional<T>;
}
export function Object<T extends Record<string, TScheme> = Record<string, TScheme>>(properties: T, args: { description?: string } = {}) {
const result: TObject<T> = {
type: 'object',
properties,
required: GlobalObject.keys(properties)
.filter(key => !(properties[key] as any)[optional]),
}
if (args.description) {
result.description = args.description;
}
return result;
}
type TEnumType<T extends (string | number) = (string | number)> =
T extends string ? TString<T> :
T extends number ? TNumber<T> : never;
export function Enum<T extends TString>(items: Static<T>[], args?: { description?: string }): T;
export function Enum<T extends TNumber>(items: Static<T>[], args?: { description?: string }): T;
export function Enum<T extends TEnumType = TEnumType>(items: Static<T>[], args: { description?: string } = {}) {
if (typeof items?.[0] === 'number') {
return Number({ enum: items.map(item => +item!), ...args });
}
return String({ enum: items.map(item => item!.toString()), ...args });
}
export interface CheckError {
path: string;
message: string;
}
function check(scheme: TScheme, value: unknown, path: string): CheckError[] {
if (value == null) {
if ((scheme as any)[optional]) return [];
return [{ path, message: `Expected ${scheme.type} at ${path}, got ${value}` }];
}
switch (scheme.type) {
case 'string': {
if (typeof value !== 'string') {
return [{ path, message: `Expected string at ${path}, got ${typeof value}` }];
}
if (scheme.enum && !scheme.enum.includes(value)) {
return [{ path, message: `Expected one of [${scheme.enum.join(', ')}] at ${path}, got "${value}"` }];
}
return [];
}
case 'number':
case 'integer': {
if (typeof value !== 'number') {
return [{ path, message: `Expected number at ${path}, got ${typeof value}` }];
}
if (scheme.type === 'integer' && !GlobalNumber.isInteger(value)) {
return [{ path, message: `Expected integer at ${path}, got ${value}` }];
}
const s = scheme as TNumber;
if (s.enum && !s.enum.includes(value)) {
return [{ path, message: `Expected one of [${s.enum.join(', ')}] at ${path}, got ${value}` }];
}
return [];
}
case 'boolean': {
if (typeof value !== 'boolean') {
return [{ path, message: `Expected boolean at ${path}, got ${typeof value}` }];
}
return [];
}
case 'array': {
if (!GlobalArray.isArray(value)) {
return [{ path, message: `Expected array at ${path}, got ${typeof value}` }];
}
const s = scheme as TArray;
return value.flatMap((item, i) => check(s.items, item, `${path}[${i}]`));
}
case 'object': {
if (typeof value !== 'object' || GlobalArray.isArray(value)) {
return [{ path, message: `Expected object at ${path}, got ${typeof value}` }];
}
const s = scheme as TObject;
const errors: CheckError[] = [];
for (const key of s.required || []) {
if (!(key in (value as Record<string, unknown>))) {
errors.push({ path: `${path}.${key}`, message: `Missing required property ${path}.${key}` });
}
}
for (const [key, propScheme] of GlobalObject.entries(s.properties)) {
const propValue = (value as Record<string, unknown>)[key];
if (propValue === undefined) continue;
errors.push(...check(propScheme, propValue, `${path}.${key}`));
}
return errors;
}
default:
return [];
}
}
export function Check(scheme: TScheme, value: unknown): CheckError[] {
return check(scheme, value, '$');
}
}
export interface TString<T extends string = string> {
type: 'string';
enum?: T[];
description?: string;
}
export interface TNumber<T extends number = number> {
type: 'number' | 'integer';
enum?: T[];
description?: string;
}
export interface TBoolean {
type: 'boolean';
description?: string;
}
export interface TArray<T extends TScheme = TScheme> {
type: 'array';
description?: string;
items: T;
}
export interface TObject<T extends Record<string, TScheme> = Record<string, TScheme>> {
type: 'object';
description?: string;
properties: T;
required?: string[];
}
export interface TOptionalString<T extends string = string> extends TString<T> {
[optional]: true;
}
export interface TOptionalNumber<T extends number = number> extends TNumber<T> {
[optional]: true;
}
export interface TOptionalBoolean extends TBoolean {
[optional]: true;
}
export interface TOptionalArray<T extends TScheme = TScheme> extends TArray<T> {
[optional]: true;
}
export interface TOptionalObject<T extends Record<string, TScheme> = Record<string, TScheme>> extends TObject<T> {
[optional]: true;
}
export type TOptional<T extends TScheme = TScheme> =
T extends TString<infer S> ? TOptionalString<S> :
T extends TNumber<infer N> ? TOptionalNumber<N> :
T extends TBoolean ? TOptionalBoolean :
T extends TArray<infer I> ? TOptionalArray<I> :
T extends TObject<infer P> ? TOptionalObject<P> :
never;
export type IsOptional<T> = T extends { [optional]: true } ? true : false;
export type TScheme = TString | TNumber | TBoolean | TArray | TObject | TOptionalString | TOptionalNumber | TOptionalBoolean | TOptionalArray | TOptionalObject;
type Prettify<T> = { [K in keyof T]: T[K] } & {};
type RequiredKeys<T extends Record<string, TScheme>> = {
[K in keyof T]: IsOptional<T[K]> extends true ? never : K
}[keyof T];
type OptionalKeys<T extends Record<string, TScheme>> = {
[K in keyof T]: IsOptional<T[K]> extends true ? K : never
}[keyof T];
type StaticObject<T extends Record<string, TScheme>> = Prettify<
{ [K in RequiredKeys<T>]: Static<T[K]> } &
{ [K in OptionalKeys<T>]?: Static<T[K]> }
>;
export type Static<T extends TScheme> =
T extends TString<infer S> ? S :
T extends TNumber<infer N> ? N :
T extends TBoolean ? boolean :
T extends TArray<infer I> ? Static<I>[] :
T extends TObject<infer P> ? StaticObject<P> :
never;

View File

@ -1,5 +1,5 @@
import { formatError } from '@common/errors';
import SSE from '@common/sse'; import SSE from '@common/sse';
import type { TObject } from '@common/typebox';
namespace LLM { namespace LLM {
export interface Connection { export interface Connection {
@ -41,45 +41,12 @@ namespace LLM {
export type ChatMessage = ChatMessageUser | ChatMessageAssistant | ChatMessageSystem | ChatMessageTool; export type ChatMessage = ChatMessageUser | ChatMessageAssistant | ChatMessageSystem | ChatMessageTool;
export interface ToolStringParameter {
type: 'string';
enum?: string[];
description?: string;
}
export interface ToolNumberParameter {
type: 'number' | 'integer';
enum?: number[];
description?: string;
}
export interface ToolBooleanParameter {
type: 'boolean';
enum?: boolean[];
description?: string;
}
export interface ToolArrayParameter {
type: 'array';
description?: string;
items: ToolParameter;
}
export interface ToolObjectParameter {
type: 'object';
description?: string;
properties: Record<string, ToolParameter>;
required?: string[];
}
export type ToolParameter = ToolStringParameter | ToolNumberParameter | ToolBooleanParameter | ToolArrayParameter | ToolObjectParameter;
export interface Tool { export interface Tool {
type: 'function'; type: 'function';
function: { function: {
name: string; name: string;
description?: string; description?: string;
parameters: ToolObjectParameter; parameters: TObject;
}; };
} }

View File

@ -105,6 +105,7 @@ namespace Prompt {
tools: Tools.getTools(), tools: Tools.getTools(),
banned_tokens: state.bannedTokens, banned_tokens: state.bannedTokens,
enable_thinking: enableThinking, enable_thinking: enableThinking,
max_tokens: model.max_length ? model.max_length / 2 : 2048,
}; };
} }
} }

View File

@ -1,4 +1,5 @@
import { formatError } from "@common/errors"; 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 { LocationScale, type AppState, type Character, type Location } from "../contexts/state";
import type LLM from "./llm"; import type LLM from "./llm";
@ -10,29 +11,24 @@ const SCALE_DESCRIPTION = Object.entries(LocationScale)
const VALID_SCALES = Object.values(LocationScale).filter(v => typeof v === 'number'); const VALID_SCALES = Object.values(LocationScale).filter(v => typeof v === 'number');
export namespace Tools { export namespace Tools {
interface Tool { interface Tool<T extends TObject = TObject> {
description: string; description: string;
parameters: LLM.ToolObjectParameter; parameters: T;
handler(args: string | Record<string, any>, appState: AppState): unknown; handler(args: Static<T>, appState: AppState): unknown;
} }
const tool = <T extends TObject = TObject>(t: Tool<T>): Tool<T> => t;
const TOOLS: Record<string, Tool> = { const TOOLS: Record<string, Tool> = {
'append_to_story': { 'append_to_story': tool({
handler: async (args, appState) => { 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) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
appState.dispatch({ appState.dispatch({
type: 'EDIT_STORY', type: 'EDIT_STORY',
id: appState.currentStory.id, id: appState.currentStory.id,
text: appState.currentStory.text + text text: appState.currentStory.text + args.text
}); });
appState.dispatch({ appState.dispatch({
type: 'SET_CURRENT_TAB', type: 'SET_CURRENT_TAB',
@ -42,33 +38,19 @@ export namespace Tools {
return 'Text appended successfully'; return 'Text appended successfully';
}, },
description: 'Append text to the current story', description: 'Append text to the current story',
parameters: { parameters: Type.Object({
type: 'object', text: Type.String({ description: 'The text to append to the story' }),
properties: { }),
text: { }),
type: 'string', 'append_to_lore': tool({
description: 'The text to append to the story',
},
},
required: ['text'],
},
},
'append_to_lore': {
handler: async (args, appState) => { 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) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
appState.dispatch({ appState.dispatch({
type: 'EDIT_LORE', type: 'EDIT_LORE',
id: appState.currentStory.id, id: appState.currentStory.id,
lore: appState.currentStory.lore + text lore: appState.currentStory.lore + args.text
}); });
appState.dispatch({ appState.dispatch({
type: 'SET_CURRENT_TAB', type: 'SET_CURRENT_TAB',
@ -78,42 +60,19 @@ export namespace Tools {
return 'Text appended to lore successfully'; return 'Text appended to lore successfully';
}, },
description: 'Append text to the story lore', description: 'Append text to the story lore',
parameters: { parameters: Type.Object({
type: 'object', text: Type.String({ description: 'The text to append to the lore' }),
properties: { }),
text: { }),
type: 'string', 'add_character': tool({
description: 'The text to append to the lore',
},
},
required: ['text'],
},
},
'add_character': {
handler: async (args, appState) => { 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) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
// Filter out relations that reference non-existent characters // Filter out relations that reference non-existent characters
const existingCharacterNames = appState.currentStory.characters.map(c => c.name); const existingCharacterNames = appState.currentStory.characters.map(c => c.name);
const invalidRelations: string[] = []; const invalidRelations: string[] = [];
const validRelations = (relations || []).filter(rel => { const validRelations = (args.relations || []).filter(rel => {
if (!existingCharacterNames.includes(rel.name)) { if (!existingCharacterNames.includes(rel.name)) {
invalidRelations.push(`${rel.name} (${rel.relation})`); invalidRelations.push(`${rel.name} (${rel.relation})`);
return false; return false;
@ -125,10 +84,10 @@ export namespace Tools {
storyId: appState.currentStory.id, storyId: appState.currentStory.id,
character: { character: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: name.trim(), name: args.name.trim(),
nicknames: nicknames || [], nicknames: args.nicknames || [],
shortDescription: shortDescription.trim(), shortDescription: args.shortDescription.trim(),
description: description || '', description: args.description || '',
relations: validRelations, relations: validRelations,
}, },
}); });
@ -137,75 +96,43 @@ export namespace Tools {
id: appState.currentStory.id, id: appState.currentStory.id,
tab: 'characters' tab: 'characters'
}); });
let message = `Character "${name.trim()}" added successfully`; let message = `Character "${args.name.trim()}" added successfully`;
if (invalidRelations.length > 0) { if (invalidRelations.length > 0) {
message += `. Removed invalid relations to non-existent characters: ${invalidRelations.join(', ')}`; message += `. Removed invalid relations to non-existent characters: ${invalidRelations.join(', ')}`;
} }
return message; return message;
}, },
description: 'Add a new character to the story', description: 'Add a new character to the story',
parameters: { parameters: Type.Object({
type: 'object', name: Type.String({ description: "The character's full name" }),
properties: { shortDescription: Type.String({ description: 'A brief description of the character (one line)' }),
name: { nicknames: Type.Optional(Type.Array(Type.String(), { description: 'Optional list of nicknames' })),
type: 'string', description: Type.Optional(Type.String({ description: 'Optional full character description' })),
description: 'The character\'s full name', relations: Type.Optional(Type.Array(
}, Type.Object({
shortDescription: { name: Type.String({ description: 'Related character name' }),
type: 'string', relation: Type.String({ description: 'Relationship type' }),
description: 'A brief description of the character (one line)', }),
}, { description: 'Optional list of relationships with other characters' }
nicknames: { )),
type: 'array', }),
items: { type: 'string' }, }),
description: 'Optional list of nicknames', 'edit_character': tool({
},
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) => { 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) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
const character = appState.currentStory.characters.find(c => c.name === name.trim()); const character = appState.currentStory.characters.find(c => c.name === args.name.trim());
if (!character) { if (!character) {
return `Error: Character "${name.trim()}" not found`; return `Error: Character "${args.name.trim()}" not found`;
} }
// Only include defined values to avoid setting fields to undefined // Only include defined values to avoid setting fields to undefined
const definedUpdates: Partial<Character> = {}; const definedUpdates: Partial<Character> = {};
if (shortDescription !== undefined) { if (args.shortDescription !== undefined) {
definedUpdates.shortDescription = shortDescription; definedUpdates.shortDescription = args.shortDescription;
} }
if (description !== undefined) { if (args.description !== undefined) {
definedUpdates.description = description; definedUpdates.description = args.description;
} }
appState.dispatch({ appState.dispatch({
type: 'EDIT_CHARACTER', type: 'EDIT_CHARACTER',
@ -218,51 +145,17 @@ export namespace Tools {
id: appState.currentStory.id, id: appState.currentStory.id,
tab: 'characters' tab: 'characters'
}); });
return `Character "${name.trim()}" updated successfully`; return `Character "${args.name.trim()}" updated successfully`;
}, },
description: 'Edit an existing character\'s description', description: "Edit an existing character's description",
parameters: { parameters: Type.Object({
type: 'object', name: Type.String({ description: "The character's full name to identify which character to edit" }),
properties: { shortDescription: Type.Optional(Type.String({ description: 'Brief description of the character (one line)' })),
name: { description: Type.Optional(Type.String({ description: 'Full character description' })),
type: 'string', }),
description: 'The character\'s full name to identify which character to edit', }),
}, 'add_location': tool({
shortDescription: {
type: 'string',
description: 'Brief description of the character (one line)',
},
description: {
type: 'string',
description: 'Full character description',
},
},
required: ['name'],
},
},
'add_location': {
handler: async (args, appState) => { handler: async (args, appState) => {
if (!args || typeof args !== 'object') {
return 'Error: Missing required arguments';
}
const { name, shortDescription, description, scale } = args as {
name: string;
shortDescription: string;
description?: string;
scale: number;
};
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 (typeof scale !== 'number') {
return 'Error: Argument "scale" must be a number';
}
if (!VALID_SCALES.includes(scale)) {
return `Error: Argument "scale" must be a valid LocationScale value (${VALID_SCALES.join(', ')})`;
}
if (!appState.currentStory) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
@ -271,10 +164,10 @@ export namespace Tools {
storyId: appState.currentStory.id, storyId: appState.currentStory.id,
location: { location: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: name.trim(), name: args.name.trim(),
shortDescription: shortDescription.trim(), shortDescription: args.shortDescription.trim(),
description: description || '', description: args.description || '',
scale: scale as LocationScale, scale: args.scale,
}, },
}); });
appState.dispatch({ appState.dispatch({
@ -282,66 +175,36 @@ export namespace Tools {
id: appState.currentStory.id, id: appState.currentStory.id,
tab: 'locations' tab: 'locations'
}); });
return `Location "${name.trim()}" added successfully`; return `Location "${args.name.trim()}" added successfully`;
}, },
description: 'Add a new location to the story', description: 'Add a new location to the story',
parameters: { parameters: Type.Object({
type: 'object', name: Type.String({ description: "The location's full name" }),
properties: { shortDescription: Type.String({ description: 'A brief description of the location (one line)' }),
name: { description: Type.Optional(Type.String({ description: 'Optional full location description' })),
type: 'string', scale: Type.Enum(VALID_SCALES, {
description: 'The location\'s full name',
},
shortDescription: {
type: 'string',
description: 'A brief description of the location (one line)',
},
description: {
type: 'string',
description: 'Optional full location description',
},
scale: {
type: 'number',
description: `Location scale (enum): ${SCALE_DESCRIPTION}`, description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
enum: VALID_SCALES }),
}, }),
}, }),
required: ['name', 'shortDescription', 'scale'], 'edit_location': tool({
},
},
'edit_location': {
handler: async (args, appState) => { handler: async (args, appState) => {
if (!args || typeof args !== 'object') {
return 'Error: Missing required arguments';
}
const { name, shortDescription, description, scale } = args as {
name: string;
shortDescription?: string;
description?: string;
scale?: number;
};
if (typeof name !== 'string' || !name.trim()) {
return 'Error: Argument "name" must be a non-empty string';
}
if (!appState.currentStory) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
const location = appState.currentStory.locations.find(l => l.name === name.trim()); const location = appState.currentStory.locations.find(l => l.name === args.name.trim());
if (!location) { if (!location) {
return `Error: Location "${name.trim()}" not found`; return `Error: Location "${args.name.trim()}" not found`;
} }
const definedUpdates: Partial<Location> = {}; const definedUpdates: Partial<Location> = {};
if (shortDescription !== undefined) { if (args.shortDescription !== undefined) {
definedUpdates.shortDescription = shortDescription; definedUpdates.shortDescription = args.shortDescription;
} }
if (description !== undefined) { if (args.description !== undefined) {
definedUpdates.description = description; definedUpdates.description = args.description;
} }
if (scale !== undefined) { if (args.scale !== undefined) {
if (!VALID_SCALES.includes(scale)) { definedUpdates.scale = args.scale;
return `Error: Argument "scale" must be a valid LocationScale value (${VALID_SCALES.join(', ')})`;
}
definedUpdates.scale = scale as LocationScale;
} }
appState.dispatch({ appState.dispatch({
type: 'EDIT_LOCATION', type: 'EDIT_LOCATION',
@ -354,32 +217,18 @@ export namespace Tools {
id: appState.currentStory.id, id: appState.currentStory.id,
tab: 'locations' tab: 'locations'
}); });
return `Location "${name.trim()}" updated successfully`; return `Location "${args.name.trim()}" updated successfully`;
}, },
description: 'Edit an existing location\'s description', description: "Edit an existing location's description",
parameters: { parameters: Type.Object({
type: 'object', name: Type.String({ description: "The location's full name to identify which location to edit" }),
properties: { shortDescription: Type.Optional(Type.String({ description: 'Brief description of the location (one line)' })),
name: { description: Type.Optional(Type.String({ description: 'Full location description' })),
type: 'string', scale: Type.Optional(Type.Enum(VALID_SCALES, {
description: 'The location\'s full name to identify which location to edit',
},
shortDescription: {
type: 'string',
description: 'Brief description of the location (one line)',
},
description: {
type: 'string',
description: 'Full location description',
},
scale: {
type: 'number',
description: `Location scale (enum): ${SCALE_DESCRIPTION}`, description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
}, })),
}, }),
required: ['name'], })
},
}
}; };
export function getTools(): LLM.Tool[] { export function getTools(): LLM.Tool[] {
@ -418,17 +267,18 @@ export namespace Tools {
const { function: fn } = toolCall; const { function: fn } = toolCall;
const args = parseArg(fn.arguments); const args = parseArg(fn.arguments);
if (!args || typeof args !== 'object') { const tool = TOOLS[fn.name];
return 'Error: Arguments must be an object'; if (!tool) {
}
const handler = TOOLS[fn.name]?.handler;
if (!handler) {
return `Unknown tool: ${fn.name}`; 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 { try {
const result = await handler(args, appState); const result = await tool.handler(args as any, appState);
if (typeof result === 'string') { if (typeof result === 'string') {
return result; return result;
} }