1
0
Fork 0

Compare commits

...

4 Commits

Author SHA1 Message Date
Pabloader bbe5716e40 Highlight headers 2026-03-25 09:49:30 +00:00
Pabloader 4b24ce85e0 Refactor highlight 2026-03-25 09:36:38 +00:00
Pabloader 6e61ff7194 Include the story in the context 2026-03-25 09:19:59 +00:00
Pabloader af59e03212 Proper typechecking for tools 2026-03-25 07:56:51 +00:00
12 changed files with 669 additions and 371 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=="],
} }
} }

View File

@ -0,0 +1,34 @@
.italic {
font-style: italic;
color: var(--italicColor, #AFAFAF);
}
.bold {
font-weight: bold;
}
.quote {
color: var(--quoteColor, #D4E5FF);
}
.codeBlock {
font-family: monospace;
background: var(--codeBg, #49483e);
padding: 0.5em;
display: block;
border-radius: var(--radius, 4px);
}
.inlineCode {
font-family: monospace;
background: var(--codeBg, #49483e);
padding: 0.1em 0.3em;
border-radius: 0.2em;
}
.header {
font-size: 1.4em;
font-weight: bold;
color: var(--yellow, #e6db74);
display: block;
}

76
src/common/highlight.ts Normal file
View File

@ -0,0 +1,76 @@
import styles from './assets/highlight.module.css';
export const highlight = (message: string, keepMarkup = true): string => {
let resultHTML = '';
const tokenRegex = /(\*\*?|"|```|`|(?:^|\n)# |\n)/g;
const stack: string[] = [];
let inCodeBlock = false;
let inHeader = false;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = tokenRegex.exec(message)) !== null) {
resultHTML += message.slice(lastIndex, match.index);
lastIndex = tokenRegex.lastIndex;
const token = match[0];
const isClose = stack.at(-1) === token;
const keepToken = keepMarkup || token === '"';
if (inCodeBlock) {
if (token === '```' && isClose) {
inCodeBlock = false;
stack.pop();
resultHTML += `${keepToken ? token : ''}</span>`;
} else {
resultHTML += token;
}
continue;
}
if (token.endsWith('# ')) {
if (inHeader) resultHTML += '</span>';
inHeader = true;
resultHTML += `${token.slice(0, -2)}<span class="${styles.header}">${keepMarkup ? '# ' : ''}`;
continue;
}
if (token === '\n') {
if (inHeader) {
resultHTML += `${keepMarkup ? '\n' : ''}</span>`;
inHeader = false;
} else {
resultHTML += '\n';
}
continue;
}
if (isClose) {
stack.pop();
resultHTML += `${keepToken ? token : ''}</span>`;
} else if (token === '*') {
stack.push(token);
resultHTML += `<span class="${styles.italic}">${keepToken ? token : ''}`;
} else if (token === '**') {
stack.push(token);
resultHTML += `<span class="${styles.bold}">${keepToken ? token : ''}`;
} else if (token === '"') {
stack.push(token);
resultHTML += `<span class="${styles.quote}">"`;
} else if (token === '```') {
stack.push(token);
inCodeBlock = true;
resultHTML += `<span class="${styles.codeBlock}">`;
} else if (token === '`') {
stack.push(token);
resultHTML += `<span class="${styles.inlineCode}">`;
}
}
resultHTML += message.slice(lastIndex);
if (inHeader) resultHTML += '</span>';
resultHTML += '</span>'.repeat(stack.length);
return resultHTML;
}

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

@ -20,7 +20,7 @@
.content { .content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 0 72px; padding: 0 72px 72px;
} }
.editable { .editable {

View File

@ -1,10 +1,10 @@
import { useInputState } from "@common/hooks/useInputState"; import { useInputState } from "@common/hooks/useInputState";
import { highlight } from "@common/highlight";
import { Sidebar } from "./sidebar"; import { Sidebar } from "./sidebar";
import { useAppState, type ChatMessage } from "../contexts/state"; import { useAppState, type ChatMessage } from "../contexts/state";
import styles from '../assets/chat-sidebar.module.css'; import styles from '../assets/chat-sidebar.module.css';
import { useState, useRef, useEffect, useMemo, useCallback } from "preact/hooks"; import { useState, useRef, useEffect, useMemo, useCallback } from "preact/hooks";
import LLM from "../utils/llm"; import LLM from "../utils/llm";
import { highlight } from "../utils/highlight";
import Prompt from "../utils/prompt"; import Prompt from "../utils/prompt";
import { Tools } from "../utils/tools"; import { Tools } from "../utils/tools";
import clsx from "clsx"; import clsx from "clsx";

View File

@ -1,7 +1,7 @@
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
import { highlight } from "@common/highlight";
import { useAppState, type Tab } from "../contexts/state"; import { useAppState, type Tab } from "../contexts/state";
import styles from '../assets/editor.module.css'; import styles from '../assets/editor.module.css';
import { highlight } from "../utils/highlight";
import { useMemo } from "preact/hooks"; import { useMemo } from "preact/hooks";
import { CharacterEditor } from "./character-editor"; import { CharacterEditor } from "./character-editor";
import { LocationEditor } from "./location-editor"; import { LocationEditor } from "./location-editor";

View File

@ -49,6 +49,7 @@ export interface Story {
id: string; id: string;
title: string; title: string;
text: string; text: string;
lastModifiedChunk: string;
lore: string; lore: string;
characters: Character[]; characters: Character[];
locations: Location[]; locations: Location[];
@ -73,7 +74,7 @@ interface IState {
type Action = type Action =
| { type: 'CREATE_STORY'; title: string } | { type: 'CREATE_STORY'; title: string }
| { type: 'RENAME_STORY'; id: string; title: string } | { type: 'RENAME_STORY'; id: string; title: string }
| { type: 'EDIT_STORY'; id: string; text: string } | { type: 'EDIT_STORY'; id: string; text: string; lastModifiedChunk?: string }
| { type: 'EDIT_LORE'; id: string; lore: string } | { type: 'EDIT_LORE'; id: string; lore: string }
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string } | { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
| { type: 'SET_CURRENT_TAB'; id: string; tab: Tab } | { type: 'SET_CURRENT_TAB'; id: string; tab: Tab }
@ -116,6 +117,7 @@ function reducer(state: IState, action: Action): IState {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: action.title, title: action.title,
text: '', text: '',
lastModifiedChunk: '',
lore: '', lore: '',
characters: [], characters: [],
locations: [], locations: [],
@ -140,7 +142,11 @@ function reducer(state: IState, action: Action): IState {
return { return {
...state, ...state,
stories: state.stories.map(s => stories: state.stories.map(s =>
s.id === action.id ? { ...s, text: action.text } : s s.id === action.id ? {
...s,
text: action.text,
lastModifiedChunk: action.lastModifiedChunk ?? s.lastModifiedChunk
} : s
), ),
}; };
} }

View File

@ -1,63 +0,0 @@
export const highlight = (message: string, keepMarkup = true): string => {
let resultHTML = '';
const replaceRegex = /(\*\*?|"|```|`)/ig;
const splitToken = '___SPLIT_AWOORWA___';
const preparedMessage = message.replace(replaceRegex, `${splitToken}$1${splitToken}`);
const parts = preparedMessage.split(splitToken);
const stack: string[] = [];
let inCodeBlock = false;
for (const part of parts) {
const isClose = stack.at(-1) === part;
const keepPart = keepMarkup || part === '"';
if (inCodeBlock) {
if (part === '```' && isClose) {
inCodeBlock = false;
stack.pop();
resultHTML += `${keepPart ? part : ''}</span>`;
} else {
resultHTML += part;
}
continue;
}
if (isClose) {
stack.pop();
if (part === '*' || part === '**' || part === '"' || part === '`' || part === '```') {
resultHTML += `${keepPart ? part : ''}</span>`;
}
} else {
if (part === '*') {
stack.push(part);
resultHTML += `<span style="font-style:italic;color:var(--italicColor)">${keepPart ? part : ''}`;
} else if (part === '**') {
stack.push(part);
resultHTML += `<span style="font-weight:bold">${keepPart ? part : ''}`;
} else if (part === '"') {
stack.push(part);
resultHTML += `<span style="color:var(--quoteColor)">"`;
} else if (part === '```') {
stack.push(part);
inCodeBlock = true;
resultHTML += `<span style="font-family:monospace;background:var(--codeBg);padding:0.5em;display:block;border-radius:var(--radius)">`;
} else if (part === '`') {
stack.push(part);
resultHTML += `<span style="font-family:monospace;background:var(--codeBg);padding:0.1em 0.3em;border-radius:0.2em">`;
} else {
resultHTML += part;
}
}
}
while (stack.length) {
const part = stack.pop();
if (part === '*' || part === '**' || part === '"' || part === '`' || part === '```') {
resultHTML += `</span>`;
}
}
return resultHTML;
}

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

@ -3,6 +3,62 @@ import { type AppState, LocationScale } from "../contexts/state";
import { Tools } from "./tools"; import { Tools } from "./tools";
namespace Prompt { namespace Prompt {
function approxTokens(text: string): number {
return text.length / 3;
}
export function formatStoryText(text: string, tokenBudget: number): string {
if (!text) return '';
if (approxTokens(text) <= tokenBudget / 2) {
return text;
}
const lines = text.split('\n');
const separator = '[...]';
// Max chars for content = half-budget tokens * 3 chars/token, minus separator overhead
const targetChars = Math.floor(tokenBudget / 2 * 3) - separator.length - 2;
if (targetChars <= 0) {
return separator;
}
// 1/3 of budget for start, 2/3 for end
const startCharsMax = Math.floor(targetChars / 3);
const endCharsMax = targetChars - startCharsMax;
let startCharsUsed = 0;
let startEnd = 0;
for (let i = 0; i < lines.length; i++) {
const lineLen = lines[i].length + 1; // +1 for '\n'
if (startCharsUsed + lineLen > startCharsMax) break;
startCharsUsed += lineLen;
startEnd = i + 1;
}
let endCharsUsed = 0;
let endStart = lines.length;
for (let i = lines.length - 1; i >= startEnd; i--) {
const lineLen = lines[i].length + 1;
if (endCharsUsed + lineLen > endCharsMax) break;
endCharsUsed += lineLen;
endStart = i;
}
if (startEnd >= endStart) {
return text; // All lines fit after all
}
const startPart = lines.slice(0, startEnd).join('\n');
const endPart = lines.slice(endStart).join('\n');
const parts: string[] = [];
if (startPart) parts.push(startPart);
parts.push(separator);
if (endPart) parts.push(endPart);
return parts.join('\n');
}
export function formatCharactersMarkdown(state: AppState): string { export function formatCharactersMarkdown(state: AppState): string {
const { currentStory } = state; const { currentStory } = state;
if (!currentStory || !currentStory.characters?.length) { if (!currentStory || !currentStory.characters?.length) {
@ -57,7 +113,7 @@ namespace Prompt {
return lines.join('\n'); return lines.join('\n');
} }
export function formatSystemPrompt(state: AppState): string { export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
const { currentStory } = state; const { currentStory } = state;
if (!currentStory) { if (!currentStory) {
return state.systemInstruction; return state.systemInstruction;
@ -68,7 +124,7 @@ namespace Prompt {
parts.push(`# ${currentStory.title}`); parts.push(`# ${currentStory.title}`);
if (currentStory.lore) { if (currentStory.lore) {
parts.push('## Lore\n' + currentStory.lore); parts.push(`## Lore\n${currentStory.lore}`);
} }
const charactersSection = formatCharactersMarkdown(state); const charactersSection = formatCharactersMarkdown(state);
@ -81,6 +137,17 @@ namespace Prompt {
parts.push(locationsSection); parts.push(locationsSection);
} }
if (currentStory.text && storyTokenBudget > 0) {
const storyText = formatStoryText(currentStory.text, storyTokenBudget);
if (storyText) {
parts.push(`## Story\n${storyText}`);
}
}
if (currentStory.lastModifiedChunk) {
parts.push(`## Last Modified Chunk\n${currentStory.lastModifiedChunk}`);
}
return parts.join('\n\n'); return parts.join('\n\n');
} }
@ -91,9 +158,20 @@ namespace Prompt {
return null; return null;
} }
// Estimate token budget for story text
let storyTokenBudget = 0;
if (model.max_context) {
const nonStorySystem = formatSystemPrompt(state, 0);
const chatText = [...currentStory.chatMessages, ...newMessages]
.map(m => m.content)
.join('');
const maxOutput = model.max_length ?? 2048;
const otherTokens = approxTokens(nonStorySystem) + approxTokens(chatText) + maxOutput;
storyTokenBudget = model.max_context - otherTokens;
}
const messages: LLM.ChatMessage[] = [ const messages: LLM.ChatMessage[] = [
{ role: 'system', content: formatSystemPrompt(state) }, { role: 'system', content: formatSystemPrompt(state, storyTokenBudget) },
// TODO part of story
...currentStory.chatMessages, ...currentStory.chatMessages,
]; ];
@ -105,6 +183,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 : 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,25 @@ 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,
lastModifiedChunk: args.text
}); });
appState.dispatch({ appState.dispatch({
type: 'SET_CURRENT_TAB', type: 'SET_CURRENT_TAB',
@ -42,33 +39,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 +61,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 +85,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 +97,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 +146,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 +165,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 +176,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', description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
}, }),
shortDescription: { }),
type: 'string', }),
description: 'A brief description of the location (one line)', 'edit_location': tool({
},
description: {
type: 'string',
description: 'Optional full location description',
},
scale: {
type: 'number',
description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
enum: VALID_SCALES
},
},
required: ['name', 'shortDescription', 'scale'],
},
},
'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,45 +218,125 @@ 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', description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
}, })),
shortDescription: { }),
type: 'string', }),
description: 'Brief description of the location (one line)', 'edit_story': tool({
}, handler: async (args, appState) => {
description: { if (!appState.currentStory) {
type: 'string', return 'Error: No story selected';
description: 'Full location description', }
}, const occurrences = appState.currentStory.text.split(args.old_text).length - 1;
scale: { if (occurrences === 0) {
type: 'number', return 'Error: old_text not found in story';
description: `Location scale (enum): ${SCALE_DESCRIPTION}`, }
}, if (occurrences > 1 && !args.replace_all) {
}, return 'Error: old_text appears multiple times in story';
required: ['name'], }
appState.dispatch({
type: 'EDIT_STORY',
id: appState.currentStory.id,
text: appState.currentStory.text.replaceAll(args.old_text, args.new_text),
lastModifiedChunk: args.replace_all ? undefined : 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[] { export function getTools(): LLM.Tool[] {
return Object.entries(TOOLS).map(([key, tool]) => { return Object.entries(TOOLS).map(([key, tool]) => ({
return { type: 'function',
type: 'function', function: {
function: { name: key,
name: key, description: tool.description,
description: tool.description, parameters: tool.parameters,
parameters: tool.parameters, },
}, }));
};
});
} }
function parseArg(arg: unknown): unknown { function parseArg(arg: unknown): unknown {
@ -418,17 +362,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;
} }