diff --git a/src/games/storywriter/assets/editor.module.css b/src/games/storywriter/assets/editor.module.css index d86d7df..c26b3ae 100644 --- a/src/games/storywriter/assets/editor.module.css +++ b/src/games/storywriter/assets/editor.module.css @@ -15,6 +15,21 @@ font-weight: bold; color: var(--text); text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.titleWorld { + color: var(--text-muted); + font-weight: normal; +} + +.titleSep { + color: var(--text-muted); + font-weight: normal; + font-size: 24px; } diff --git a/src/games/storywriter/assets/menu.module.css b/src/games/storywriter/assets/menu.module.css index 37e8719..b7f46a6 100644 --- a/src/games/storywriter/assets/menu.module.css +++ b/src/games/storywriter/assets/menu.module.css @@ -5,6 +5,57 @@ height: 100%; } +.worldGroup { + display: flex; + flex-direction: column; +} + +.storiesList { + display: flex; + flex-direction: column; + gap: 1px; + padding-left: 20px; + border-left: 1px solid var(--border); + margin-left: 10px; + margin-bottom: 4px; +} + +.storyItem { + padding-left: 4px; +} + +.worldTitle { + display: flex; + align-items: center; + gap: 5px; + font-weight: 500; +} + +.expandButton { + flex-shrink: 0; + padding: 4px 3px; + background: transparent; + border: none; + outline: none; + cursor: pointer; + color: var(--text-muted); + border-radius: 2px; + display: flex; + align-items: center; + + &:hover { + background: var(--bg-hover); + color: var(--text); + } +} + +.emptyStories { + padding: 4px 8px; + font-size: 12px; + color: var(--text-muted); + font-style: italic; +} + .newButton { width: 100%; padding: 6px 8px; diff --git a/src/games/storywriter/components/chapters-editor.tsx b/src/games/storywriter/components/chapters-editor.tsx index 68a1e78..8ed7056 100644 --- a/src/games/storywriter/components/chapters-editor.tsx +++ b/src/games/storywriter/components/chapters-editor.tsx @@ -6,7 +6,7 @@ import { highlight } from "@common/highlight"; import styles from "../assets/chapters-editor.module.css"; export const ChaptersEditor = () => { - const { currentStory, dispatch } = useAppState(); + const { currentWorld, currentStory, dispatch } = useAppState(); if (!currentStory) return null; @@ -50,6 +50,7 @@ export const ChaptersEditor = () => { placeholder="Not summarized yet..." onInput={(e) => dispatch({ type: 'STORE_CHAPTER_SUMMARY', + worldId: currentWorld!.id, storyId: currentStory.id, header: parsedChapter.header, hash, diff --git a/src/games/storywriter/components/character-editor.tsx b/src/games/storywriter/components/character-editor.tsx index 4200b00..9b1fb69 100644 --- a/src/games/storywriter/components/character-editor.tsx +++ b/src/games/storywriter/components/character-editor.tsx @@ -5,20 +5,26 @@ import LLM from "../utils/llm"; import { ContentEditable } from "@common/components/ContentEditable"; export const CharacterEditor = () => { - const { currentStory, dispatch, connection, model } = useAppState(); + const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState(); const [newNickname, setNewNickname] = useState>({}); const [newRelation, setNewRelation] = useState>({}); const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [generatingShortDesc, setGeneratingShortDesc] = useState(null); - if (!currentStory) { + if (!currentWorld) { return null; } + // When a story is selected, edit story-level characters; otherwise world-level + const storyId = currentStory?.id ?? null; + const worldId = currentWorld.id; + const characters = currentStory ? currentStory.characters : currentWorld.characters; + const handleAddCharacter = () => { dispatch({ type: 'ADD_CHARACTER', - storyId: currentStory.id, + worldId, + storyId, character: { id: crypto.randomUUID(), name: 'New Character', @@ -34,7 +40,8 @@ export const CharacterEditor = () => { const handleEditCharacter = (characterId: string, field: keyof Character, value: any) => { dispatch({ type: 'EDIT_CHARACTER', - storyId: currentStory.id, + worldId, + storyId, characterId, updates: { [field]: value }, }); @@ -43,7 +50,8 @@ export const CharacterEditor = () => { const handleDeleteCharacter = (characterId: string) => { dispatch({ type: 'DELETE_CHARACTER', - storyId: currentStory.id, + worldId, + storyId, characterId, }); }; @@ -54,7 +62,8 @@ export const CharacterEditor = () => { dispatch({ type: 'ADD_CHARACTER_RELATION', - storyId: currentStory.id, + worldId, + storyId, characterId, relation: { name: rel.name.trim(), relation: rel.relation.trim() }, }); @@ -64,7 +73,8 @@ export const CharacterEditor = () => { const handleEditRelation = (characterId: string, targetName: string, field: 'name' | 'relation', value: string) => { dispatch({ type: 'EDIT_CHARACTER_RELATION', - storyId: currentStory.id, + worldId, + storyId, characterId, targetName, updates: { [field]: value }, @@ -74,7 +84,8 @@ export const CharacterEditor = () => { const handleDeleteRelation = (characterId: string, targetName: string) => { dispatch({ type: 'DELETE_CHARACTER_RELATION', - storyId: currentStory.id, + worldId, + storyId, characterId, targetName, }); @@ -84,7 +95,7 @@ export const CharacterEditor = () => { const nickname = (newNickname[characterId] || '').trim(); if (!nickname) return; - const character = currentStory.characters.find(c => c.id === characterId); + const character = characters.find(c => c.id === characterId); if (character) { handleEditCharacter(characterId, 'nicknames', [...character.nicknames, nickname]); setNewNickname({ ...newNickname, [characterId]: '' }); @@ -92,7 +103,7 @@ export const CharacterEditor = () => { }; const handleNicknameDelete = (characterId: string, nickname: string) => { - const character = currentStory.characters.find(c => c.id === characterId); + const character = characters.find(c => c.id === characterId); if (character) { handleEditCharacter(characterId, 'nicknames', character.nicknames.filter(n => n !== nickname)); } @@ -106,7 +117,7 @@ export const CharacterEditor = () => { const handleGenerateShortDescription = async (characterId: string) => { if (!connection || !model) return; - const character = currentStory.characters.find(c => c.id === characterId); + const character = characters.find(c => c.id === characterId); if (!character || !character.description.trim()) return; setGeneratingShortDesc(characterId); @@ -123,18 +134,18 @@ export const CharacterEditor = () => { return (
-

Characters

+

{currentStory ? 'Story Characters' : 'World Characters'}

- {currentStory.characters.length === 0 && ( + {characters.length === 0 && (

No characters yet. Add your first character!

)} - {currentStory.characters.map((character) => ( + {characters.map((character) => (
{ onInput={(e) => handleNewRelationChange(character.id, 'name', e.currentTarget.value)} > - {currentStory.characters + {mergedCharacters .filter(c => c.id !== character.id) .map(c => ( @@ -313,7 +324,7 @@ export const CharacterEditor = () => { value={rel.name} onInput={(e) => handleEditRelation(character.id, rel.name, 'name', e.currentTarget.value)} > - {currentStory.characters + {mergedCharacters .filter(c => c.id !== character.id) .map(c => ( diff --git a/src/games/storywriter/components/chat-sidebar.tsx b/src/games/storywriter/components/chat-sidebar.tsx index 7e63f6e..7413e89 100644 --- a/src/games/storywriter/components/chat-sidebar.tsx +++ b/src/games/storywriter/components/chat-sidebar.tsx @@ -44,7 +44,7 @@ const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => { export const ChatSidebar = () => { const appState = useAppState(); - const { currentStory, dispatch, connection, model, enableThinking, chatOpen } = appState; + const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen } = appState; const { summarizeAll, isSummarizing } = useChapterSummarization(); const [input, setInput] = useInputState(''); const [isLoading, setIsLoading] = useState(false); @@ -126,11 +126,12 @@ export const ChatSidebar = () => { }, [currentStory, connection, model, input, currentStory?.chatMessages.length]); const sendMessage = useCallback(async (newMessages: ChatMessage[]) => { - if (!currentStory || !connection || !model) return; + if (!currentStory || !currentWorld || !connection || !model) return; for (const message of newMessages) { dispatch({ type: 'ADD_CHAT_MESSAGE', + worldId: currentWorld.id, storyId: currentStory.id, message, }); @@ -139,6 +140,7 @@ export const ChatSidebar = () => { const assistantMessageId = crypto.randomUUID(); dispatch({ type: 'ADD_CHAT_MESSAGE', + worldId: currentWorld.id, storyId: currentStory.id, message: { id: assistantMessageId, @@ -182,6 +184,7 @@ export const ChatSidebar = () => { if (content || reasoningContent) { dispatch({ type: 'ADD_CHAT_MESSAGE', + worldId: currentWorld.id, storyId: currentStory.id, message: { id: assistantMessageId, @@ -202,6 +205,7 @@ export const ChatSidebar = () => { }; dispatch({ type: 'ADD_CHAT_MESSAGE', + worldId: currentWorld.id, storyId: currentStory.id, message: assistantMessage, }); @@ -221,6 +225,7 @@ export const ChatSidebar = () => { }; dispatch({ type: 'ADD_CHAT_MESSAGE', + worldId: currentWorld.id, storyId: currentStory.id, message, }); @@ -291,9 +296,10 @@ export const ChatSidebar = () => { }; const handleClear = () => { - if (!currentStory) return; + if (!currentStory || !currentWorld) return; dispatch({ type: 'CLEAR_CHAT', + worldId: currentWorld.id, storyId: currentStory.id, }); }; diff --git a/src/games/storywriter/components/editor.tsx b/src/games/storywriter/components/editor.tsx index adb8934..d49c1ea 100644 --- a/src/games/storywriter/components/editor.tsx +++ b/src/games/storywriter/components/editor.tsx @@ -11,9 +11,10 @@ import { LoreEditor } from "./lore-editor"; import { Menu } from "./menu"; import { useInputCallback } from "@common/hooks/useInputCallback"; import Prompt from "../utils/prompt"; -import { BookOpen, List, Users, MapPin, BookMarked, FileText, Code, Layers, MessageSquare, type LucideIcon } from "lucide-preact"; +import { BookOpen, List, Users, MapPin, BookMarked, FileText, Code, Layers, MessageSquare, Globe, type LucideIcon } from "lucide-preact"; -const TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [ +// Tabs available when a story is selected +const STORY_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [ { id: "menu", label: "Menu", icon: List }, { id: "story", label: "Story", icon: BookOpen }, { id: "chapters", label: "Chapters", icon: Layers }, @@ -24,19 +25,27 @@ const TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [ { id: "prompt", label: "Prompt", icon: Code }, ]; +// Tabs available when only a world is selected (no story) +const WORLD_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [ + { id: "menu", label: "Menu", icon: List }, + { id: "lore", label: "Lore", icon: BookMarked }, + { id: "characters", label: "Characters", icon: Users }, + { id: "locations", label: "Locations", icon: MapPin }, +]; + export const Editor = () => { const appState = useAppState(); - const { currentStory, currentTab, chatOpen, dispatch } = appState; + const { currentWorld, currentStory, currentTab, chatOpen, dispatch } = appState; const handleInput = useInputCallback((text: string) => { - if (!currentStory) return; - dispatch({ type: 'EDIT_STORY', id: currentStory.id, text }); - }, [currentStory?.id]); + if (!currentStory || !currentWorld) return; + dispatch({ type: 'EDIT_STORY', worldId: currentWorld.id, id: currentStory.id, text }); + }, [currentStory?.id, currentWorld?.id]); const handleScratchpadInput = useInputCallback((text: string) => { - if (!currentStory) return; - dispatch({ type: 'EDIT_SCRATCHPAD', id: currentStory.id, text }); - }, [currentStory?.id]); + if (!currentStory || !currentWorld) return; + dispatch({ type: 'EDIT_SCRATCHPAD', worldId: currentWorld.id, id: currentStory.id, text }); + }, [currentStory?.id, currentWorld?.id]); const handleTabChange = (tab: Tab) => { dispatch({ type: 'SET_CURRENT_TAB', tab }); @@ -89,11 +98,23 @@ export const Editor = () => { } }); return () => cancelAnimationFrame(raf); - }, [currentStory?.id, currentTab]); + }, [currentStory?.id, currentWorld?.id, currentTab]); + + const hasSelection = currentWorld !== null; + const tabs = currentStory ? STORY_TABS : currentWorld ? WORLD_TABS : [{ id: "menu" as Tab, label: "Menu", icon: List }]; + + // Title bar: show world > story or just world + const titleBar = currentStory + ?
+ {currentWorld?.title} + /{currentStory.title}
+ : currentWorld + ?
{currentWorld.title}
+ : null; return (
- {currentStory &&
{currentStory.title}
} + {titleBar}
{currentTab === "menu" && ( @@ -106,13 +127,13 @@ export const Editor = () => { placeholder="Start writing your story..." /> )} - {currentTab === "lore" && currentStory && ( + {currentTab === "lore" && (currentStory || currentWorld) && ( )} - {currentTab === "characters" && currentStory && ( + {currentTab === "characters" && (currentStory || currentWorld) && ( )} - {currentTab === "locations" && currentStory && ( + {currentTab === "locations" && (currentStory || currentWorld) && ( )} {currentTab === "chapters" && currentStory && ( @@ -131,7 +152,7 @@ export const Editor = () => { )}
- {TABS.filter(tab => currentStory || tab.id === 'menu').map((tab) => ( + {tabs.filter(tab => hasSelection || tab.id === 'menu').map((tab) => ( ))} - + {currentStory && ( + + )}
); diff --git a/src/games/storywriter/components/location-editor.tsx b/src/games/storywriter/components/location-editor.tsx index af55af0..cf5cc23 100644 --- a/src/games/storywriter/components/location-editor.tsx +++ b/src/games/storywriter/components/location-editor.tsx @@ -5,18 +5,24 @@ import LLM from "../utils/llm"; import { ContentEditable } from "@common/components/ContentEditable"; export const LocationEditor = () => { - const { currentStory, dispatch, connection, model } = useAppState(); + const { currentWorld, currentStory, dispatch, connection, model } = useAppState(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [generatingShortDesc, setGeneratingShortDesc] = useState(null); - if (!currentStory) { + if (!currentWorld) { return null; } + // When a story is selected, edit story-level locations; otherwise world-level + const storyId = currentStory?.id ?? null; + const worldId = currentWorld.id; + const locations = currentStory ? currentStory.locations : currentWorld.locations; + const handleAddLocation = () => { dispatch({ type: 'ADD_LOCATION', - storyId: currentStory.id, + worldId, + storyId, location: { id: crypto.randomUUID(), name: 'New Location', @@ -30,7 +36,8 @@ export const LocationEditor = () => { const handleEditLocation = (locationId: string, field: keyof Location, value: any) => { dispatch({ type: 'EDIT_LOCATION', - storyId: currentStory.id, + worldId, + storyId, locationId, updates: { [field]: value }, }); @@ -39,7 +46,8 @@ export const LocationEditor = () => { const handleDeleteLocation = (locationId: string) => { dispatch({ type: 'DELETE_LOCATION', - storyId: currentStory.id, + worldId, + storyId, locationId, }); }; @@ -47,7 +55,7 @@ export const LocationEditor = () => { const handleGenerateShortDescription = async (locationId: string) => { if (!connection || !model) return; - const location = currentStory.locations.find(l => l.id === locationId); + const location = locations.find(l => l.id === locationId); if (!location || !location.description.trim()) return; setGeneratingShortDesc(locationId); @@ -64,18 +72,18 @@ export const LocationEditor = () => { return (
-

Locations

+

{currentStory ? 'Story Locations' : 'World Locations'}

- {currentStory.locations.length === 0 && ( + {locations.length === 0 && (

No locations yet. Add your first location!

)} - {currentStory.locations.map((location) => ( + {locations.map((location) => (
{ - const { currentStory, dispatch } = useAppState(); + const { currentWorld, currentStory, dispatch } = useAppState(); const [editingId, setEditingId] = useState(null); const [newTitle, setNewTitle] = useState(''); - if (!currentStory) { + if (!currentWorld) { return null; } + // When a story is selected, edit story-level lore; otherwise world-level lore + const storyId = currentStory?.id ?? null; + const worldId = currentWorld.id; + const lore = currentStory ? currentStory.lore : currentWorld.lore; + const handleAddEntry = () => { if (!newTitle.trim()) return; dispatch({ type: 'ADD_LORE_ENTRY', - storyId: currentStory.id, + worldId, + storyId, entry: { id: crypto.randomUUID(), title: newTitle.trim(), @@ -31,7 +37,8 @@ export const LoreEditor = () => { const handleEditEntry = (entryId: string, field: keyof LoreEntry, value: string) => { dispatch({ type: 'EDIT_LORE_ENTRY', - storyId: currentStory.id, + worldId, + storyId, entryId, updates: { [field]: value }, }); @@ -40,29 +47,32 @@ export const LoreEditor = () => { const handleDeleteEntry = (entryId: string) => { dispatch({ type: 'DELETE_LORE_ENTRY', - storyId: currentStory.id, + worldId, + storyId, entryId, }); }; const handleMoveUp = (index: number) => { if (index === 0) return; - const entryIds = currentStory.lore.map(e => e.id); + const entryIds = lore.map(e => e.id); [entryIds[index - 1], entryIds[index]] = [entryIds[index], entryIds[index - 1]]; dispatch({ type: 'REORDER_LORE_ENTRIES', - storyId: currentStory.id, + worldId, + storyId, entryIds, }); }; const handleMoveDown = (index: number) => { - if (index === currentStory.lore.length - 1) return; - const entryIds = currentStory.lore.map(e => e.id); + if (index === lore.length - 1) return; + const entryIds = lore.map(e => e.id); [entryIds[index], entryIds[index + 1]] = [entryIds[index + 1], entryIds[index]]; dispatch({ type: 'REORDER_LORE_ENTRIES', - storyId: currentStory.id, + worldId, + storyId, entryIds, }); }; @@ -70,7 +80,7 @@ export const LoreEditor = () => { return (
-

Lore

+

{currentStory ? 'Story Lore' : 'World Lore'}

{
- {currentStory.lore.length === 0 && ( + {lore.length === 0 && (

No lore entries yet. Add your first entry!

)} - {currentStory.lore.map((entry, index) => ( + {lore.map((entry, index) => (
@@ -139,7 +149,7 @@ export const LoreEditor = () => { + +
+ + + +
+
+ )} + {isExpanded.value && ( +
+ {world.stories.map(story => ( + onSelectStory(story.id)} + onRename={(title) => onRenameStory(story.id, title)} + onDelete={() => onDeleteStory(story.id)} + onDuplicate={() => onDuplicateStory(story.id)} + /> + ))} + {world.stories.length === 0 && ( +
No stories yet
+ )} +
+ )} +
+ ); +}; + // ─── Menu Sidebar ───────────────────────────────────────────────────────────── export const Menu = () => { - const { stories, currentStory, dispatch } = useAppState(); + const { worlds, currentWorld, currentStory, dispatch } = useAppState(); const isConnectionSettingsOpen = useBool(false); const isSettingsOpen = useBool(false); - const handleCreate = () => { - dispatch({ type: 'CREATE_STORY', title: 'New Story' }); + const handleCreateWorld = () => { + dispatch({ type: 'CREATE_WORLD', title: 'New World' }); }; - const handleSelect = (id: string) => { - dispatch({ type: 'SELECT_STORY', id }); + const handleSelectWorld = (worldId: string) => { + dispatch({ type: 'SELECT_WORLD', worldId }); }; - const handleRename = (id: string, newTitle: string) => { - dispatch({ type: 'RENAME_STORY', id, title: newTitle }); + const handleRenameWorld = (worldId: string, title: string) => { + dispatch({ type: 'RENAME_WORLD', worldId, title }); }; - const handleDelete = (id: string) => { - const story = stories.find(s => s.id === id); - if (!story) return; - if (confirm(`Delete "${story.title}"?`)) { - dispatch({ type: 'DELETE_STORY', id }); + const handleDeleteWorld = (worldId: string) => { + const world = worlds.find(w => w.id === worldId); + if (!world) return; + if (confirm(`Delete world "${world.title}" and all its stories?`)) { + dispatch({ type: 'DELETE_WORLD', worldId }); } }; - const handleDuplicate = (id: string) => { - dispatch({ type: 'DUPLICATE_STORY', id }); + const handleCreateStory = (worldId: string) => { + dispatch({ type: 'CREATE_STORY', worldId, title: 'New Story' }); + }; + + const handleSelectStory = (worldId: string, storyId: string) => { + dispatch({ type: 'SELECT_STORY', worldId, id: storyId }); + }; + + const handleRenameStory = (worldId: string, storyId: string, title: string) => { + dispatch({ type: 'RENAME_STORY', worldId, id: storyId, title }); + }; + + const handleDeleteStory = (worldId: string, storyId: string) => { + const world = worlds.find(w => w.id === worldId); + const story = world?.stories.find(s => s.id === storyId); + if (!story) return; + if (confirm(`Delete "${story.title}"?`)) { + dispatch({ type: 'DELETE_STORY', worldId, id: storyId }); + } + }; + + const handleDuplicateStory = (worldId: string, storyId: string) => { + dispatch({ type: 'DUPLICATE_STORY', worldId, id: storyId }); }; return (
-
- {stories.map(story => ( - handleSelect(story.id)} - onRename={(newTitle) => handleRename(story.id, newTitle)} - onDelete={() => handleDelete(story.id)} - onDuplicate={() => handleDuplicate(story.id)} + {worlds.map(world => ( + handleSelectWorld(world.id)} + onRenameWorld={(title) => handleRenameWorld(world.id, title)} + onDeleteWorld={() => handleDeleteWorld(world.id)} + onCreateStory={() => handleCreateStory(world.id)} + onSelectStory={(storyId) => handleSelectStory(world.id, storyId)} + onRenameStory={(storyId, title) => handleRenameStory(world.id, storyId, title)} + onDeleteStory={(storyId) => handleDeleteStory(world.id, storyId)} + onDuplicateStory={(storyId) => handleDuplicateStory(world.id, storyId)} /> ))}
diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index debd7dc..1327c66 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -1,9 +1,9 @@ import { createContext } from "preact"; import { useContext, useMemo } from "preact/hooks"; +import { useRemoteReducer } from "@common/hooks/useRemote"; import LLM from "../utils/llm"; import Chapters from "../utils/chapters"; -import { useRemoteReducer } from "@common/hooks/useRemote"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -77,10 +77,20 @@ export interface Story { lastEditedText?: string; } +export interface World { + id: string; + title: string; + lore: LoreEntry[]; + characters: Character[]; + locations: Location[]; + stories: Story[]; +} + // ─── State ─────────────────────────────────────────────────────────────────── interface IState { - stories: Story[]; + worlds: World[]; + currentWorldId: string | null; currentStoryId: string | null; currentTab: Tab; chatOpen: boolean; @@ -94,42 +104,85 @@ interface IState { // ─── Actions ───────────────────────────────────────────────────────────────── type Action = - | { type: 'CREATE_STORY'; title: string } - | { type: 'RENAME_STORY'; id: string; title: string } - | { type: 'EDIT_STORY'; id: string; text: string; highlightText?: string } - | { type: 'EDIT_SCRATCHPAD'; id: string; text: 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[] } + // World actions + | { type: 'CREATE_WORLD'; title: string } + | { type: 'RENAME_WORLD'; worldId: string; title: string } + | { type: 'DELETE_WORLD'; worldId: string } + | { type: 'SELECT_WORLD'; worldId: string } + // Story actions + | { type: 'CREATE_STORY'; worldId: string; title: string } + | { type: 'RENAME_STORY'; worldId: string; id: string; title: string } + | { type: 'EDIT_STORY'; worldId: string; id: string; text: string; highlightText?: string } + | { type: 'EDIT_SCRATCHPAD'; worldId: string; id: string; text: string } + | { type: 'DELETE_STORY'; worldId: string; id: string } + | { type: 'SELECT_STORY'; worldId: string; id: string } + | { type: 'DUPLICATE_STORY'; worldId: string; id: string } + // Story lore + | { type: 'ADD_LORE_ENTRY'; worldId: string; storyId: string | null; entry: LoreEntry } + | { type: 'EDIT_LORE_ENTRY'; worldId: string; storyId: string | null; entryId: string; updates: Partial } + | { type: 'DELETE_LORE_ENTRY'; worldId: string; storyId: string | null; entryId: string } + | { type: 'REORDER_LORE_ENTRIES'; worldId: string; storyId: string | null; entryIds: string[] } + // Settings | { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string } | { type: 'SET_CURRENT_TAB'; tab: Tab } | { type: 'SET_CHAT_OPEN'; open: boolean } - | { type: 'DELETE_STORY'; id: string } - | { type: 'SELECT_STORY'; id: string } - | { type: 'DUPLICATE_STORY'; id: string } - | { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage } - | { type: 'CLEAR_CHAT'; storyId: string } + // Chat + | { type: 'ADD_CHAT_MESSAGE'; worldId: string; storyId: string; message: ChatMessage } + | { type: 'CLEAR_CHAT'; worldId: string; storyId: string } + // Connection | { type: 'SET_CONNECTION'; connection: LLM.Connection | null } | { type: 'SET_MODEL'; model: LLM.ModelInfo | null } | { type: 'SET_ENABLE_THINKING'; enable: boolean } | { type: 'SET_BANNED_TOKENS'; tokens: string[] } - | { type: 'ADD_CHARACTER'; storyId: string; character: Character } - | { type: 'EDIT_CHARACTER'; storyId: string; characterId: string; updates: Partial> } - | { type: 'DELETE_CHARACTER'; storyId: string; characterId: string } - | { type: 'ADD_CHARACTER_RELATION'; storyId: string; characterId: string; relation: Character['relations'][number] } - | { type: 'EDIT_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string; updates: Partial } - | { type: 'DELETE_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string } - | { type: 'ADD_LOCATION'; storyId: string; location: Location } - | { type: 'EDIT_LOCATION'; storyId: string; locationId: string; updates: Partial } - | { type: 'DELETE_LOCATION'; storyId: string; locationId: string } - | { type: 'STORE_CHAPTER_SUMMARY'; storyId: string; header: string; hash: Chapters.Hash; summary: string } - | { type: 'CLEAN_CHAPTER_SUMMARIES'; storyId: string; validHashes: Record }; + // Characters + | { type: 'ADD_CHARACTER'; worldId: string; storyId: string | null; character: Character } + | { type: 'EDIT_CHARACTER'; worldId: string; storyId: string | null; characterId: string; updates: Partial> } + | { type: 'DELETE_CHARACTER'; worldId: string; storyId: string | null; characterId: string } + | { type: 'ADD_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; relation: Character['relations'][number] } + | { type: 'EDIT_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; targetName: string; updates: Partial } + | { type: 'DELETE_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; targetName: string } + // Locations + | { type: 'ADD_LOCATION'; worldId: string; storyId: string | null; location: Location } + | { type: 'EDIT_LOCATION'; worldId: string; storyId: string | null; locationId: string; updates: Partial } + | { type: 'DELETE_LOCATION'; worldId: string; storyId: string | null; locationId: string } + // Chapters + | { type: 'STORE_CHAPTER_SUMMARY'; worldId: string; storyId: string; header: string; hash: Chapters.Hash; summary: string } + | { type: 'CLEAN_CHAPTER_SUMMARIES'; worldId: string; storyId: string; validHashes: Record }; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function updateWorld(state: IState, worldId: string, updater: (w: World) => World): IState { + return { + ...state, + worlds: state.worlds.map(w => w.id === worldId ? updater(w) : w), + }; +} + +function updateStory(state: IState, worldId: string, storyId: string, updater: (s: Story) => Story): IState { + return updateWorld(state, worldId, w => ({ + ...w, + stories: w.stories.map(s => s.id === storyId ? updater(s) : s), + })); +} + + +function updateLoreContainer( + state: IState, + worldId: string, + storyId: string | null, + updater: (c: { lore: LoreEntry[]; characters: Character[]; locations: Location[] }) => Partial<{ lore: LoreEntry[]; characters: Character[]; locations: Location[] }> +): IState { + if (storyId) { + return updateStory(state, worldId, storyId, s => ({ ...s, ...updater(s) })); + } + return updateWorld(state, worldId, w => ({ ...w, ...updater(w) })); +} // ─── Initial State ─────────────────────────────────────────────────────────── const DEFAULT_STATE: IState = { - stories: [], + worlds: [], + currentWorldId: null, currentStoryId: null, currentTab: 'menu', chatOpen: false, @@ -139,8 +192,8 @@ const DEFAULT_STATE: IState = { bannedTokens: [], systemInstruction: `You are a creative writing assistant. Help the user develop their story by writing engaging content, maintaining consistency with the established characters, settings, and plot. Follow the user's instructions while staying true to the story's tone and style. -Write using markdown to highlight special parts. -Supported markdown subset: +Write using markdown to highlight special parts. +Supported markdown subset: - *italic* - **bold** - "quotes" @@ -153,7 +206,7 @@ Supported markdown subset: - Ordered lists - Unordered lists (only with \`- \` markers) - Only top-level lists (no nesting) - + Show the chapters with \`# Chapter\` headers. Add important details not yet ready to be included in the story to the scratchpad: character motivations, hidden plot points, etc. You **must** use \`edit_text\` tool to write to the story. @@ -165,6 +218,44 @@ The most actual state of the story is provided below, use it as ground truth.`, function reducer(state: IState, action: Action): IState { switch (action.type) { + case 'CREATE_WORLD': { + const world: World = { + id: crypto.randomUUID(), + title: action.title, + lore: [], + characters: [], + locations: [], + stories: [], + }; + return { + ...state, + worlds: [...state.worlds, world], + currentWorldId: world.id, + currentStoryId: null, + currentTab: 'lore', + }; + } + case 'RENAME_WORLD': { + return updateWorld(state, action.worldId, w => ({ ...w, title: action.title })); + } + case 'DELETE_WORLD': { + const remaining = state.worlds.filter(w => w.id !== action.worldId); + const deletingCurrent = state.currentWorldId === action.worldId; + return { + ...state, + worlds: remaining, + currentWorldId: deletingCurrent ? null : state.currentWorldId, + currentStoryId: deletingCurrent ? null : state.currentStoryId, + }; + } + case 'SELECT_WORLD': { + return { + ...state, + currentWorldId: action.worldId, + currentStoryId: null, + currentTab: 'lore', + }; + } case 'CREATE_STORY': { const story: Story = { id: crypto.randomUUID(), @@ -178,108 +269,46 @@ function reducer(state: IState, action: Action): IState { chapters: [], }; return { - ...state, - stories: [...state.stories, story], + ...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })), + currentWorldId: action.worldId, currentStoryId: story.id, + currentTab: 'story', }; } case 'RENAME_STORY': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.id ? { ...s, title: action.title } : s - ), - }; + return updateStory(state, action.worldId, action.id, s => ({ ...s, title: action.title })); } case 'EDIT_STORY': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.id - ? { ...s, text: action.text, lastEditedText: action.highlightText } - : s - ), - }; + return updateStory(state, action.worldId, action.id, s => ({ + ...s, + text: action.text, + lastEditedText: action.highlightText, + })); } - case 'ADD_LORE_ENTRY': { - return { - ...state, - stories: state.stories.map(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, - systemInstruction: action.systemInstruction, - }; - } - case 'SET_CURRENT_TAB': { - return { ...state, currentTab: action.tab }; - } - case 'SET_CHAT_OPEN': { - return { ...state, chatOpen: action.open }; + case 'EDIT_SCRATCHPAD': { + return updateStory(state, action.worldId, action.id, s => ({ ...s, scratchpad: action.text })); } case 'DELETE_STORY': { - const remaining = state.stories.filter(s => s.id !== action.id); const deletingCurrent = state.currentStoryId === action.id; return { - ...state, - stories: remaining, + ...updateWorld(state, action.worldId, w => ({ + ...w, + stories: w.stories.filter(s => s.id !== action.id), + })), currentStoryId: deletingCurrent ? null : state.currentStoryId, }; } + case 'SELECT_STORY': { + return { + ...state, + currentWorldId: action.worldId, + currentStoryId: action.id, + currentTab: 'story', + }; + } case 'DUPLICATE_STORY': { - const original = state.stories.find(s => s.id === action.id); + const world = state.worlds.find(w => w.id === action.worldId); + const original = world?.stories.find(s => s.id === action.id); if (!original) return state; const newStory: Story = { id: crypto.randomUUID(), @@ -287,254 +316,182 @@ function reducer(state: IState, action: Action): IState { text: '', scratchpad: '', lore: [...original.lore], - characters: original.characters, - locations: original.locations, + characters: [...original.characters], + locations: [...original.locations], chatMessages: [], chapters: [], }; return { - ...state, - stories: [...state.stories, newStory], + ...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })), currentStoryId: newStory.id, + currentTab: 'story', }; } - case 'SELECT_STORY': { - return { ...state, currentStoryId: action.id, currentTab: 'story' }; + case 'ADD_LORE_ENTRY': { + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + lore: [...c.lore, action.entry], + })); + } + case 'EDIT_LORE_ENTRY': { + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + lore: c.lore.map(e => e.id === action.entryId ? { ...e, ...action.updates } : e), + })); + } + case 'DELETE_LORE_ENTRY': { + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + lore: c.lore.filter(e => e.id !== action.entryId), + })); + } + case 'REORDER_LORE_ENTRIES': { + return updateLoreContainer(state, action.worldId, action.storyId, c => { + const entryMap = new Map(c.lore.map(e => [e.id, e])); + const reordered = action.entryIds + .map(id => entryMap.get(id)) + .filter((e): e is LoreEntry => e !== undefined); + for (const entry of c.lore) { + if (!action.entryIds.includes(entry.id)) reordered.push(entry); + } + return { lore: reordered }; + }); + } + case 'SET_SYSTEM_INSTRUCTION': { + return { ...state, systemInstruction: action.systemInstruction }; + } + case 'SET_CURRENT_TAB': { + return { ...state, currentTab: action.tab }; + } + case 'SET_CHAT_OPEN': { + return { ...state, chatOpen: action.open }; } case 'ADD_CHAT_MESSAGE': { - return { - ...state, - stories: state.stories.map(s => { - if (s.id !== action.storyId) return s; - const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id); - if (existingIndex !== -1) { - // Overwrite existing message with same id - const updatedMessages = [...s.chatMessages]; - updatedMessages[existingIndex] = action.message; - return { ...s, chatMessages: updatedMessages }; - } - // Append new message - return { ...s, chatMessages: [...s.chatMessages, action.message] }; - }), - }; + return updateStory(state, action.worldId, action.storyId, s => { + const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id); + if (existingIndex !== -1) { + const updatedMessages = [...s.chatMessages]; + updatedMessages[existingIndex] = action.message; + return { ...s, chatMessages: updatedMessages }; + } + return { ...s, chatMessages: [...s.chatMessages, action.message] }; + }); } case 'CLEAR_CHAT': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.storyId ? { ...s, chatMessages: [] } : s - ), - }; + return updateStory(state, action.worldId, action.storyId, s => ({ ...s, chatMessages: [] })); } case 'SET_CONNECTION': { - return { - ...state, - connection: action.connection, - }; + return { ...state, connection: action.connection }; } case 'SET_MODEL': { - return { - ...state, - model: action.model, - }; + return { ...state, model: action.model }; } case 'SET_ENABLE_THINKING': { - return { - ...state, - enableThinking: action.enable, - }; + return { ...state, enableThinking: action.enable }; } case 'SET_BANNED_TOKENS': { - return { - ...state, - bannedTokens: action.tokens, - }; + return { ...state, bannedTokens: action.tokens }; } case 'ADD_CHARACTER': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.storyId - ? { ...s, characters: [...s.characters, action.character] } - : s - ), - }; + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + characters: [...c.characters, action.character], + })); } case 'EDIT_CHARACTER': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.storyId - ? { - ...s, - characters: s.characters.map(c => - c.id === action.characterId - ? { ...c, ...action.updates } - : c - ), - } - : s + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + characters: c.characters.map(ch => + ch.id === action.characterId ? { ...ch, ...action.updates } : ch ), - }; + })); } case 'DELETE_CHARACTER': { - return { - ...state, - stories: state.stories.map(s => { - if (s.id !== action.storyId) return s; - const deletedChar = s.characters.find(c => c.id === action.characterId); - 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), - })), - }; - }), - }; + return updateLoreContainer(state, action.worldId, action.storyId, c => { + const deleted = c.characters.find(ch => ch.id === action.characterId); + if (!deleted) return {}; + return { + characters: c.characters + .filter(ch => ch.id !== action.characterId) + .map(ch => ({ + ...ch, + relations: ch.relations.filter(r => r.name !== deleted.name), + })), + }; + }); } case 'ADD_CHARACTER_RELATION': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.storyId - ? { - ...s, - characters: s.characters.map(c => - c.id === action.characterId - ? { ...c, relations: [...c.relations, action.relation] } - : c - ), - } - : s + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + characters: c.characters.map(ch => + ch.id === action.characterId + ? { ...ch, relations: [...ch.relations, action.relation] } + : ch ), - }; + })); } case 'EDIT_CHARACTER_RELATION': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.storyId + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + characters: c.characters.map(ch => + ch.id === action.characterId ? { - ...s, - characters: s.characters.map(c => - c.id === action.characterId - ? { - ...c, - relations: c.relations.map(r => - r.name === action.targetName - ? { ...r, ...action.updates } - : r - ), - } - : c + ...ch, + relations: ch.relations.map(r => + r.name === action.targetName ? { ...r, ...action.updates } : r ), } - : s + : ch ), - }; + })); } case 'DELETE_CHARACTER_RELATION': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.storyId - ? { - ...s, - characters: s.characters.map(c => - c.id === action.characterId - ? { ...c, relations: c.relations.filter(r => r.name !== action.targetName) } - : c - ), - } - : s + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + characters: c.characters.map(ch => + ch.id === action.characterId + ? { ...ch, relations: ch.relations.filter(r => r.name !== action.targetName) } + : ch ), - }; + })); } case 'ADD_LOCATION': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.storyId - ? { ...s, locations: [...s.locations, action.location] } - : s - ), - }; + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + locations: [...c.locations, action.location], + })); } case 'EDIT_LOCATION': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.storyId - ? { - ...s, - locations: s.locations.map(l => - l.id === action.locationId - ? { ...l, ...action.updates } - : l - ), - } - : s + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + locations: c.locations.map(l => + l.id === action.locationId ? { ...l, ...action.updates } : l ), - }; + })); } case 'DELETE_LOCATION': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.storyId - ? { ...s, locations: s.locations.filter(l => l.id !== action.locationId) } - : s - ), - }; - } - case 'CLEAN_CHAPTER_SUMMARIES': { - return { - ...state, - stories: state.stories.map(s => { - if (s.id !== action.storyId) return s; - const chapters = (s.chapters ?? []) - .filter(c => action.validHashes[c.header] !== undefined) - .map(c => { - const valid = new Set(action.validHashes[c.header]); - const summaryCache = Object.fromEntries( - Object.entries(c.summaryCache).filter(([hash]) => valid.has(hash)) - ); - return { ...c, summaryCache }; - }); - return { ...s, chapters }; - }), - }; - } - case 'EDIT_SCRATCHPAD': { - return { - ...state, - stories: state.stories.map(s => - s.id === action.id ? { ...s, scratchpad: action.text } : s - ), - }; + return updateLoreContainer(state, action.worldId, action.storyId, c => ({ + locations: c.locations.filter(l => l.id !== action.locationId), + })); } case 'STORE_CHAPTER_SUMMARY': { - return { - ...state, - stories: state.stories.map(s => { - if (s.id !== action.storyId) return s; - const chapters = s.chapters ?? []; - const existing = chapters.find(c => c.header === action.header); - const updated = existing - ? Chapters.storeSummary(existing, action.hash, action.summary) - : Chapters.storeSummary(Chapters.emptyChapter(action.header), action.hash, action.summary); - return { - ...s, - chapters: existing - ? chapters.map(c => c.header === action.header ? updated : c) - : [...chapters, updated], - }; - }), - }; + return updateStory(state, action.worldId, action.storyId, s => { + const chapters = s.chapters ?? []; + const existing = chapters.find(c => c.header === action.header); + const updated = existing + ? Chapters.storeSummary(existing, action.hash, action.summary) + : Chapters.storeSummary(Chapters.emptyChapter(action.header), action.hash, action.summary); + return { + ...s, + chapters: existing + ? chapters.map(c => c.header === action.header ? updated : c) + : [...chapters, updated], + }; + }); + } + case 'CLEAN_CHAPTER_SUMMARIES': { + return updateStory(state, action.worldId, action.storyId, s => { + const chapters = (s.chapters ?? []) + .filter(c => action.validHashes[c.header] !== undefined) + .map(c => { + const valid = new Set(action.validHashes[c.header]); + const summaryCache = Object.fromEntries( + Object.entries(c.summaryCache).filter(([hash]) => valid.has(hash)) + ); + return { ...c, summaryCache }; + }); + return { ...s, chapters }; + }); } } } @@ -542,8 +499,13 @@ function reducer(state: IState, action: Action): IState { // ─── Context ───────────────────────────────────────────────────────────────── export interface AppState { - stories: Story[]; + worlds: World[]; + currentWorld: World | null; currentStory: Story | null; + /** Combined lore/characters/locations: world-level merged with story-level */ + mergedLore: LoreEntry[]; + mergedCharacters: Character[]; + mergedLocations: Location[]; currentTab: Tab; chatOpen: boolean; connection: LLM.Connection | null; @@ -563,18 +525,41 @@ export const useAppState = () => useContext(StateContext); export const StateContextProvider = ({ children }: { children?: any }) => { const [state, dispatch] = useRemoteReducer('storywriter.state', reducer, DEFAULT_STATE); - const value = useMemo(() => ({ - stories: state.stories, - currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null, - currentTab: state.currentTab, - chatOpen: state.chatOpen, - connection: state.connection, - model: state.model, - enableThinking: state.enableThinking, - bannedTokens: state.bannedTokens ?? [], - systemInstruction: state.systemInstruction ?? '', - dispatch, - }), [state]); + const value = useMemo(() => { + const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null; + const currentStory = currentWorld?.stories.find(s => s.id === state.currentStoryId) ?? null; + + // Merge world-level + story-level, story takes priority (appended after) + const mergedLore = [ + ...(currentWorld?.lore ?? []), + ...(currentStory?.lore ?? []), + ]; + const mergedCharacters = [ + ...(currentWorld?.characters ?? []), + ...(currentStory?.characters ?? []), + ]; + const mergedLocations = [ + ...(currentWorld?.locations ?? []), + ...(currentStory?.locations ?? []), + ]; + + return { + worlds: state.worlds, + currentWorld, + currentStory, + mergedLore, + mergedCharacters, + mergedLocations, + currentTab: state.currentTab, + chatOpen: state.chatOpen, + connection: state.connection, + model: state.model, + enableThinking: state.enableThinking, + bannedTokens: state.bannedTokens ?? [], + systemInstruction: state.systemInstruction ?? '', + dispatch, + }; + }, [state]); return ( diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index 1e546e1..dd64156 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -176,8 +176,8 @@ namespace Prompt { }; export function formatCharactersMarkdown(state: AppState): string { - const { currentStory } = state; - if (!currentStory || !currentStory.characters?.length) { + const { mergedCharacters } = state; + if (!mergedCharacters?.length) { return ''; } @@ -185,7 +185,7 @@ namespace Prompt { lines.push('## Characters\n'); // Sort characters by importance (protagonist first, cameo last) - const sortedCharacters = [...currentStory.characters].sort((a, b) => { + const sortedCharacters = [...mergedCharacters].sort((a, b) => { const importanceOrder = [ CharacterRole.Protagonist, CharacterRole.Main, @@ -231,15 +231,15 @@ namespace Prompt { } export function formatLoreMarkdown(state: AppState): string { - const { currentStory } = state; - if (!currentStory?.lore?.length) { + const { mergedLore } = state; + if (!mergedLore?.length) { return ''; } const lines: string[] = []; lines.push('## Lore\n'); - for (const entry of currentStory.lore) { + for (const entry of mergedLore) { lines.push(`### ${entry.title}`); if (entry.text) { lines.push(entry.text); @@ -251,15 +251,15 @@ namespace Prompt { } export function formatLocationsMarkdown(state: AppState): string { - const { currentStory } = state; - if (!currentStory || !currentStory.locations?.length) { + const { mergedLocations } = state; + if (!mergedLocations?.length) { return ''; } const lines: string[] = []; lines.push('## Locations\n'); - for (const location of currentStory.locations) { + for (const location of mergedLocations) { lines.push(`### ${location.name}`); const description = location.shortDescription || location.description; diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts index b276e65..a0c7e87 100644 --- a/src/games/storywriter/utils/tools.ts +++ b/src/games/storywriter/utils/tools.ts @@ -22,7 +22,7 @@ export namespace Tools { if (!appState.currentStory) { return 'Error: No story selected'; } - const character = appState.currentStory.characters.find(c => c.name === args.name.trim()); + const character = appState.mergedCharacters.find(c => c.name === args.name.trim()); if (!character) { return `Error: Character "${args.name.trim()}" not found`; } @@ -50,13 +50,14 @@ export namespace Tools { }), 'set_character': tool({ handler: async (args, appState) => { - if (!appState.currentStory) { + if (!appState.currentStory || !appState.currentWorld) { return 'Error: No story selected'; } - const existingCharacter = appState.currentStory.characters.find(c => c.name === args.name.trim()); + const existingCharacter = appState.mergedCharacters.find(c => c.name === args.name.trim()); if (existingCharacter) { - // Edit existing character + // Edit existing character — find which container it lives in + const inStory = appState.currentStory.characters.some(c => c.id === existingCharacter.id); const definedUpdates: Partial = {}; if (args.shortDescription !== undefined) { definedUpdates.shortDescription = args.shortDescription; @@ -75,25 +76,22 @@ export namespace Tools { } appState.dispatch({ type: 'EDIT_CHARACTER', - storyId: appState.currentStory.id, + worldId: appState.currentWorld.id, + storyId: inStory ? appState.currentStory.id : null, characterId: existingCharacter.id, updates: definedUpdates, }); - appState.dispatch({ - type: 'SET_CURRENT_TAB', - id: appState.currentStory.id, - tab: 'characters' - }); + appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'characters' }); return `Character "${args.name.trim()}" updated successfully`; } else { - // Add new character - validate required fields + // Add new character — add to story level by default if (!args.shortDescription) { return 'Error: shortDescription is required when adding a new character'; } if (args.role === undefined) { return 'Error: role is required when adding a new character'; } - const existingCharacterNames = new Set(appState.currentStory.characters.map(c => c.name)); + const existingCharacterNames = new Set(appState.mergedCharacters.map(c => c.name)); const invalidRelations: string[] = []; for (const rel of args.relations || []) { if (!existingCharacterNames.has(rel.name)) { @@ -102,6 +100,7 @@ export namespace Tools { } appState.dispatch({ type: 'ADD_CHARACTER', + worldId: appState.currentWorld.id, storyId: appState.currentStory.id, character: { id: crypto.randomUUID(), @@ -113,11 +112,7 @@ export namespace Tools { relations: args.relations || [], }, }); - appState.dispatch({ - type: 'SET_CURRENT_TAB', - id: appState.currentStory.id, - tab: 'characters' - }); + appState.dispatch({ type: 'SET_CURRENT_TAB', 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(', ')}`; @@ -143,49 +138,43 @@ export namespace Tools { }), 'set_character_relation': tool({ handler: async (args, appState) => { - if (!appState.currentStory) { + if (!appState.currentStory || !appState.currentWorld) { return 'Error: No story selected'; } - const character = appState.currentStory.characters.find(c => c.name === args.character_name.trim()); + const character = appState.mergedCharacters.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()); + const targetCharacter = appState.mergedCharacters.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 inStory = appState.currentStory.characters.some(c => c.id === character.id); + const storyId = inStory ? appState.currentStory.id : null; 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, + worldId: appState.currentWorld.id, + storyId, 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' - }); + appState.dispatch({ type: 'SET_CURRENT_TAB', 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, + worldId: appState.currentWorld.id, + storyId, 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' - }); + appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'characters' }); return `Relation added: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`; } }, @@ -201,7 +190,7 @@ export namespace Tools { if (!appState.currentStory) { return 'Error: No story selected'; } - const location = appState.currentStory.locations.find(l => l.name === args.name.trim()); + const location = appState.mergedLocations.find(l => l.name === args.name.trim()); if (!location) { return `Error: Location "${args.name.trim()}" not found`; } @@ -220,12 +209,12 @@ export namespace Tools { }), 'set_location': tool({ handler: async (args, appState) => { - if (!appState.currentStory) { + if (!appState.currentStory || !appState.currentWorld) { return 'Error: No story selected'; } - const existingLocation = appState.currentStory.locations.find(l => l.name === args.name.trim()); + const existingLocation = appState.mergedLocations.find(l => l.name === args.name.trim()); if (existingLocation) { - // Edit existing location + const inStory = appState.currentStory.locations.some(l => l.id === existingLocation.id); const definedUpdates: Partial = {}; if (args.shortDescription) { definedUpdates.shortDescription = args.shortDescription; @@ -238,18 +227,14 @@ export namespace Tools { } appState.dispatch({ type: 'EDIT_LOCATION', - storyId: appState.currentStory.id, + worldId: appState.currentWorld.id, + storyId: inStory ? appState.currentStory.id : null, locationId: existingLocation.id, updates: definedUpdates, }); - appState.dispatch({ - type: 'SET_CURRENT_TAB', - id: appState.currentStory.id, - tab: 'locations' - }); + appState.dispatch({ type: 'SET_CURRENT_TAB', 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'; } @@ -258,6 +243,7 @@ export namespace Tools { } appState.dispatch({ type: 'ADD_LOCATION', + worldId: appState.currentWorld.id, storyId: appState.currentStory.id, location: { id: crypto.randomUUID(), @@ -267,11 +253,7 @@ export namespace Tools { scale: args.scale, }, }); - appState.dispatch({ - type: 'SET_CURRENT_TAB', - id: appState.currentStory.id, - tab: 'locations' - }); + appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'locations' }); return `Location "${args.name.trim()}" added successfully`; } }, @@ -287,27 +269,26 @@ export namespace Tools { }), 'set_lore_entry': tool({ handler: async (args, appState) => { - if (!appState.currentStory) { + if (!appState.currentStory || !appState.currentWorld) { return 'Error: No story selected'; } - const existingEntry = appState.currentStory.lore.find(e => e.title.toLowerCase() === args.title.trim().toLowerCase()); + const existingEntry = appState.mergedLore.find(e => e.title.toLowerCase() === args.title.trim().toLowerCase()); if (existingEntry) { + const inStory = appState.currentStory.lore.some(e => e.id === existingEntry.id); appState.dispatch({ type: 'EDIT_LORE_ENTRY', - storyId: appState.currentStory.id, + worldId: appState.currentWorld.id, + storyId: inStory ? appState.currentStory.id : null, entryId: existingEntry.id, updates: { text: args.text }, }); - appState.dispatch({ - type: 'SET_CURRENT_TAB', - id: appState.currentStory.id, - tab: 'lore' - }); + appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'lore' }); return `Lore entry "${existingEntry.title}" updated successfully`; } else { appState.dispatch({ type: 'ADD_LORE_ENTRY', + worldId: appState.currentWorld.id, storyId: appState.currentStory.id, entry: { id: crypto.randomUUID(), @@ -315,11 +296,7 @@ export namespace Tools { text: args.text, }, }); - appState.dispatch({ - type: 'SET_CURRENT_TAB', - id: appState.currentStory.id, - tab: 'lore' - }); + appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'lore' }); return `Lore entry "${args.title.trim()}" added successfully`; } }, @@ -331,7 +308,7 @@ export namespace Tools { }), 'edit_text': tool({ handler: async (args, appState) => { - if (!appState.currentStory) { + if (!appState.currentStory || !appState.currentWorld) { return 'Error: No story selected'; } const target = args.target ?? 'story'; @@ -341,8 +318,8 @@ export namespace Tools { const dispatchEdit = (text: string) => appState.dispatch( isScratchpad - ? { type: 'EDIT_SCRATCHPAD', id: appState.currentStory!.id, text } - : { type: 'EDIT_STORY', id: appState.currentStory!.id, text, highlightText: args.new_text } + ? { type: 'EDIT_SCRATCHPAD', worldId: appState.currentWorld!.id, id: appState.currentStory!.id, text } + : { type: 'EDIT_STORY', worldId: appState.currentWorld!.id, id: appState.currentStory!.id, text, highlightText: args.new_text } ); // Append mode: when old_text is not provided, append new_text @@ -397,7 +374,7 @@ export namespace Tools { } dispatchEdit(currentText + '\n' + args.new_text); - appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab }); + appState.dispatch({ type: 'SET_CURRENT_TAB', tab }); let message = cropped ? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nNote: The rest was cropped due to ${LINES_LIMIT} lines limit!` : `Text appended to ${target} successfully.`; @@ -417,7 +394,7 @@ export namespace Tools { } dispatchEdit(currentText.replaceAll(args.old_text, args.new_text)); - appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab }); + appState.dispatch({ type: 'SET_CURRENT_TAB', tab }); return `${target.charAt(0).toUpperCase() + target.slice(1)} edited successfully`; }, description: `Replace or append text in the story or scratchpad. When old_text is omitted, appends new_text to the end. Case-sensitive. When appending to the main story, you can add no more than ${LINES_LIMIT} non-empty lines at once.`, @@ -436,11 +413,11 @@ export namespace Tools { const sources: { name: string; content: string }[] = [ { name: 'story', content: appState.currentStory.text }, - ...appState.currentStory.lore.flatMap(e => [ + ...appState.mergedLore.flatMap(e => [ { name: `lore:${e.title}`, content: e.title }, { name: `lore:${e.title}`, content: e.text }, ]), - ...appState.currentStory.characters.flatMap(c => [ + ...appState.mergedCharacters.flatMap(c => [ { name: `character:${c.name}`, content: c.name }, { name: `character:${c.name}`, content: c.shortDescription }, { name: `character:${c.name}`, content: c.description || '' }, @@ -448,7 +425,7 @@ export namespace Tools { { name: `character:${c.name}`, content: `relation: ${rel.name} (${rel.relation})` } )), ]), - ...appState.currentStory.locations.flatMap(l => [ + ...appState.mergedLocations.flatMap(l => [ { name: `location:${l.name}`, content: l.name }, { name: `location:${l.name}`, content: l.shortDescription }, { name: `location:${l.name}`, content: l.description || '' }, @@ -486,7 +463,7 @@ export namespace Tools { } return result; }, - 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 (includes world-level data)', parameters: Type.Object({ 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)' })), diff --git a/src/games/storywriter/utils/useChapterSummarization.ts b/src/games/storywriter/utils/useChapterSummarization.ts index b456e93..566a753 100644 --- a/src/games/storywriter/utils/useChapterSummarization.ts +++ b/src/games/storywriter/utils/useChapterSummarization.ts @@ -11,8 +11,8 @@ export function useChapterSummarization() { const [isSummarizing, setIsSummarizing] = useState(false); const summarizeAll = async () => { - const { currentStory, connection, model, dispatch } = stateRef.current; - if (!currentStory || !connection || !model || isSummarizing) return; + const { currentWorld, currentStory, connection, model, dispatch } = stateRef.current; + if (!currentWorld || !currentStory || !connection || !model || isSummarizing) return; setIsSummarizing(true); try { @@ -34,6 +34,7 @@ export function useChapterSummarization() { const newSummary = await LLM.summarize(connection, model.id, body); dispatch({ type: 'STORE_CHAPTER_SUMMARY', + worldId: currentWorld.id, storyId: currentStory.id, header: parsedChapter.header, hash, @@ -48,6 +49,7 @@ export function useChapterSummarization() { // Clean up stale cache entries dispatch({ type: 'CLEAN_CHAPTER_SUMMARIES', + worldId: currentWorld.id, storyId: currentStory.id, validHashes, });