diff --git a/src/common/assets/highlight.module.css b/src/common/assets/highlight.module.css index e892450..ff1d8af 100644 --- a/src/common/assets/highlight.module.css +++ b/src/common/assets/highlight.module.css @@ -27,8 +27,19 @@ } .header { - font-size: 1.4em; font-weight: bold; color: var(--yellow, #e6db74); display: block; } + +.header1 { + font-size: 1.4em; +} + +.header2 { + font-size: 1.2em; +} + +.header3 { + font-size: 1em; +} \ No newline at end of file diff --git a/src/common/highlight.ts b/src/common/highlight.ts index 2ceffa4..5660489 100644 --- a/src/common/highlight.ts +++ b/src/common/highlight.ts @@ -1,8 +1,10 @@ +import clsx from 'clsx'; import styles from './assets/highlight.module.css'; export const highlight = (message: string, keepMarkup = true): string => { let resultHTML = ''; - const tokenRegex = /(\*\*?|"|```|`|(?:^|\n)# |\n)/g; + const tokenRegex = /(\*\*?|"|```|`|(?:^|\n)#{1,3} |\n)/g; + const headerRegex = /#{1,3} $/; const stack: string[] = []; let inCodeBlock = false; let inHeader = false; @@ -28,10 +30,13 @@ export const highlight = (message: string, keepMarkup = true): string => { continue; } - if (token.endsWith('# ')) { + const headerMatch = token.match(headerRegex); + if (headerMatch) { if (inHeader) resultHTML += ''; + const markup = keepMarkup ? headerMatch[0] : ''; + const len = headerMatch[0].length; inHeader = true; - resultHTML += `${token.slice(0, -2)}${keepMarkup ? '# ' : ''}`; + resultHTML += `${token.slice(0, -len)}${markup}`; continue; } diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index ac98775..2f7bc0c 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -65,7 +65,7 @@ namespace Prompt { const content = slot.mode === 'omitted' ? '[...]' : slot.mode === 'summary' ? `[Summary: ${slot.summary}]` - : slot.body; + : slot.body; lines.push(content); parts.push(lines.join('\n\n')); } @@ -172,10 +172,10 @@ namespace Prompt { const parts: string[] = [state.systemInstruction]; - parts.push(`# ${currentStory.title}`); + parts.push(`# Story Title: ${currentStory.title}`); if (currentStory.lore) { - parts.push(`## Lore\n${currentStory.lore}`); + parts.push(`## Lore`, currentStory.lore); } const charactersSection = formatCharactersMarkdown(state); @@ -191,11 +191,11 @@ namespace Prompt { if (currentStory.text && storyTokenBudget > 0) { const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget); if (storyText) { - parts.push(`## Story\n${storyText}`); + parts.push(`## Story Text:`, storyText); } } -return parts.join('\n\n'); + return parts.join('\n\n'); } export function compilePrompt(state: AppState, newMessages: LLM.ChatMessage[] = []): LLM.ChatCompletionRequest | null { diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts index 6b1bce2..36270d5 100644 --- a/src/games/storywriter/utils/tools.ts +++ b/src/games/storywriter/utils/tools.ts @@ -20,50 +20,6 @@ export namespace Tools { const tool = (t: Tool): Tool => t; const TOOLS: Record = { - '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 + '\n' + 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 + '\n' + 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({ handler: async (args, appState) => { if (!appState.currentStory) { @@ -229,66 +185,87 @@ export namespace Tools { })), }), }), - 'edit_story': tool({ + 'edit_text': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } - const occurrences = appState.currentStory.text.split(args.old_text).length - 1; + const target = args.target ?? 'story'; + + // Append mode: when old_text is not provided, append new_text to the target + if (args.old_text == null) { + if (target === 'lore') { + 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({ + type: 'EDIT_STORY', + id: appState.currentStory.id, + text: appState.currentStory.text + '\n' + args.new_text, + }); + appState.dispatch({ + type: 'SET_CURRENT_TAB', + id: appState.currentStory.id, + tab: 'story' + }); + } + return `Text appended to ${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) { - return 'Error: old_text not found in story'; + return `Error: old_text not found in ${target}`; } if (occurrences > 1 && !args.replace_all) { - return 'Error: old_text appears multiple times in story'; + return `Error: old_text appears multiple times in ${target}`; } - appState.dispatch({ - type: 'EDIT_STORY', - id: appState.currentStory.id, - text: appState.currentStory.text.replaceAll(args.old_text, args.new_text), - }); - appState.dispatch({ - type: 'SET_CURRENT_TAB', - id: appState.currentStory.id, - tab: 'story' - }); - return 'Story edited successfully'; + + if (target === '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' + }); + } 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 current story', + description: 'Replace text in the story or lore. When old_text is omitted, appends new_text to the target.', 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' }), + new_text: Type.String({ description: 'The new text to replace old_text with, or to append if old_text is omitted' }), + old_text: Type.Optional(Type.String({ description: 'The text to find and replace. If omitted, new_text will be appended' })), replace_all: Type.Optional(Type.Boolean({ description: 'If true, replace all occurrences of old_text' })), + target: Type.Optional(Type.Enum(['lore', 'story'], + { description: 'Target to edit (story or lore, default: story)' }, + )), }), }), 'grep': tool({ @@ -296,39 +273,56 @@ export namespace Tools { if (!appState.currentStory) { return 'Error: No story selected'; } - const source = args.source === 'lore' - ? appState.currentStory.lore - : appState.currentStory.text; - const lines = source.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'); - for (let i = 0; i < lines.length; i++) { - if (pattern.test(lines[i])) { - matches.push({ line: i + 1, content: lines[i].trim() }); + + for (const src of sources) { + const lines = src.content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (pattern.test(lines[i])) { + allMatches.push({ + source: src.name, + line: i + 1, + content: lines[i].trim(), + }); + } } } - if (matches.length === 0) { + + if (allMatches.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'); + const truncated = allMatches.length > limit; + const displayed = truncated ? allMatches.slice(0, limit) : allMatches; + + let result = `Found ${allMatches.length} match(es) for pattern "${args.pattern}":\n`; + result += displayed.map(m => `[${m.source}] Line ${m.line}: ${m.content}`).join('\n'); if (truncated) { - result += `\n... and ${matches.length - limit} more match(es)`; + result += `\n... and ${allMatches.length - limit} more match(es)`; } return result; }, - description: 'Search for a pattern in the story text or lore', + description: 'Search for a pattern in the story text, lore, characters, and locations', 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)' })), - source: Type.Optional(Type.Enum(['lore', 'story'], - { description: 'Source to search (story or lore, default: story)' }, - )), }), }) };