From 53590264660c3270b9afac2eca8eb4fc7531ddd1 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Thu, 26 Mar 2026 08:20:59 +0000 Subject: [PATCH] Lore refactor to entries --- .../storywriter/assets/lore-editor.module.css | 200 ++++++++++++++++++ src/games/storywriter/components/editor.tsx | 18 +- .../storywriter/components/lore-editor.tsx | 171 +++++++++++++++ src/games/storywriter/contexts/state.tsx | 69 +++++- src/games/storywriter/utils/prompt.ts | 25 ++- src/games/storywriter/utils/tools.ts | 124 ++++++----- 6 files changed, 528 insertions(+), 79 deletions(-) create mode 100644 src/games/storywriter/assets/lore-editor.module.css create mode 100644 src/games/storywriter/components/lore-editor.tsx diff --git a/src/games/storywriter/assets/lore-editor.module.css b/src/games/storywriter/assets/lore-editor.module.css new file mode 100644 index 0000000..4e2c820 --- /dev/null +++ b/src/games/storywriter/assets/lore-editor.module.css @@ -0,0 +1,200 @@ +.loreEditor { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + gap: 24px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.header h2 { + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--text); +} + +.addEntry { + display: flex; + gap: 8px; + align-items: center; +} + +.titleInput { + padding: 8px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + color: var(--text); + font-family: inherit; + width: 250px; + + &:focus { + outline: none; + border-color: var(--accent); + } +} + +.addButton { + padding: 8px 16px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + background: var(--accent-alt); + } +} + +.list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.empty { + color: var(--text-muted); + font-style: italic; + font-size: 14px; +} + +.entryCard { + background: var(--bg-secondary); + border-radius: 8px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.cardHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.titleRow { + flex: 1; + min-width: 0; +} + +.entryTitle { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--accent); + } +} + +.titleEditInput { + width: 100%; + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 18px; + font-weight: 600; + color: var(--text); + font-family: inherit; + + &:focus { + outline: none; + border-color: var(--accent); + } +} + +.actions { + display: flex; + gap: 6px; + align-items: center; +} + +.moveButton { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 14px; + cursor: pointer; + transition: all var(--transition); + + &:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +} + +.deleteButton { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-muted); + font-size: 18px; + cursor: pointer; + transition: all var(--transition); + + &:hover { + background: var(--danger); + border-color: var(--danger); + color: var(--bg); + } +} + +.content { + min-height: 80px; +} + +.textarea { + width: 100%; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 14px; + color: var(--text); + font-family: inherit; + resize: vertical; + min-height: 80px; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: var(--accent); + } +} diff --git a/src/games/storywriter/components/editor.tsx b/src/games/storywriter/components/editor.tsx index 2a89980..4355548 100644 --- a/src/games/storywriter/components/editor.tsx +++ b/src/games/storywriter/components/editor.tsx @@ -7,6 +7,7 @@ import clsx from "clsx"; import { CharacterEditor } from "./character-editor"; import { LocationEditor } from "./location-editor"; import { ChaptersEditor } from "./chapters-editor"; +import { LoreEditor } from "./lore-editor"; import { useInputCallback } from "@common/hooks/useInputCallback"; const TABS: { id: Tab; label: string }[] = [ @@ -29,15 +30,6 @@ export const Editor = () => { }); }, [currentStory?.id]); - const handleLoreInput = useInputCallback((lore: string) => { - if (!currentStory) return; - dispatch({ - type: 'EDIT_LORE', - id: currentStory.id, - lore, - }); - }, [currentStory?.id]); - const handleTabChange = (tab: Tab) => { if (!currentStory) return; dispatch({ @@ -48,7 +40,6 @@ export const Editor = () => { }; const storyValue = useMemo(() => currentStory ? highlight(currentStory.text) : '', [currentStory?.text]); - const loreValue = useMemo(() => currentStory ? highlight(currentStory.lore) : '', [currentStory?.lore]); if (!currentStory) { return
; @@ -69,12 +60,7 @@ export const Editor = () => { /> )} {currentStory.currentTab === "lore" && ( - + )} {currentStory.currentTab === "characters" && ( diff --git a/src/games/storywriter/components/lore-editor.tsx b/src/games/storywriter/components/lore-editor.tsx new file mode 100644 index 0000000..557bbd8 --- /dev/null +++ b/src/games/storywriter/components/lore-editor.tsx @@ -0,0 +1,171 @@ +import { useAppState, type LoreEntry } from "../contexts/state"; +import styles from '../assets/lore-editor.module.css'; +import { ContentEditable } from "@common/components/ContentEditable"; +import { useState } from "preact/hooks"; + +export const LoreEditor = () => { + const { currentStory, dispatch } = useAppState(); + const [editingId, setEditingId] = useState(null); + const [newTitle, setNewTitle] = useState(''); + + if (!currentStory) { + return null; + } + + const handleAddEntry = () => { + if (!newTitle.trim()) return; + + dispatch({ + type: 'ADD_LORE_ENTRY', + storyId: currentStory.id, + entry: { + id: crypto.randomUUID(), + title: newTitle.trim(), + text: '', + }, + }); + setNewTitle(''); + setEditingId(null); + }; + + const handleEditEntry = (entryId: string, field: keyof LoreEntry, value: string) => { + dispatch({ + type: 'EDIT_LORE_ENTRY', + storyId: currentStory.id, + entryId, + updates: { [field]: value }, + }); + }; + + const handleDeleteEntry = (entryId: string) => { + dispatch({ + type: 'DELETE_LORE_ENTRY', + storyId: currentStory.id, + entryId, + }); + }; + + const handleMoveUp = (index: number) => { + if (index === 0) return; + const entryIds = currentStory.lore.map(e => e.id); + [entryIds[index - 1], entryIds[index]] = [entryIds[index], entryIds[index - 1]]; + dispatch({ + type: 'REORDER_LORE_ENTRIES', + storyId: currentStory.id, + entryIds, + }); + }; + + const handleMoveDown = (index: number) => { + if (index === currentStory.lore.length - 1) return; + const entryIds = currentStory.lore.map(e => e.id); + [entryIds[index], entryIds[index + 1]] = [entryIds[index + 1], entryIds[index]]; + dispatch({ + type: 'REORDER_LORE_ENTRIES', + storyId: currentStory.id, + entryIds, + }); + }; + + return ( +
+
+

Lore

+
+ setNewTitle(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddEntry(); + } + }} + placeholder="New lore entry title..." + /> + +
+
+ +
+ {currentStory.lore.length === 0 && ( +

No lore entries yet. Add your first entry!

+ )} + + {currentStory.lore.map((entry, index) => ( +
+
+
+ {editingId === entry.id ? ( + handleEditEntry(entry.id, 'title', e.currentTarget.value)} + onBlur={() => setEditingId(null)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + setEditingId(null); + } else if (e.key === 'Escape') { + setEditingId(null); + } + }} + autoFocus + /> + ) : ( +

setEditingId(entry.id)} + title="Click to edit" + > + {entry.title} +

+ )} +
+
+ + + +
+
+ +
+ handleEditEntry(entry.id, 'text', e.currentTarget.textContent || '')} + placeholder="Enter lore content..." + /> +
+
+ ))} +
+
+ ); +}; diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index 205b1ab..1365d1b 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -57,11 +57,17 @@ export interface Location { scale: LocationScale; } +export interface LoreEntry { + id: string; + title: string; + text: string; +} + export interface Story { id: string; title: string; text: string; - lore: string; + lore: LoreEntry[]; characters: Character[]; locations: Location[]; currentTab: Tab; @@ -87,7 +93,10 @@ type Action = | { type: 'CREATE_STORY'; title: string } | { type: 'RENAME_STORY'; id: string; title: string } | { type: 'EDIT_STORY'; id: string; text: string } - | { type: 'EDIT_LORE'; id: string; lore: string } + | { type: 'ADD_LORE_ENTRY'; storyId: string; entry: LoreEntry } + | { type: 'EDIT_LORE_ENTRY'; storyId: string; entryId: string; updates: Partial } + | { type: 'DELETE_LORE_ENTRY'; storyId: string; entryId: string } + | { type: 'REORDER_LORE_ENTRIES'; storyId: string; entryIds: string[] } | { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string } | { type: 'SET_CURRENT_TAB'; id: string; tab: Tab } | { type: 'DELETE_STORY'; id: string } @@ -132,7 +141,7 @@ function reducer(state: IState, action: Action): IState { id: crypto.randomUUID(), title: action.title, text: '', - lore: '', + lore: [], characters: [], locations: [], currentTab: 'story', @@ -161,14 +170,62 @@ function reducer(state: IState, action: Action): IState { ), }; } - case 'EDIT_LORE': { + case 'ADD_LORE_ENTRY': { return { ...state, stories: state.stories.map(s => - s.id === action.id ? { ...s, lore: action.lore } : s + s.id === action.storyId + ? { ...s, lore: [...s.lore, action.entry] } + : s ), }; } + case 'EDIT_LORE_ENTRY': { + return { + ...state, + stories: state.stories.map(s => + s.id === action.storyId + ? { + ...s, + lore: s.lore.map(e => + e.id === action.entryId + ? { ...e, ...action.updates } + : e + ), + } + : s + ), + }; + } + case 'DELETE_LORE_ENTRY': { + return { + ...state, + stories: state.stories.map(s => + s.id === action.storyId + ? { ...s, lore: s.lore.filter(e => e.id !== action.entryId) } + : s + ), + }; + } + case 'REORDER_LORE_ENTRIES': { + return { + ...state, + stories: state.stories.map(s => { + if (s.id !== action.storyId) return s; + const entryMap = new Map(s.lore.map(e => [e.id, e])); + const reordered = action.entryIds + .map(id => entryMap.get(id)) + .filter((e): e is LoreEntry => e !== undefined); + // Add any entries that weren't in the new order (safety) + for (const entry of s.lore) { + if (!action.entryIds.includes(entry.id)) { + reordered.push(entry); + } + } + return { ...s, lore: reordered }; + }), + }; + } case 'SET_SYSTEM_INSTRUCTION': { return { ...state, @@ -199,7 +256,7 @@ function reducer(state: IState, action: Action): IState { id: crypto.randomUUID(), title: `${original.title} (Copy)`, text: '', - lore: original.lore, + lore: [...original.lore], characters: original.characters, locations: original.locations, currentTab: 'story', diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index 9a75689..f1d2ee6 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -230,6 +230,26 @@ namespace Prompt { return lines.join('\n'); } + export function formatLoreMarkdown(state: AppState): string { + const { currentStory } = state; + if (!currentStory?.lore?.length) { + return ''; + } + + const lines: string[] = []; + lines.push('## Lore\n'); + + for (const entry of currentStory.lore) { + lines.push(`### ${entry.title}`); + if (entry.text) { + lines.push(entry.text); + } + lines.push(''); + } + + return lines.join('\n'); + } + export function formatLocationsMarkdown(state: AppState): string { const { currentStory } = state; if (!currentStory || !currentStory.locations?.length) { @@ -264,8 +284,9 @@ namespace Prompt { parts.push(`# Story Title: ${currentStory.title}`); - if (currentStory.lore) { - parts.push(`## Lore`, currentStory.lore); + const loreSection = formatLoreMarkdown(state); + if (loreSection) { + parts.push(loreSection); } const charactersSection = formatCharactersMarkdown(state); diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts index 3b26ebb..d34d3bb 100644 --- a/src/games/storywriter/utils/tools.ts +++ b/src/games/storywriter/utils/tools.ts @@ -1,6 +1,6 @@ import { formatErrorMessage } from "@common/errors"; import { Type, type Static, type TObject } from '@common/typebox'; -import { CharacterRole, LocationScale, type AppState, type Character, type Location } from "../contexts/state"; +import { CharacterRole, LocationScale, type AppState, type Character, type Location, type LoreEntry } from "../contexts/state"; import type LLM from "./llm"; const SCALE_DESCRIPTION = Object.entries(LocationScale) @@ -296,87 +296,98 @@ export namespace Tools { })), }), }), - 'edit_text': tool({ + 'set_lore_entry': tool({ handler: async (args, appState) => { if (!appState.currentStory) { return 'Error: No story selected'; } - const target = args.target ?? 'story'; + const existingEntry = appState.currentStory.lore.find(e => e.title.toLowerCase() === args.title.trim().toLowerCase()); - // 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 ${target}`; - } - if (occurrences > 1 && !args.replace_all) { - return `Error: old_text appears multiple times in ${target}`; - } - - if (target === 'lore') { + if (existingEntry) { appState.dispatch({ - type: 'EDIT_LORE', - id: appState.currentStory.id, - lore: appState.currentStory.lore.replaceAll(args.old_text, args.new_text), + type: 'EDIT_LORE_ENTRY', + storyId: appState.currentStory.id, + entryId: existingEntry.id, + updates: { text: args.text }, }); appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'lore' }); + return `Lore entry "${existingEntry.title}" updated successfully`; } else { + appState.dispatch({ + type: 'ADD_LORE_ENTRY', + storyId: appState.currentStory.id, + entry: { + id: crypto.randomUUID(), + title: args.title.trim(), + text: args.text, + }, + }); + appState.dispatch({ + type: 'SET_CURRENT_TAB', + id: appState.currentStory.id, + tab: 'lore' + }); + return `Lore entry "${args.title.trim()}" added successfully`; + } + }, + description: 'Add or edit a lore entry. If entry with matching title exists, updates it; otherwise creates new one.', + parameters: Type.Object({ + title: Type.String({ description: "The lore entry's title" }), + text: Type.String({ description: 'The lore entry content.' }), + }), + }), + 'edit_text': tool({ + handler: async (args, appState) => { + if (!appState.currentStory) { + return 'Error: No story selected'; + } + + // Append mode: when old_text is not provided, append new_text to the story + if (args.old_text == null) { appState.dispatch({ type: 'EDIT_STORY', 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({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab: 'story' }); + return 'Text appended to story successfully'; } - return `${target === 'lore' ? 'Lore' : 'Story'} edited successfully`; + + // Replace mode: find and replace old_text with new_text in story + const source = appState.currentStory.text; + const occurrences = source.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), + }); + appState.dispatch({ + type: 'SET_CURRENT_TAB', + id: appState.currentStory.id, + tab: 'story' + }); + return 'Story edited successfully'; }, - description: "Replace text in the story or lore. When old_text is omitted, appends new_text to the target's end. Case-sensitive.", + description: "Replace text in the story. When old_text is omitted, appends new_text to the story's end. Case-sensitive.", parameters: Type.Object({ 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({ @@ -387,7 +398,10 @@ export namespace Tools { const sources: { name: string; content: string }[] = [ { name: 'story', content: appState.currentStory.text }, - { name: 'lore', content: appState.currentStory.lore }, + ...appState.currentStory.lore.flatMap(e => [ + { name: `lore:${e.title}`, content: e.title }, + { name: `lore:${e.title}`, content: e.text }, + ]), ...appState.currentStory.characters.flatMap(c => [ { name: `character:${c.name}`, content: c.name }, { name: `character:${c.name}`, content: c.shortDescription },