1
0
Fork 0

Compare commits

..

2 Commits

Author SHA1 Message Date
Pabloader e39de39e35 Refactor the tools 2026-03-25 21:27:53 +00:00
Pabloader 951c13022c Add story duplication and fix switching 2026-03-25 19:30:26 +00:00
5 changed files with 309 additions and 164 deletions

View File

@ -65,10 +65,10 @@ export const highlight = (message: string, keepMarkup = true): string => {
} else if (token === '```') { } else if (token === '```') {
stack.push(token); stack.push(token);
inCodeBlock = true; inCodeBlock = true;
resultHTML += `<span class="${styles.codeBlock}">`; resultHTML += `<span class="${styles.codeBlock}">${keepToken ? token : ''}`;
} else if (token === '`') { } else if (token === '`') {
stack.push(token); stack.push(token);
resultHTML += `<span class="${styles.inlineCode}">`; resultHTML += `<span class="${styles.inlineCode}">${keepToken ? token : ''}`;
} }
} }

View File

@ -20,27 +20,26 @@ const TABS: { id: Tab; label: string }[] = [
export const Editor = () => { export const Editor = () => {
const { currentStory, dispatch } = useAppState(); const { currentStory, dispatch } = useAppState();
if (!currentStory) {
return <div class={styles.editor} />;
}
const handleInput = useInputCallback((text: string) => { const handleInput = useInputCallback((text: string) => {
if (!currentStory) return;
dispatch({ dispatch({
type: 'EDIT_STORY', type: 'EDIT_STORY',
id: currentStory.id, id: currentStory.id,
text, text,
}); });
}, []); }, [currentStory?.id]);
const handleLoreInput = useInputCallback((lore: string) => { const handleLoreInput = useInputCallback((lore: string) => {
if (!currentStory) return;
dispatch({ dispatch({
type: 'EDIT_LORE', type: 'EDIT_LORE',
id: currentStory.id, id: currentStory.id,
lore, lore,
}); });
}, []); }, [currentStory?.id]);
const handleTabChange = (tab: Tab) => { const handleTabChange = (tab: Tab) => {
if (!currentStory) return;
dispatch({ dispatch({
type: 'SET_CURRENT_TAB', type: 'SET_CURRENT_TAB',
id: currentStory.id, id: currentStory.id,
@ -48,8 +47,12 @@ export const Editor = () => {
}); });
}; };
const storyValue = useMemo(() => highlight(currentStory.text), [currentStory.text]); const storyValue = useMemo(() => currentStory ? highlight(currentStory.text) : '', [currentStory?.text]);
const loreValue = useMemo(() => highlight(currentStory.lore), [currentStory.lore]); const loreValue = useMemo(() => currentStory ? highlight(currentStory.lore) : '', [currentStory?.lore]);
if (!currentStory) {
return <div class={styles.editor} />;
}
return ( return (
<div class={styles.editor}> <div class={styles.editor}>

View File

@ -4,10 +4,10 @@ import { ConnectionSettingsModal } from "./connection-settings-modal";
import { SettingsModal } from "./settings-modal"; import { SettingsModal } from "./settings-modal";
import { useAppState } from "../contexts/state"; import { useAppState } from "../contexts/state";
import { useBool } from "@common/hooks/useBool"; import { useBool } from "@common/hooks/useBool";
import { useInputState } from "@common/hooks/useInputState";
import type { Story } from "../contexts/state"; import type { Story } from "../contexts/state";
import styles from '../assets/menu-sidebar.module.css'; import styles from '../assets/menu-sidebar.module.css';
import { useState } from "preact/hooks"; import { Pencil, X, Plus, Plug, Settings, Copy } from "lucide-preact";
import { Pencil, X, Plus, Plug, Settings } from "lucide-preact";
// ─── Story Item ─────────────────────────────────────────────────────────────── // ─── Story Item ───────────────────────────────────────────────────────────────
@ -17,17 +17,18 @@ interface StoryItemProps {
onSelect: () => void; onSelect: () => void;
onRename: (newTitle: string) => void; onRename: (newTitle: string) => void;
onDelete: () => void; onDelete: () => void;
onDuplicate: () => void;
} }
const StoryItem = ({ story, active, onSelect, onRename, onDelete }: StoryItemProps) => { const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }: StoryItemProps) => {
const [isEditing, setIsEditing] = useState(false); const isEditing = useBool(false);
const [editTitle, setEditTitle] = useState(story.title); const [editTitle, setEditTitle] = useInputState(story.title);
const handleSubmit = () => { const handleSubmit = () => {
if (editTitle.trim()) { if (editTitle.trim()) {
onRename(editTitle.trim()); onRename(editTitle.trim());
} }
setIsEditing(false); isEditing.setFalse();
}; };
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@ -35,7 +36,7 @@ const StoryItem = ({ story, active, onSelect, onRename, onDelete }: StoryItemPro
handleSubmit(); handleSubmit();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setEditTitle(story.title); setEditTitle(story.title);
setIsEditing(false); isEditing.setFalse();
} }
}; };
@ -43,13 +44,13 @@ const StoryItem = ({ story, active, onSelect, onRename, onDelete }: StoryItemPro
handleSubmit(); handleSubmit();
}; };
if (isEditing) { if (isEditing.value) {
return ( return (
<div class={clsx(styles.itemWrapper, active && styles.active)}> <div class={clsx(styles.itemWrapper, active && styles.active)}>
<input <input
class={styles.input} class={styles.input}
value={editTitle} value={editTitle}
onInput={(e) => setEditTitle((e.target as HTMLInputElement).value)} onInput={setEditTitle}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur} onBlur={handleBlur}
autoFocus autoFocus
@ -63,13 +64,17 @@ const StoryItem = ({ story, active, onSelect, onRename, onDelete }: StoryItemPro
<button <button
class={clsx(styles.item, active && styles.active)} class={clsx(styles.item, active && styles.active)}
onClick={onSelect} onClick={onSelect}
onDblClick={isEditing.setTrue}
> >
{story.title} {story.title}
</button> </button>
<div class={styles.actions}> <div class={styles.actions}>
<button class={styles.actionButton} onClick={() => setIsEditing(true)} title="Rename"> <button class={styles.actionButton} onClick={isEditing.setTrue} title="Rename">
<Pencil size={14} /> <Pencil size={14} />
</button> </button>
<button class={styles.actionButton} onClick={onDuplicate} title="Duplicate">
<Copy size={14} />
</button>
<button class={styles.actionButton} onClick={onDelete} title="Delete"> <button class={styles.actionButton} onClick={onDelete} title="Delete">
<X size={14} /> <X size={14} />
</button> </button>
@ -105,6 +110,10 @@ export const MenuSidebar = () => {
} }
}; };
const handleDuplicate = (id: string) => {
dispatch({ type: 'DUPLICATE_STORY', id });
};
return ( return (
<Sidebar side="left"> <Sidebar side="left">
<div class={styles.menu}> <div class={styles.menu}>
@ -120,6 +129,7 @@ export const MenuSidebar = () => {
onSelect={() => handleSelect(story.id)} onSelect={() => handleSelect(story.id)}
onRename={(newTitle) => handleRename(story.id, newTitle)} onRename={(newTitle) => handleRename(story.id, newTitle)}
onDelete={() => handleDelete(story.id)} onDelete={() => handleDelete(story.id)}
onDuplicate={() => handleDuplicate(story.id)}
/> />
))} ))}
</div> </div>

View File

@ -81,6 +81,7 @@ type Action =
| { type: 'SET_CURRENT_TAB'; id: string; tab: Tab } | { type: 'SET_CURRENT_TAB'; id: string; tab: Tab }
| { type: 'DELETE_STORY'; id: string } | { type: 'DELETE_STORY'; id: string }
| { type: 'SELECT_STORY'; id: string } | { type: 'SELECT_STORY'; id: string }
| { type: 'DUPLICATE_STORY'; id: string }
| { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage } | { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage }
| { type: 'CLEAR_CHAT'; storyId: string } | { type: 'CLEAR_CHAT'; storyId: string }
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null } | { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
@ -180,6 +181,26 @@ function reducer(state: IState, action: Action): IState {
currentStoryId: deletingCurrent ? null : state.currentStoryId, currentStoryId: deletingCurrent ? null : state.currentStoryId,
}; };
} }
case 'DUPLICATE_STORY': {
const original = state.stories.find(s => s.id === action.id);
if (!original) return state;
const newStory: Story = {
id: crypto.randomUUID(),
title: `${original.title} (Copy)`,
text: '',
lore: original.lore,
characters: original.characters,
locations: original.locations,
currentTab: 'story',
chatMessages: [],
chapters: [],
};
return {
...state,
stories: [...state.stories, newStory],
currentStoryId: newStory.id,
};
}
case 'SELECT_STORY': { case 'SELECT_STORY': {
return { return {
...state, ...state,
@ -265,11 +286,20 @@ function reducer(state: IState, action: Action): IState {
case 'DELETE_CHARACTER': { case 'DELETE_CHARACTER': {
return { return {
...state, ...state,
stories: state.stories.map(s => stories: state.stories.map(s => {
s.id === action.storyId if (s.id !== action.storyId) return s;
? { ...s, characters: s.characters.filter(c => c.id !== action.characterId) } const deletedChar = s.characters.find(c => c.id === action.characterId);
: s if (!deletedChar) return s;
), return {
...s,
characters: s.characters
.filter(c => c.id !== action.characterId)
.map(c => ({
...c,
relations: c.relations.filter(r => r.name !== deletedChar.name),
})),
};
}),
}; };
} }
case 'ADD_CHARACTER_RELATION': { case 'ADD_CHARACTER_RELATION': {

View File

@ -1,4 +1,4 @@
import { formatError } from "@common/errors"; import { formatErrorMessage } from "@common/errors";
import { Type, type Static, type TObject } from '@common/typebox'; 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,6 +10,12 @@ 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');
const SCALE_NAMES = Object.fromEntries(
Object.entries(LocationScale)
.filter(([, value]) => typeof value === 'number')
.map(([key, value]) => [value, key])
);
export namespace Tools { export namespace Tools {
interface Tool<T extends TObject = TObject> { interface Tool<T extends TObject = TObject> {
description: string; description: string;
@ -20,60 +26,7 @@ export namespace Tools {
const tool = <T extends TObject = TObject>(t: Tool<T>): Tool<T> => t; const tool = <T extends TObject = TObject>(t: Tool<T>): Tool<T> => t;
const TOOLS: Record<string, Tool> = { const TOOLS: Record<string, Tool> = {
'add_character': tool({ 'get_character': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
// Filter out relations that reference non-existent characters
const existingCharacterNames = appState.currentStory.characters.map(c => c.name);
const invalidRelations: string[] = [];
const validRelations = (args.relations || []).filter(rel => {
if (!existingCharacterNames.includes(rel.name)) {
invalidRelations.push(`${rel.name} (${rel.relation})`);
return false;
}
return true;
});
appState.dispatch({
type: 'ADD_CHARACTER',
storyId: appState.currentStory.id,
character: {
id: crypto.randomUUID(),
name: args.name.trim(),
nicknames: args.nicknames || [],
shortDescription: args.shortDescription.trim(),
description: args.description || '',
relations: validRelations,
},
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'characters'
});
let message = `Character "${args.name.trim()}" added successfully`;
if (invalidRelations.length > 0) {
message += `. Removed invalid relations to non-existent characters: ${invalidRelations.join(', ')}`;
}
return message;
},
description: 'Add a new character to the story',
parameters: Type.Object({
name: Type.String({ description: "The character's full name" }),
shortDescription: Type.String({ description: 'A brief description of the character (one line)' }),
nicknames: Type.Optional(Type.Array(Type.String(), { description: 'Optional list of nicknames' })),
description: Type.Optional(Type.String({ description: 'Optional full character description' })),
relations: Type.Optional(Type.Array(
Type.Object({
name: Type.String({ description: 'Related character name' }),
relation: Type.String({ description: 'Relationship type' }),
}),
{ description: 'Optional list of relationships with other characters' }
)),
}),
}),
'edit_character': tool({
handler: async (args, appState) => { handler: async (args, appState) => {
if (!appState.currentStory) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
@ -82,68 +35,168 @@ export namespace Tools {
if (!character) { if (!character) {
return `Error: Character "${args.name.trim()}" not found`; return `Error: Character "${args.name.trim()}" not found`;
} }
// Only include defined values to avoid setting fields to undefined let result = `# Character: ${character.name}\n\n`;
const definedUpdates: Partial<Character> = {}; if (character.nicknames && character.nicknames.length > 0) {
if (args.shortDescription !== undefined) { result += `**Nicknames:** ${character.nicknames.join(', ')}\n\n`;
definedUpdates.shortDescription = args.shortDescription;
} }
if (args.description !== undefined) { result += `**Short Description:** ${character.shortDescription}\n\n`;
definedUpdates.description = args.description; if (character.description) {
result += `**Description:** ${character.description}\n\n`;
} }
appState.dispatch({ if (character.relations && character.relations.length > 0) {
type: 'EDIT_CHARACTER', result += `**Relations:**\n`;
storyId: appState.currentStory.id, for (const rel of character.relations) {
characterId: character.id, result += `- ${rel.name}: ${rel.relation}\n`;
updates: definedUpdates, }
}); }
appState.dispatch({ return result.trim();
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'characters'
});
return `Character "${args.name.trim()}" updated successfully`;
}, },
description: "Edit an existing character's description", description: 'Get full information about a character by name',
parameters: Type.Object({ parameters: Type.Object({
name: Type.String({ description: "The character's full name to identify which character to edit" }), name: Type.String({ description: "The character's full name" }),
shortDescription: Type.Optional(Type.String({ description: 'Brief description of the character (one line)' })),
description: Type.Optional(Type.String({ description: 'Full character description' })),
}), }),
}), }),
'add_location': tool({ 'set_character': tool({
handler: async (args, appState) => { handler: async (args, appState) => {
if (!appState.currentStory) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
appState.dispatch({ const existingCharacter = appState.currentStory.characters.find(c => c.name === args.name.trim());
type: 'ADD_LOCATION',
storyId: appState.currentStory.id, if (existingCharacter) {
location: { // Edit existing character
id: crypto.randomUUID(), const definedUpdates: Partial<Character> = {};
name: args.name.trim(), if (args.shortDescription !== undefined) {
shortDescription: args.shortDescription.trim(), definedUpdates.shortDescription = args.shortDescription;
description: args.description || '', }
scale: args.scale, if (args.description !== undefined) {
}, definedUpdates.description = args.description;
}); }
appState.dispatch({ if (args.nicknames !== undefined) {
type: 'SET_CURRENT_TAB', definedUpdates.nicknames = args.nicknames;
id: appState.currentStory.id, }
tab: 'locations' if (args.relations !== undefined) {
}); return 'Error: set_character does not support updating relations. Use set_character_relation instead.';
return `Location "${args.name.trim()}" added successfully`; }
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';
}
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(),
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 a new location to the story', description: 'Add or edit a character. If character exists, updates it; otherwise creates new one.',
parameters: Type.Object({ parameters: Type.Object({
name: Type.String({ description: "The location's full name" }), name: Type.String({ description: "The character's full name" }),
shortDescription: Type.String({ description: 'A brief description of the location (one line)' }), shortDescription: Type.Optional(Type.String({ description: 'A brief description of the character (one line). Required on a new character.' })),
description: Type.Optional(Type.String({ description: 'Optional full location description' })), nicknames: Type.Optional(Type.Array(Type.String(), { description: 'Optional list of nicknames' })),
scale: Type.Enum(VALID_SCALES, { description: Type.Optional(Type.String({ description: 'Optional full character description' })),
description: `Location scale (enum): ${SCALE_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)' }
)),
}), }),
}), }),
'edit_location': tool({ '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) => { handler: async (args, appState) => {
if (!appState.currentStory) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
@ -152,34 +205,81 @@ export namespace Tools {
if (!location) { if (!location) {
return `Error: Location "${args.name.trim()}" not found`; return `Error: Location "${args.name.trim()}" not found`;
} }
const definedUpdates: Partial<Location> = {}; let result = `# Location: ${location.name}\n\n`;
if (args.shortDescription !== undefined) { result += `**Short Description:** ${location.shortDescription}\n\n`;
definedUpdates.shortDescription = args.shortDescription; if (location.description) {
result += `**Description:** ${location.description}\n\n`;
} }
if (args.description !== undefined) { result += `**Scale:** ${SCALE_NAMES[location.scale]}\n`;
definedUpdates.description = args.description; return result.trim();
}
if (args.scale !== undefined) {
definedUpdates.scale = args.scale;
}
appState.dispatch({
type: 'EDIT_LOCATION',
storyId: appState.currentStory.id,
locationId: location.id,
updates: definedUpdates,
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'locations'
});
return `Location "${args.name.trim()}" updated successfully`;
}, },
description: "Edit an existing location's description", description: 'Get full information about a location by name',
parameters: Type.Object({ parameters: Type.Object({
name: Type.String({ description: "The location's full name to identify which location to edit" }), name: Type.String({ description: "The location's full name" }),
shortDescription: Type.Optional(Type.String({ description: 'Brief description of the location (one line)' })), }),
description: Type.Optional(Type.String({ description: 'Full location description' })), }),
'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, { scale: Type.Optional(Type.Enum(VALID_SCALES, {
description: `Location scale (enum): ${SCALE_DESCRIPTION}`, description: `Location scale (enum): ${SCALE_DESCRIPTION}`,
})), })),
@ -258,7 +358,7 @@ export namespace Tools {
} }
return `${target === 'lore' ? 'Lore' : 'Story'} edited successfully`; return `${target === 'lore' ? 'Lore' : 'Story'} edited successfully`;
}, },
description: 'Replace text in the story or lore. When old_text is omitted, appends new_text to the target.', description: "Replace text in the story or lore. When old_text is omitted, appends new_text to the target's end. Case-sensitive.",
parameters: Type.Object({ parameters: Type.Object({
new_text: Type.String({ description: 'The new text to replace old_text with, or to append if old_text is omitted' }), 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' })), old_text: Type.Optional(Type.String({ description: 'The text to find and replace. If omitted, new_text will be appended' })),
@ -277,14 +377,16 @@ export namespace Tools {
const sources: { name: string; content: string }[] = [ const sources: { name: string; content: string }[] = [
{ name: 'story', content: appState.currentStory.text }, { name: 'story', content: appState.currentStory.text },
{ name: 'lore', content: appState.currentStory.lore }, { name: 'lore', content: appState.currentStory.lore },
...appState.currentStory.characters.map(c => ({ ...appState.currentStory.characters.flatMap(c => [
name: `character:${c.name}`, { name: `character:${c.name}`, content: c.name },
content: `${c.shortDescription}\n${c.description || ''}`.trim(), { name: `character:${c.name}`, content: c.shortDescription },
})), { name: `character:${c.name}`, content: c.description || '' },
...appState.currentStory.locations.map(l => ({ ]),
name: `location:${l.name}`, ...appState.currentStory.locations.flatMap(l => [
content: `${l.shortDescription}\n${l.description || ''}`.trim(), { 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 allMatches: { source: string; line: number; content: string }[] = [];
@ -320,7 +422,7 @@ export namespace Tools {
}, },
description: 'Search for a pattern in the story text, lore, characters, and locations', description: 'Search for a pattern in the story text, lore, characters, and locations',
parameters: Type.Object({ parameters: Type.Object({
pattern: Type.String({ description: 'The regex pattern to search for' }), 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)' })), 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)' })), limit: Type.Optional(Type.Integer({ description: 'Maximum number of matches to return (default: 20)' })),
}), }),
@ -378,7 +480,7 @@ export namespace Tools {
} }
return JSON.stringify(result); return JSON.stringify(result);
} catch (err) { } catch (err) {
return formatError(err, 'Error executing tool'); return formatErrorMessage(err);
} }
} }
} }