1
0
Fork 0

Compare commits

...

2 Commits

Author SHA1 Message Date
Pabloader 97f88a24b9 Compact tools 2026-03-25 16:21:53 +00:00
Pabloader ad8895430b Replace all textarea to contenteditable 2026-03-25 15:50:51 +00:00
8 changed files with 141 additions and 120 deletions

View File

@ -27,8 +27,19 @@
} }
.header { .header {
font-size: 1.4em;
font-weight: bold; font-weight: bold;
color: var(--yellow, #e6db74); color: var(--yellow, #e6db74);
display: block; display: block;
} }
.header1 {
font-size: 1.4em;
}
.header2 {
font-size: 1.2em;
}
.header3 {
font-size: 1em;
}

View File

@ -1,8 +1,10 @@
import clsx from 'clsx';
import styles from './assets/highlight.module.css'; import styles from './assets/highlight.module.css';
export const highlight = (message: string, keepMarkup = true): string => { export const highlight = (message: string, keepMarkup = true): string => {
let resultHTML = ''; let resultHTML = '';
const tokenRegex = /(\*\*?|"|```|`|(?:^|\n)# |\n)/g; const tokenRegex = /(\*\*?|"|```|`|(?:^|\n)#{1,3} |\n)/g;
const headerRegex = /#{1,3} $/;
const stack: string[] = []; const stack: string[] = [];
let inCodeBlock = false; let inCodeBlock = false;
let inHeader = false; let inHeader = false;
@ -28,10 +30,13 @@ export const highlight = (message: string, keepMarkup = true): string => {
continue; continue;
} }
if (token.endsWith('# ')) { const headerMatch = token.match(headerRegex);
if (headerMatch) {
if (inHeader) resultHTML += '</span>'; if (inHeader) resultHTML += '</span>';
const markup = keepMarkup ? headerMatch[0] : '';
const len = headerMatch[0].length;
inHeader = true; inHeader = true;
resultHTML += `${token.slice(0, -2)}<span class="${styles.header}">${keepMarkup ? '# ' : ''}`; resultHTML += `${token.slice(0, -len)}<span class="${clsx(styles.header, styles[`header${len - 1}`])}">${markup}`;
continue; continue;
} }

View File

@ -71,6 +71,7 @@
min-height: 80px; min-height: 80px;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
resize: vertical;
&:focus { &:focus {
outline: none; outline: none;

View File

@ -44,6 +44,7 @@ export const ChaptersEditor = () => {
)} )}
<div class={styles.chunkPreview}>{body}</div> <div class={styles.chunkPreview}>{body}</div>
<ContentEditable <ContentEditable
autoLines
class={styles.summaryEditable} class={styles.summaryEditable}
value={highlight(summary ?? '')} value={highlight(summary ?? '')}
placeholder="Not summarized yet..." placeholder="Not summarized yet..."

View File

@ -2,6 +2,7 @@ import { useAppState, type Character } from "../contexts/state";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import styles from '../assets/character-editor.module.css'; import styles from '../assets/character-editor.module.css';
import LLM from "../utils/llm"; import LLM from "../utils/llm";
import { ContentEditable } from "@common/components/ContentEditable";
export const CharacterEditor = () => { export const CharacterEditor = () => {
const { currentStory, dispatch, connection, model } = useAppState(); const { currentStory, dispatch, connection, model } = useAppState();
@ -174,12 +175,12 @@ export const CharacterEditor = () => {
<div class={styles.field}> <div class={styles.field}>
<div class={styles.label}>Description</div> <div class={styles.label}>Description</div>
<textarea <ContentEditable
autoLines
class={styles.textarea} class={styles.textarea}
value={character.description} value={character.description}
onInput={(e) => handleEditCharacter(character.id, 'description', e.currentTarget.value)} onInput={(e) => handleEditCharacter(character.id, 'description', e.currentTarget.textContent)}
placeholder="Full character description..." placeholder="Full character description..."
rows={4}
/> />
</div> </div>

View File

@ -2,6 +2,7 @@ import { useAppState, type Location, LocationScale } from "../contexts/state";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import styles from '../assets/location-editor.module.css'; import styles from '../assets/location-editor.module.css';
import LLM from "../utils/llm"; import LLM from "../utils/llm";
import { ContentEditable } from "@common/components/ContentEditable";
const SCALE_OPTIONS = Object.entries(LocationScale) const SCALE_OPTIONS = Object.entries(LocationScale)
.filter(([, value]) => typeof value === 'number') .filter(([, value]) => typeof value === 'number')
@ -138,12 +139,12 @@ export const LocationEditor = () => {
<div class={styles.field}> <div class={styles.field}>
<div class={styles.label}>Description</div> <div class={styles.label}>Description</div>
<textarea <ContentEditable
autoLines
class={styles.textarea} class={styles.textarea}
value={location.description} value={location.description}
onInput={(e) => handleEditLocation(location.id, 'description', e.currentTarget.value)} onInput={(e) => handleEditLocation(location.id, 'description', e.currentTarget.textContent)}
placeholder="Full location description..." placeholder="Full location description..."
rows={4}
/> />
</div> </div>

View File

@ -172,10 +172,10 @@ namespace Prompt {
const parts: string[] = [state.systemInstruction]; const parts: string[] = [state.systemInstruction];
parts.push(`# ${currentStory.title}`); parts.push(`# Story Title: ${currentStory.title}`);
if (currentStory.lore) { if (currentStory.lore) {
parts.push(`## Lore\n${currentStory.lore}`); parts.push(`## Lore`, currentStory.lore);
} }
const charactersSection = formatCharactersMarkdown(state); const charactersSection = formatCharactersMarkdown(state);
@ -191,7 +191,7 @@ namespace Prompt {
if (currentStory.text && storyTokenBudget > 0) { if (currentStory.text && storyTokenBudget > 0) {
const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget); const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget);
if (storyText) { if (storyText) {
parts.push(`## Story\n${storyText}`); parts.push(`## Story Text:`, storyText);
} }
} }

View File

@ -20,50 +20,6 @@ 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> = {
'append_to_story': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
appState.dispatch({
type: 'EDIT_STORY',
id: appState.currentStory.id,
text: appState.currentStory.text + args.text,
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'story'
});
return 'Text appended successfully';
},
description: 'Append text to the current story',
parameters: Type.Object({
text: Type.String({ description: 'The text to append to the story' }),
}),
}),
'append_to_lore': tool({
handler: async (args, appState) => {
if (!appState.currentStory) {
return 'Error: No story selected';
}
appState.dispatch({
type: 'EDIT_LORE',
id: appState.currentStory.id,
lore: appState.currentStory.lore + args.text
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'lore'
});
return 'Text appended to lore successfully';
},
description: 'Append text to the story lore',
parameters: Type.Object({
text: Type.String({ description: 'The text to append to the lore' }),
}),
}),
'add_character': tool({ 'add_character': tool({
handler: async (args, appState) => { handler: async (args, appState) => {
if (!appState.currentStory) { if (!appState.currentStory) {
@ -229,49 +185,55 @@ export namespace Tools {
})), })),
}), }),
}), }),
'edit_story': tool({ 'edit_text': 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';
} }
const occurrences = appState.currentStory.text.split(args.old_text).length - 1; const target = args.target ?? 'story';
if (occurrences === 0) {
return 'Error: old_text not found in story'; // Append mode: when old_text is not provided, append new_text to the target
} if (args.old_text == null) {
if (occurrences > 1 && !args.replace_all) { if (target === 'lore') {
return 'Error: old_text appears multiple times in story'; appState.dispatch({
} type: 'EDIT_LORE',
id: appState.currentStory.id,
lore: appState.currentStory.lore + '\n' + args.new_text,
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'lore'
});
} else {
appState.dispatch({ appState.dispatch({
type: 'EDIT_STORY', type: 'EDIT_STORY',
id: appState.currentStory.id, id: appState.currentStory.id,
text: appState.currentStory.text.replaceAll(args.old_text, args.new_text), text: appState.currentStory.text + '\n' + args.new_text,
}); });
appState.dispatch({ appState.dispatch({
type: 'SET_CURRENT_TAB', type: 'SET_CURRENT_TAB',
id: appState.currentStory.id, id: appState.currentStory.id,
tab: 'story' 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; return `Text appended to ${target} successfully`;
}
// Replace mode: find and replace old_text with new_text
const source = target === 'lore'
? appState.currentStory.lore
: appState.currentStory.text;
const occurrences = source.split(args.old_text).length - 1;
if (occurrences === 0) { if (occurrences === 0) {
return 'Error: old_text not found in lore'; return `Error: old_text not found in ${target}`;
} }
if (occurrences > 1 && !args.replace_all) { if (occurrences > 1 && !args.replace_all) {
return 'Error: old_text appears multiple times in lore'; return `Error: old_text appears multiple times in ${target}`;
} }
if (target === 'lore') {
appState.dispatch({ appState.dispatch({
type: 'EDIT_LORE', type: 'EDIT_LORE',
id: appState.currentStory.id, id: appState.currentStory.id,
@ -282,13 +244,28 @@ export namespace Tools {
id: appState.currentStory.id, id: appState.currentStory.id,
tab: 'lore' tab: 'lore'
}); });
return 'Lore edited successfully'; } else {
appState.dispatch({
type: 'EDIT_STORY',
id: appState.currentStory.id,
text: appState.currentStory.text.replaceAll(args.old_text, args.new_text),
});
appState.dispatch({
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'story'
});
}
return `${target === 'lore' ? 'Lore' : 'Story'} edited successfully`;
}, },
description: 'Replace text in the story lore', description: 'Replace text in the story or lore. When old_text is omitted, appends new_text to the target.',
parameters: Type.Object({ 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, or to append if old_text is omitted' }),
new_text: Type.String({ description: 'The new text to replace old_text with' }), old_text: Type.Optional(Type.String({ description: 'The text to find and replace. If omitted, new_text will be appended' })),
replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })), replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })),
target: Type.Optional(Type.Enum(['lore', 'story'],
{ description: 'Target to edit (story or lore, default: story)' },
)),
}), }),
}), }),
'grep': tool({ 'grep': tool({
@ -296,28 +273,52 @@ export namespace Tools {
if (!appState.currentStory) { if (!appState.currentStory) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
const lines = appState.currentStory.text.split('\n');
const matches: { line: number; content: string }[] = []; const sources: { name: string; content: string }[] = [
{ name: 'story', content: appState.currentStory.text },
{ name: 'lore', content: appState.currentStory.lore },
...appState.currentStory.characters.map(c => ({
name: `character:${c.name}`,
content: `${c.shortDescription}\n${c.description || ''}`.trim(),
})),
...appState.currentStory.locations.map(l => ({
name: `location:${l.name}`,
content: `${l.shortDescription}\n${l.description || ''}`.trim(),
})),
];
const allMatches: { source: string; line: number; content: string }[] = [];
const pattern = new RegExp(args.pattern, args.case_sensitive ? 'g' : 'gi'); const pattern = new RegExp(args.pattern, args.case_sensitive ? 'g' : 'gi');
for (const src of sources) {
const lines = src.content.split('\n');
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
if (pattern.test(lines[i])) { if (pattern.test(lines[i])) {
matches.push({ line: i + 1, content: lines[i].trim() }); allMatches.push({
source: src.name,
line: i + 1,
content: lines[i].trim(),
});
} }
} }
if (matches.length === 0) { }
if (allMatches.length === 0) {
return `No matches found for pattern: ${args.pattern}`; return `No matches found for pattern: ${args.pattern}`;
} }
const limit = args.limit ?? 20; const limit = args.limit ?? 20;
const truncated = matches.length > limit; const truncated = allMatches.length > limit;
const displayed = truncated ? matches.slice(0, limit) : matches; const displayed = truncated ? allMatches.slice(0, limit) : allMatches;
let result = `Found ${matches.length} match(es) for pattern "${args.pattern}":\n`;
result += displayed.map(m => `Line ${m.line}: ${m.content}`).join('\n'); let result = `Found ${allMatches.length} match(es) for pattern "${args.pattern}":\n`;
result += displayed.map(m => `[${m.source}] Line ${m.line}: ${m.content}`).join('\n');
if (truncated) { if (truncated) {
result += `\n... and ${matches.length - limit} more match(es)`; result += `\n... and ${allMatches.length - limit} more match(es)`;
} }
return result; return result;
}, },
description: 'Search for a pattern in the story text', 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 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)' })),