diff --git a/src/games/storywriter/assets/editor.module.css b/src/games/storywriter/assets/editor.module.css index 48e530a..e462bcd 100644 --- a/src/games/storywriter/assets/editor.module.css +++ b/src/games/storywriter/assets/editor.module.css @@ -20,7 +20,7 @@ .content { flex: 1; overflow-y: auto; - padding: 0 72px; + padding: 0 72px 72px; } .editable { diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index 00ad0c0..68e2327 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -49,6 +49,7 @@ export interface Story { id: string; title: string; text: string; + lastModifiedChunk: string; lore: string; characters: Character[]; locations: Location[]; @@ -73,7 +74,7 @@ interface IState { type Action = | { type: 'CREATE_STORY'; 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: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string } | { type: 'SET_CURRENT_TAB'; id: string; tab: Tab } @@ -116,6 +117,7 @@ function reducer(state: IState, action: Action): IState { id: crypto.randomUUID(), title: action.title, text: '', + lastModifiedChunk: '', lore: '', characters: [], locations: [], @@ -140,7 +142,11 @@ function reducer(state: IState, action: Action): IState { return { ...state, 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 ), }; } diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index 737c234..d184deb 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -3,6 +3,62 @@ import { type AppState, LocationScale } from "../contexts/state"; import { Tools } from "./tools"; 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 { const { currentStory } = state; if (!currentStory || !currentStory.characters?.length) { @@ -57,7 +113,7 @@ namespace Prompt { return lines.join('\n'); } - export function formatSystemPrompt(state: AppState): string { + export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string { const { currentStory } = state; if (!currentStory) { return state.systemInstruction; @@ -68,7 +124,7 @@ namespace Prompt { parts.push(`# ${currentStory.title}`); if (currentStory.lore) { - parts.push('## Lore\n' + currentStory.lore); + parts.push(`## Lore\n${currentStory.lore}`); } const charactersSection = formatCharactersMarkdown(state); @@ -81,6 +137,17 @@ namespace Prompt { 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'); } @@ -91,9 +158,20 @@ namespace Prompt { 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[] = [ - { role: 'system', content: formatSystemPrompt(state) }, - // TODO part of story + { role: 'system', content: formatSystemPrompt(state, storyTokenBudget) }, ...currentStory.chatMessages, ]; @@ -105,7 +183,7 @@ namespace Prompt { tools: Tools.getTools(), banned_tokens: state.bannedTokens, enable_thinking: enableThinking, - max_tokens: model.max_length ? model.max_length / 2 : 2048, + max_tokens: model.max_length ? model.max_length : 2048, }; } } diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts index 82f1994..98b5a5c 100644 --- a/src/games/storywriter/utils/tools.ts +++ b/src/games/storywriter/utils/tools.ts @@ -28,7 +28,8 @@ export namespace Tools { appState.dispatch({ type: 'EDIT_STORY', id: appState.currentStory.id, - text: appState.currentStory.text + args.text + text: appState.currentStory.text + args.text, + lastModifiedChunk: args.text }); appState.dispatch({ type: 'SET_CURRENT_TAB', @@ -228,20 +229,114 @@ export namespace Tools { description: `Location scale (enum): ${SCALE_DESCRIPTION}`, })), }), + }), + 'edit_story': tool({ + handler: async (args, appState) => { + if (!appState.currentStory) { + return 'Error: No story selected'; + } + const occurrences = appState.currentStory.text.split(args.old_text).length - 1; + if (occurrences === 0) { + return 'Error: old_text not found in story'; + } + if (occurrences > 1 && !args.replace_all) { + return 'Error: old_text appears multiple times in story'; + } + 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[] { - return Object.entries(TOOLS).map(([key, tool]) => { - return { - type: 'function', - function: { - name: key, - description: tool.description, - parameters: tool.parameters, - }, - }; - }); + return Object.entries(TOOLS).map(([key, tool]) => ({ + type: 'function', + function: { + name: key, + description: tool.description, + parameters: tool.parameters, + }, + })); } function parseArg(arg: unknown): unknown {