1
0
Fork 0
This commit is contained in:
Pabloader 2026-04-07 07:47:04 +00:00
parent ad1239273a
commit 64b913374e
13 changed files with 751 additions and 537 deletions

View File

@ -15,6 +15,21 @@
font-weight: bold; font-weight: bold;
color: var(--text); color: var(--text);
text-align: center; 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;
} }

View File

@ -5,6 +5,57 @@
height: 100%; 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 { .newButton {
width: 100%; width: 100%;
padding: 6px 8px; padding: 6px 8px;

View File

@ -6,7 +6,7 @@ import { highlight } from "@common/highlight";
import styles from "../assets/chapters-editor.module.css"; import styles from "../assets/chapters-editor.module.css";
export const ChaptersEditor = () => { export const ChaptersEditor = () => {
const { currentStory, dispatch } = useAppState(); const { currentWorld, currentStory, dispatch } = useAppState();
if (!currentStory) return null; if (!currentStory) return null;
@ -50,6 +50,7 @@ export const ChaptersEditor = () => {
placeholder="Not summarized yet..." placeholder="Not summarized yet..."
onInput={(e) => dispatch({ onInput={(e) => dispatch({
type: 'STORE_CHAPTER_SUMMARY', type: 'STORE_CHAPTER_SUMMARY',
worldId: currentWorld!.id,
storyId: currentStory.id, storyId: currentStory.id,
header: parsedChapter.header, header: parsedChapter.header,
hash, hash,

View File

@ -5,20 +5,26 @@ import LLM from "../utils/llm";
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
export const CharacterEditor = () => { export const CharacterEditor = () => {
const { currentStory, dispatch, connection, model } = useAppState(); const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState();
const [newNickname, setNewNickname] = useState<Record<string, string>>({}); const [newNickname, setNewNickname] = useState<Record<string, string>>({});
const [newRelation, setNewRelation] = useState<Record<string, { name: string; relation: string }>>({}); const [newRelation, setNewRelation] = useState<Record<string, { name: string; relation: string }>>({});
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [generatingShortDesc, setGeneratingShortDesc] = useState<string | null>(null); const [generatingShortDesc, setGeneratingShortDesc] = useState<string | null>(null);
if (!currentStory) { if (!currentWorld) {
return null; 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 = () => { const handleAddCharacter = () => {
dispatch({ dispatch({
type: 'ADD_CHARACTER', type: 'ADD_CHARACTER',
storyId: currentStory.id, worldId,
storyId,
character: { character: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: 'New Character', name: 'New Character',
@ -34,7 +40,8 @@ export const CharacterEditor = () => {
const handleEditCharacter = (characterId: string, field: keyof Character, value: any) => { const handleEditCharacter = (characterId: string, field: keyof Character, value: any) => {
dispatch({ dispatch({
type: 'EDIT_CHARACTER', type: 'EDIT_CHARACTER',
storyId: currentStory.id, worldId,
storyId,
characterId, characterId,
updates: { [field]: value }, updates: { [field]: value },
}); });
@ -43,7 +50,8 @@ export const CharacterEditor = () => {
const handleDeleteCharacter = (characterId: string) => { const handleDeleteCharacter = (characterId: string) => {
dispatch({ dispatch({
type: 'DELETE_CHARACTER', type: 'DELETE_CHARACTER',
storyId: currentStory.id, worldId,
storyId,
characterId, characterId,
}); });
}; };
@ -54,7 +62,8 @@ export const CharacterEditor = () => {
dispatch({ dispatch({
type: 'ADD_CHARACTER_RELATION', type: 'ADD_CHARACTER_RELATION',
storyId: currentStory.id, worldId,
storyId,
characterId, characterId,
relation: { name: rel.name.trim(), relation: rel.relation.trim() }, 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) => { const handleEditRelation = (characterId: string, targetName: string, field: 'name' | 'relation', value: string) => {
dispatch({ dispatch({
type: 'EDIT_CHARACTER_RELATION', type: 'EDIT_CHARACTER_RELATION',
storyId: currentStory.id, worldId,
storyId,
characterId, characterId,
targetName, targetName,
updates: { [field]: value }, updates: { [field]: value },
@ -74,7 +84,8 @@ export const CharacterEditor = () => {
const handleDeleteRelation = (characterId: string, targetName: string) => { const handleDeleteRelation = (characterId: string, targetName: string) => {
dispatch({ dispatch({
type: 'DELETE_CHARACTER_RELATION', type: 'DELETE_CHARACTER_RELATION',
storyId: currentStory.id, worldId,
storyId,
characterId, characterId,
targetName, targetName,
}); });
@ -84,7 +95,7 @@ export const CharacterEditor = () => {
const nickname = (newNickname[characterId] || '').trim(); const nickname = (newNickname[characterId] || '').trim();
if (!nickname) return; if (!nickname) return;
const character = currentStory.characters.find(c => c.id === characterId); const character = characters.find(c => c.id === characterId);
if (character) { if (character) {
handleEditCharacter(characterId, 'nicknames', [...character.nicknames, nickname]); handleEditCharacter(characterId, 'nicknames', [...character.nicknames, nickname]);
setNewNickname({ ...newNickname, [characterId]: '' }); setNewNickname({ ...newNickname, [characterId]: '' });
@ -92,7 +103,7 @@ export const CharacterEditor = () => {
}; };
const handleNicknameDelete = (characterId: string, nickname: string) => { 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) { if (character) {
handleEditCharacter(characterId, 'nicknames', character.nicknames.filter(n => n !== nickname)); handleEditCharacter(characterId, 'nicknames', character.nicknames.filter(n => n !== nickname));
} }
@ -106,7 +117,7 @@ export const CharacterEditor = () => {
const handleGenerateShortDescription = async (characterId: string) => { const handleGenerateShortDescription = async (characterId: string) => {
if (!connection || !model) return; 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; if (!character || !character.description.trim()) return;
setGeneratingShortDesc(characterId); setGeneratingShortDesc(characterId);
@ -123,18 +134,18 @@ export const CharacterEditor = () => {
return ( return (
<div class={styles.characterEditor}> <div class={styles.characterEditor}>
<div class={styles.header}> <div class={styles.header}>
<h2>Characters</h2> <h2>{currentStory ? 'Story Characters' : 'World Characters'}</h2>
<button class={styles.addButton} onClick={handleAddCharacter}> <button class={styles.addButton} onClick={handleAddCharacter}>
+ Add Character + Add Character
</button> </button>
</div> </div>
<div class={styles.list}> <div class={styles.list}>
{currentStory.characters.length === 0 && ( {characters.length === 0 && (
<p class={styles.empty}>No characters yet. Add your first character!</p> <p class={styles.empty}>No characters yet. Add your first character!</p>
)} )}
{currentStory.characters.map((character) => ( {characters.map((character) => (
<div key={character.id} class={styles.characterCard}> <div key={character.id} class={styles.characterCard}>
<div class={styles.cardHeader}> <div class={styles.cardHeader}>
<input <input
@ -275,7 +286,7 @@ export const CharacterEditor = () => {
onInput={(e) => handleNewRelationChange(character.id, 'name', e.currentTarget.value)} onInput={(e) => handleNewRelationChange(character.id, 'name', e.currentTarget.value)}
> >
<option value="" disabled>Select character</option> <option value="" disabled>Select character</option>
{currentStory.characters {mergedCharacters
.filter(c => c.id !== character.id) .filter(c => c.id !== character.id)
.map(c => ( .map(c => (
<option key={c.id} value={c.name}>{c.name}</option> <option key={c.id} value={c.name}>{c.name}</option>
@ -313,7 +324,7 @@ export const CharacterEditor = () => {
value={rel.name} value={rel.name}
onInput={(e) => handleEditRelation(character.id, rel.name, 'name', e.currentTarget.value)} onInput={(e) => handleEditRelation(character.id, rel.name, 'name', e.currentTarget.value)}
> >
{currentStory.characters {mergedCharacters
.filter(c => c.id !== character.id) .filter(c => c.id !== character.id)
.map(c => ( .map(c => (
<option key={c.id} value={c.name}>{c.name}</option> <option key={c.id} value={c.name}>{c.name}</option>

View File

@ -44,7 +44,7 @@ const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
export const ChatSidebar = () => { export const ChatSidebar = () => {
const appState = useAppState(); 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 { summarizeAll, isSummarizing } = useChapterSummarization();
const [input, setInput] = useInputState(''); const [input, setInput] = useInputState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -126,11 +126,12 @@ export const ChatSidebar = () => {
}, [currentStory, connection, model, input, currentStory?.chatMessages.length]); }, [currentStory, connection, model, input, currentStory?.chatMessages.length]);
const sendMessage = useCallback(async (newMessages: ChatMessage[]) => { const sendMessage = useCallback(async (newMessages: ChatMessage[]) => {
if (!currentStory || !connection || !model) return; if (!currentStory || !currentWorld || !connection || !model) return;
for (const message of newMessages) { for (const message of newMessages) {
dispatch({ dispatch({
type: 'ADD_CHAT_MESSAGE', type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id, storyId: currentStory.id,
message, message,
}); });
@ -139,6 +140,7 @@ export const ChatSidebar = () => {
const assistantMessageId = crypto.randomUUID(); const assistantMessageId = crypto.randomUUID();
dispatch({ dispatch({
type: 'ADD_CHAT_MESSAGE', type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id, storyId: currentStory.id,
message: { message: {
id: assistantMessageId, id: assistantMessageId,
@ -182,6 +184,7 @@ export const ChatSidebar = () => {
if (content || reasoningContent) { if (content || reasoningContent) {
dispatch({ dispatch({
type: 'ADD_CHAT_MESSAGE', type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id, storyId: currentStory.id,
message: { message: {
id: assistantMessageId, id: assistantMessageId,
@ -202,6 +205,7 @@ export const ChatSidebar = () => {
}; };
dispatch({ dispatch({
type: 'ADD_CHAT_MESSAGE', type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id, storyId: currentStory.id,
message: assistantMessage, message: assistantMessage,
}); });
@ -221,6 +225,7 @@ export const ChatSidebar = () => {
}; };
dispatch({ dispatch({
type: 'ADD_CHAT_MESSAGE', type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id,
storyId: currentStory.id, storyId: currentStory.id,
message, message,
}); });
@ -291,9 +296,10 @@ export const ChatSidebar = () => {
}; };
const handleClear = () => { const handleClear = () => {
if (!currentStory) return; if (!currentStory || !currentWorld) return;
dispatch({ dispatch({
type: 'CLEAR_CHAT', type: 'CLEAR_CHAT',
worldId: currentWorld.id,
storyId: currentStory.id, storyId: currentStory.id,
}); });
}; };

View File

@ -11,9 +11,10 @@ import { LoreEditor } from "./lore-editor";
import { Menu } from "./menu"; import { Menu } from "./menu";
import { useInputCallback } from "@common/hooks/useInputCallback"; import { useInputCallback } from "@common/hooks/useInputCallback";
import Prompt from "../utils/prompt"; 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: "menu", label: "Menu", icon: List },
{ id: "story", label: "Story", icon: BookOpen }, { id: "story", label: "Story", icon: BookOpen },
{ id: "chapters", label: "Chapters", icon: Layers }, { 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 }, { 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 = () => { export const Editor = () => {
const appState = useAppState(); const appState = useAppState();
const { currentStory, currentTab, chatOpen, dispatch } = appState; const { currentWorld, currentStory, currentTab, chatOpen, dispatch } = appState;
const handleInput = useInputCallback((text: string) => { const handleInput = useInputCallback((text: string) => {
if (!currentStory) return; if (!currentStory || !currentWorld) return;
dispatch({ type: 'EDIT_STORY', id: currentStory.id, text }); dispatch({ type: 'EDIT_STORY', worldId: currentWorld.id, id: currentStory.id, text });
}, [currentStory?.id]); }, [currentStory?.id, currentWorld?.id]);
const handleScratchpadInput = useInputCallback((text: string) => { const handleScratchpadInput = useInputCallback((text: string) => {
if (!currentStory) return; if (!currentStory || !currentWorld) return;
dispatch({ type: 'EDIT_SCRATCHPAD', id: currentStory.id, text }); dispatch({ type: 'EDIT_SCRATCHPAD', worldId: currentWorld.id, id: currentStory.id, text });
}, [currentStory?.id]); }, [currentStory?.id, currentWorld?.id]);
const handleTabChange = (tab: Tab) => { const handleTabChange = (tab: Tab) => {
dispatch({ type: 'SET_CURRENT_TAB', tab }); dispatch({ type: 'SET_CURRENT_TAB', tab });
@ -89,11 +98,23 @@ export const Editor = () => {
} }
}); });
return () => cancelAnimationFrame(raf); 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
? <div class={styles.title}>
<span class={styles.titleWorld}>{currentWorld?.title}</span>
<span class={styles.titleSep}>/</span>{currentStory.title}</div>
: currentWorld
? <div class={styles.title}><Globe size={24} />{currentWorld.title}</div>
: null;
return ( return (
<div class={styles.editor}> <div class={styles.editor}>
{currentStory && <div class={styles.title}>{currentStory.title}</div>} {titleBar}
<div class={clsx(styles.content, currentTab === 'menu' && styles.menuContent)} ref={contentRef}> <div class={clsx(styles.content, currentTab === 'menu' && styles.menuContent)} ref={contentRef}>
{currentTab === "menu" && ( {currentTab === "menu" && (
<Menu /> <Menu />
@ -106,13 +127,13 @@ export const Editor = () => {
placeholder="Start writing your story..." placeholder="Start writing your story..."
/> />
)} )}
{currentTab === "lore" && currentStory && ( {currentTab === "lore" && (currentStory || currentWorld) && (
<LoreEditor /> <LoreEditor />
)} )}
{currentTab === "characters" && currentStory && ( {currentTab === "characters" && (currentStory || currentWorld) && (
<CharacterEditor /> <CharacterEditor />
)} )}
{currentTab === "locations" && currentStory && ( {currentTab === "locations" && (currentStory || currentWorld) && (
<LocationEditor /> <LocationEditor />
)} )}
{currentTab === "chapters" && currentStory && ( {currentTab === "chapters" && currentStory && (
@ -131,7 +152,7 @@ export const Editor = () => {
)} )}
</div> </div>
<div class={styles.tabs}> <div class={styles.tabs}>
{TABS.filter(tab => currentStory || tab.id === 'menu').map((tab) => ( {tabs.filter(tab => hasSelection || tab.id === 'menu').map((tab) => (
<button <button
key={tab.id} key={tab.id}
class={clsx(styles.tab, currentTab === tab.id && styles.active, tab.right && styles.tabRight)} class={clsx(styles.tab, currentTab === tab.id && styles.active, tab.right && styles.tabRight)}
@ -142,14 +163,16 @@ export const Editor = () => {
<span class={styles.tabLabel}>{tab.label}</span> <span class={styles.tabLabel}>{tab.label}</span>
</button> </button>
))} ))}
<button {currentStory && (
class={clsx(styles.tab, styles.tabRight, chatOpen && styles.active)} <button
onClick={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })} class={clsx(styles.tab, styles.tabRight, chatOpen && styles.active)}
title="Chat" onClick={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })}
> title="Chat"
<MessageSquare size={15} /> >
<span class={styles.tabLabel}>Chat</span> <MessageSquare size={15} />
</button> <span class={styles.tabLabel}>Chat</span>
</button>
)}
</div> </div>
</div> </div>
); );

View File

@ -5,18 +5,24 @@ import LLM from "../utils/llm";
import { ContentEditable } from "@common/components/ContentEditable"; import { ContentEditable } from "@common/components/ContentEditable";
export const LocationEditor = () => { export const LocationEditor = () => {
const { currentStory, dispatch, connection, model } = useAppState(); const { currentWorld, currentStory, dispatch, connection, model } = useAppState();
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [generatingShortDesc, setGeneratingShortDesc] = useState<string | null>(null); const [generatingShortDesc, setGeneratingShortDesc] = useState<string | null>(null);
if (!currentStory) { if (!currentWorld) {
return null; 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 = () => { const handleAddLocation = () => {
dispatch({ dispatch({
type: 'ADD_LOCATION', type: 'ADD_LOCATION',
storyId: currentStory.id, worldId,
storyId,
location: { location: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: 'New Location', name: 'New Location',
@ -30,7 +36,8 @@ export const LocationEditor = () => {
const handleEditLocation = (locationId: string, field: keyof Location, value: any) => { const handleEditLocation = (locationId: string, field: keyof Location, value: any) => {
dispatch({ dispatch({
type: 'EDIT_LOCATION', type: 'EDIT_LOCATION',
storyId: currentStory.id, worldId,
storyId,
locationId, locationId,
updates: { [field]: value }, updates: { [field]: value },
}); });
@ -39,7 +46,8 @@ export const LocationEditor = () => {
const handleDeleteLocation = (locationId: string) => { const handleDeleteLocation = (locationId: string) => {
dispatch({ dispatch({
type: 'DELETE_LOCATION', type: 'DELETE_LOCATION',
storyId: currentStory.id, worldId,
storyId,
locationId, locationId,
}); });
}; };
@ -47,7 +55,7 @@ export const LocationEditor = () => {
const handleGenerateShortDescription = async (locationId: string) => { const handleGenerateShortDescription = async (locationId: string) => {
if (!connection || !model) return; 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; if (!location || !location.description.trim()) return;
setGeneratingShortDesc(locationId); setGeneratingShortDesc(locationId);
@ -64,18 +72,18 @@ export const LocationEditor = () => {
return ( return (
<div class={styles.locationEditor}> <div class={styles.locationEditor}>
<div class={styles.header}> <div class={styles.header}>
<h2>Locations</h2> <h2>{currentStory ? 'Story Locations' : 'World Locations'}</h2>
<button class={styles.addButton} onClick={handleAddLocation}> <button class={styles.addButton} onClick={handleAddLocation}>
+ Add Location + Add Location
</button> </button>
</div> </div>
<div class={styles.list}> <div class={styles.list}>
{currentStory.locations.length === 0 && ( {locations.length === 0 && (
<p class={styles.empty}>No locations yet. Add your first location!</p> <p class={styles.empty}>No locations yet. Add your first location!</p>
)} )}
{currentStory.locations.map((location) => ( {locations.map((location) => (
<div key={location.id} class={styles.locationCard}> <div key={location.id} class={styles.locationCard}>
<div class={styles.cardHeader}> <div class={styles.cardHeader}>
<input <input

View File

@ -4,20 +4,26 @@ import { ContentEditable } from "@common/components/ContentEditable";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
export const LoreEditor = () => { export const LoreEditor = () => {
const { currentStory, dispatch } = useAppState(); const { currentWorld, currentStory, dispatch } = useAppState();
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
if (!currentStory) { if (!currentWorld) {
return null; 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 = () => { const handleAddEntry = () => {
if (!newTitle.trim()) return; if (!newTitle.trim()) return;
dispatch({ dispatch({
type: 'ADD_LORE_ENTRY', type: 'ADD_LORE_ENTRY',
storyId: currentStory.id, worldId,
storyId,
entry: { entry: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: newTitle.trim(), title: newTitle.trim(),
@ -31,7 +37,8 @@ export const LoreEditor = () => {
const handleEditEntry = (entryId: string, field: keyof LoreEntry, value: string) => { const handleEditEntry = (entryId: string, field: keyof LoreEntry, value: string) => {
dispatch({ dispatch({
type: 'EDIT_LORE_ENTRY', type: 'EDIT_LORE_ENTRY',
storyId: currentStory.id, worldId,
storyId,
entryId, entryId,
updates: { [field]: value }, updates: { [field]: value },
}); });
@ -40,29 +47,32 @@ export const LoreEditor = () => {
const handleDeleteEntry = (entryId: string) => { const handleDeleteEntry = (entryId: string) => {
dispatch({ dispatch({
type: 'DELETE_LORE_ENTRY', type: 'DELETE_LORE_ENTRY',
storyId: currentStory.id, worldId,
storyId,
entryId, entryId,
}); });
}; };
const handleMoveUp = (index: number) => { const handleMoveUp = (index: number) => {
if (index === 0) return; 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]]; [entryIds[index - 1], entryIds[index]] = [entryIds[index], entryIds[index - 1]];
dispatch({ dispatch({
type: 'REORDER_LORE_ENTRIES', type: 'REORDER_LORE_ENTRIES',
storyId: currentStory.id, worldId,
storyId,
entryIds, entryIds,
}); });
}; };
const handleMoveDown = (index: number) => { const handleMoveDown = (index: number) => {
if (index === currentStory.lore.length - 1) return; if (index === lore.length - 1) return;
const entryIds = currentStory.lore.map(e => e.id); const entryIds = lore.map(e => e.id);
[entryIds[index], entryIds[index + 1]] = [entryIds[index + 1], entryIds[index]]; [entryIds[index], entryIds[index + 1]] = [entryIds[index + 1], entryIds[index]];
dispatch({ dispatch({
type: 'REORDER_LORE_ENTRIES', type: 'REORDER_LORE_ENTRIES',
storyId: currentStory.id, worldId,
storyId,
entryIds, entryIds,
}); });
}; };
@ -70,7 +80,7 @@ export const LoreEditor = () => {
return ( return (
<div class={styles.loreEditor}> <div class={styles.loreEditor}>
<div class={styles.header}> <div class={styles.header}>
<h2>Lore</h2> <h2>{currentStory ? 'Story Lore' : 'World Lore'}</h2>
<div class={styles.addEntry}> <div class={styles.addEntry}>
<input <input
type="text" type="text"
@ -92,11 +102,11 @@ export const LoreEditor = () => {
</div> </div>
<div class={styles.list}> <div class={styles.list}>
{currentStory.lore.length === 0 && ( {lore.length === 0 && (
<p class={styles.empty}>No lore entries yet. Add your first entry!</p> <p class={styles.empty}>No lore entries yet. Add your first entry!</p>
)} )}
{currentStory.lore.map((entry, index) => ( {lore.map((entry, index) => (
<div key={entry.id} class={styles.entryCard}> <div key={entry.id} class={styles.entryCard}>
<div class={styles.cardHeader}> <div class={styles.cardHeader}>
<div class={styles.titleRow}> <div class={styles.titleRow}>
@ -139,7 +149,7 @@ export const LoreEditor = () => {
<button <button
class={styles.moveButton} class={styles.moveButton}
onClick={() => handleMoveDown(index)} onClick={() => handleMoveDown(index)}
disabled={index === currentStory.lore.length - 1} disabled={index === lore.length - 1}
title="Move down" title="Move down"
> >

View File

@ -4,9 +4,43 @@ import { SettingsModal } from "./settings-modal";
import { useAppState } from "../contexts/state"; import { useAppState } from "../contexts/state";
import { useBool } from "@common/hooks/useBool"; import { useBool } from "@common/hooks/useBool";
import { useInputState } from "@common/hooks/useInputState"; import { useInputState } from "@common/hooks/useInputState";
import type { Story } from "../contexts/state"; import type { World, Story } from "../contexts/state";
import styles from '../assets/menu.module.css'; import styles from '../assets/menu.module.css';
import { Pencil, X, Plus, Plug, Settings, Copy } from "lucide-preact"; import { Pencil, X, Plus, Plug, Settings, Copy, ChevronRight, ChevronDown, Globe } from "lucide-preact";
// ─── Inline Rename Input ──────────────────────────────────────────────────────
interface RenameInputProps {
value: string;
onSubmit: (title: string) => void;
onCancel: () => void;
className?: string;
}
const RenameInput = ({ value, onSubmit, onCancel, className }: RenameInputProps) => {
const [editTitle, setEditTitle] = useInputState(value);
const handleSubmit = () => {
if (editTitle.trim()) onSubmit(editTitle.trim());
else onCancel();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') handleSubmit();
else if (e.key === 'Escape') onCancel();
};
return (
<input
class={clsx(styles.input, className)}
value={editTitle}
onInput={setEditTitle}
onKeyDown={handleKeyDown}
onBlur={handleSubmit}
autoFocus
/>
);
};
// ─── Story Item ─────────────────────────────────────────────────────────────── // ─── Story Item ───────────────────────────────────────────────────────────────
@ -21,45 +55,21 @@ interface StoryItemProps {
const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }: StoryItemProps) => { const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }: StoryItemProps) => {
const isEditing = useBool(false); const isEditing = useBool(false);
const [editTitle, setEditTitle] = useInputState(story.title);
const handleSubmit = () => {
if (editTitle.trim()) {
onRename(editTitle.trim());
}
isEditing.setFalse();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit();
} else if (e.key === 'Escape') {
setEditTitle(story.title);
isEditing.setFalse();
}
};
const handleBlur = () => {
handleSubmit();
};
if (isEditing.value) { if (isEditing.value) {
return ( return (
<div class={clsx(styles.itemWrapper, active && styles.active)}> <div class={clsx(styles.itemWrapper, styles.storyItem, active && styles.active)}>
<input <RenameInput
class={styles.input} value={story.title}
value={editTitle} onSubmit={(t) => { onRename(t); isEditing.setFalse(); }}
onInput={setEditTitle} onCancel={isEditing.setFalse}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
autoFocus
/> />
</div> </div>
); );
} }
return ( return (
<div class={clsx(styles.itemWrapper, active && styles.active)}> <div class={clsx(styles.itemWrapper, styles.storyItem, active && styles.active)}>
<button <button
class={clsx(styles.item, active && styles.active)} class={clsx(styles.item, active && styles.active)}
onClick={onSelect} onClick={onSelect}
@ -82,52 +92,167 @@ const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }:
); );
}; };
// ─── World Item ───────────────────────────────────────────────────────────────
interface WorldItemProps {
world: World;
activeWorldId: string | null;
activeStoryId: string | null;
onSelectWorld: () => void;
onRenameWorld: (title: string) => void;
onDeleteWorld: () => void;
onCreateStory: () => void;
onSelectStory: (storyId: string) => void;
onRenameStory: (storyId: string, title: string) => void;
onDeleteStory: (storyId: string) => void;
onDuplicateStory: (storyId: string) => void;
}
const WorldItem = ({
world, activeWorldId, activeStoryId,
onSelectWorld, onRenameWorld, onDeleteWorld, onCreateStory,
onSelectStory, onRenameStory, onDeleteStory, onDuplicateStory,
}: WorldItemProps) => {
const isRenaming = useBool(false);
const isExpanded = useBool(activeWorldId === world.id);
const isWorldActive = activeWorldId === world.id && !activeStoryId;
const toggleExpand = (e: MouseEvent) => {
e.stopPropagation();
isExpanded.toggle();
};
return (
<div class={styles.worldGroup}>
{isRenaming.value ? (
<div class={clsx(styles.itemWrapper, isWorldActive && styles.active)}>
<RenameInput
value={world.title}
onSubmit={(t) => { onRenameWorld(t); isRenaming.setFalse(); }}
onCancel={isRenaming.setFalse}
/>
</div>
) : (
<div class={clsx(styles.itemWrapper, isWorldActive && styles.active)}>
<button class={styles.expandButton} onClick={toggleExpand} title={isExpanded.value ? 'Collapse' : 'Expand'}>
{isExpanded.value ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
<button
class={clsx(styles.item, styles.worldTitle, isWorldActive && styles.active)}
onClick={onSelectWorld}
onDblClick={isRenaming.setTrue}
>
<Globe size={13} />
{world.title}
</button>
<div class={styles.actions}>
<button class={styles.actionButton} onClick={onCreateStory} title="New Story">
<Plus size={14} />
</button>
<button class={styles.actionButton} onClick={isRenaming.setTrue} title="Rename">
<Pencil size={14} />
</button>
<button class={styles.actionButton} onClick={onDeleteWorld} title="Delete World">
<X size={14} />
</button>
</div>
</div>
)}
{isExpanded.value && (
<div class={styles.storiesList}>
{world.stories.map(story => (
<StoryItem
key={story.id}
story={story}
active={activeStoryId === story.id && activeWorldId === world.id}
onSelect={() => onSelectStory(story.id)}
onRename={(title) => onRenameStory(story.id, title)}
onDelete={() => onDeleteStory(story.id)}
onDuplicate={() => onDuplicateStory(story.id)}
/>
))}
{world.stories.length === 0 && (
<div class={styles.emptyStories}>No stories yet</div>
)}
</div>
)}
</div>
);
};
// ─── Menu Sidebar ───────────────────────────────────────────────────────────── // ─── Menu Sidebar ─────────────────────────────────────────────────────────────
export const Menu = () => { export const Menu = () => {
const { stories, currentStory, dispatch } = useAppState(); const { worlds, currentWorld, currentStory, dispatch } = useAppState();
const isConnectionSettingsOpen = useBool(false); const isConnectionSettingsOpen = useBool(false);
const isSettingsOpen = useBool(false); const isSettingsOpen = useBool(false);
const handleCreate = () => { const handleCreateWorld = () => {
dispatch({ type: 'CREATE_STORY', title: 'New Story' }); dispatch({ type: 'CREATE_WORLD', title: 'New World' });
}; };
const handleSelect = (id: string) => { const handleSelectWorld = (worldId: string) => {
dispatch({ type: 'SELECT_STORY', id }); dispatch({ type: 'SELECT_WORLD', worldId });
}; };
const handleRename = (id: string, newTitle: string) => { const handleRenameWorld = (worldId: string, title: string) => {
dispatch({ type: 'RENAME_STORY', id, title: newTitle }); dispatch({ type: 'RENAME_WORLD', worldId, title });
}; };
const handleDelete = (id: string) => { const handleDeleteWorld = (worldId: string) => {
const story = stories.find(s => s.id === id); const world = worlds.find(w => w.id === worldId);
if (!story) return; if (!world) return;
if (confirm(`Delete "${story.title}"?`)) { if (confirm(`Delete world "${world.title}" and all its stories?`)) {
dispatch({ type: 'DELETE_STORY', id }); dispatch({ type: 'DELETE_WORLD', worldId });
} }
}; };
const handleDuplicate = (id: string) => { const handleCreateStory = (worldId: string) => {
dispatch({ type: 'DUPLICATE_STORY', id }); 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 ( return (
<div class={styles.menu}> <div class={styles.menu}>
<button class={styles.newButton} onClick={handleCreate}> <button class={styles.newButton} onClick={handleCreateWorld}>
<Plus size={16} /> New Story <Plus size={16} /> New World
</button> </button>
<div class={styles.list}> <div class={styles.list}>
{stories.map(story => ( {worlds.map(world => (
<StoryItem <WorldItem
key={story.id} key={world.id}
story={story} world={world}
active={story.id === currentStory?.id} activeWorldId={currentWorld?.id ?? null}
onSelect={() => handleSelect(story.id)} activeStoryId={currentStory?.id ?? null}
onRename={(newTitle) => handleRename(story.id, newTitle)} onSelectWorld={() => handleSelectWorld(world.id)}
onDelete={() => handleDelete(story.id)} onRenameWorld={(title) => handleRenameWorld(world.id, title)}
onDuplicate={() => handleDuplicate(story.id)} 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)}
/> />
))} ))}
</div> </div>

View File

@ -1,9 +1,9 @@
import { createContext } from "preact"; import { createContext } from "preact";
import { useContext, useMemo } from "preact/hooks"; import { useContext, useMemo } from "preact/hooks";
import { useRemoteReducer } from "@common/hooks/useRemote";
import LLM from "../utils/llm"; import LLM from "../utils/llm";
import Chapters from "../utils/chapters"; import Chapters from "../utils/chapters";
import { useRemoteReducer } from "@common/hooks/useRemote";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -77,10 +77,20 @@ export interface Story {
lastEditedText?: string; lastEditedText?: string;
} }
export interface World {
id: string;
title: string;
lore: LoreEntry[];
characters: Character[];
locations: Location[];
stories: Story[];
}
// ─── State ─────────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────────
interface IState { interface IState {
stories: Story[]; worlds: World[];
currentWorldId: string | null;
currentStoryId: string | null; currentStoryId: string | null;
currentTab: Tab; currentTab: Tab;
chatOpen: boolean; chatOpen: boolean;
@ -94,42 +104,85 @@ interface IState {
// ─── Actions ───────────────────────────────────────────────────────────────── // ─── Actions ─────────────────────────────────────────────────────────────────
type Action = type Action =
| { type: 'CREATE_STORY'; title: string } // World actions
| { type: 'RENAME_STORY'; id: string; title: string } | { type: 'CREATE_WORLD'; title: string }
| { type: 'EDIT_STORY'; id: string; text: string; highlightText?: string } | { type: 'RENAME_WORLD'; worldId: string; title: string }
| { type: 'EDIT_SCRATCHPAD'; id: string; text: string } | { type: 'DELETE_WORLD'; worldId: string }
| { type: 'ADD_LORE_ENTRY'; storyId: string; entry: LoreEntry } | { type: 'SELECT_WORLD'; worldId: string }
| { type: 'EDIT_LORE_ENTRY'; storyId: string; entryId: string; updates: Partial<LoreEntry> } // Story actions
| { type: 'DELETE_LORE_ENTRY'; storyId: string; entryId: string } | { type: 'CREATE_STORY'; worldId: string; title: string }
| { type: 'REORDER_LORE_ENTRIES'; storyId: string; entryIds: 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<LoreEntry> }
| { 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_SYSTEM_INSTRUCTION'; systemInstruction: string }
| { type: 'SET_CURRENT_TAB'; tab: Tab } | { type: 'SET_CURRENT_TAB'; tab: Tab }
| { type: 'SET_CHAT_OPEN'; open: boolean } | { type: 'SET_CHAT_OPEN'; open: boolean }
| { type: 'DELETE_STORY'; id: string } // Chat
| { type: 'SELECT_STORY'; id: string } | { type: 'ADD_CHAT_MESSAGE'; worldId: string; storyId: string; message: ChatMessage }
| { type: 'DUPLICATE_STORY'; id: string } | { type: 'CLEAR_CHAT'; worldId: string; storyId: string }
| { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage } // Connection
| { type: 'CLEAR_CHAT'; storyId: string }
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null } | { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null } | { type: 'SET_MODEL'; model: LLM.ModelInfo | null }
| { type: 'SET_ENABLE_THINKING'; enable: boolean } | { type: 'SET_ENABLE_THINKING'; enable: boolean }
| { type: 'SET_BANNED_TOKENS'; tokens: string[] } | { type: 'SET_BANNED_TOKENS'; tokens: string[] }
| { type: 'ADD_CHARACTER'; storyId: string; character: Character } // Characters
| { type: 'EDIT_CHARACTER'; storyId: string; characterId: string; updates: Partial<Omit<Character, 'id' | 'name' | 'relations'>> } | { type: 'ADD_CHARACTER'; worldId: string; storyId: string | null; character: Character }
| { type: 'DELETE_CHARACTER'; storyId: string; characterId: string } | { type: 'EDIT_CHARACTER'; worldId: string; storyId: string | null; characterId: string; updates: Partial<Omit<Character, 'id' | 'name' | 'relations'>> }
| { type: 'ADD_CHARACTER_RELATION'; storyId: string; characterId: string; relation: Character['relations'][number] } | { type: 'DELETE_CHARACTER'; worldId: string; storyId: string | null; characterId: string }
| { type: 'EDIT_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string; updates: Partial<Character['relations'][number]> } | { type: 'ADD_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; relation: Character['relations'][number] }
| { type: 'DELETE_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string } | { type: 'EDIT_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; targetName: string; updates: Partial<Character['relations'][number]> }
| { type: 'ADD_LOCATION'; storyId: string; location: Location } | { type: 'DELETE_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; targetName: string }
| { type: 'EDIT_LOCATION'; storyId: string; locationId: string; updates: Partial<Location> } // Locations
| { type: 'DELETE_LOCATION'; storyId: string; locationId: string } | { type: 'ADD_LOCATION'; worldId: string; storyId: string | null; location: Location }
| { type: 'STORE_CHAPTER_SUMMARY'; storyId: string; header: string; hash: Chapters.Hash; summary: string } | { type: 'EDIT_LOCATION'; worldId: string; storyId: string | null; locationId: string; updates: Partial<Location> }
| { type: 'CLEAN_CHAPTER_SUMMARIES'; storyId: string; validHashes: Record<string, Chapters.Hash[]> }; | { 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<string, Chapters.Hash[]> };
// ─── 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 ─────────────────────────────────────────────────────────── // ─── Initial State ───────────────────────────────────────────────────────────
const DEFAULT_STATE: IState = { const DEFAULT_STATE: IState = {
stories: [], worlds: [],
currentWorldId: null,
currentStoryId: null, currentStoryId: null,
currentTab: 'menu', currentTab: 'menu',
chatOpen: false, chatOpen: false,
@ -139,8 +192,8 @@ const DEFAULT_STATE: IState = {
bannedTokens: [], 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. 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. Write using markdown to highlight special parts.
Supported markdown subset: Supported markdown subset:
- *italic* - *italic*
- **bold** - **bold**
- "quotes" - "quotes"
@ -153,7 +206,7 @@ Supported markdown subset:
- Ordered lists - Ordered lists
- Unordered lists (only with \`- \` markers) - Unordered lists (only with \`- \` markers)
- Only top-level lists (no nesting) - Only top-level lists (no nesting)
Show the chapters with \`# Chapter\` headers. 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. 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. 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 { function reducer(state: IState, action: Action): IState {
switch (action.type) { 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': { case 'CREATE_STORY': {
const story: Story = { const story: Story = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -178,108 +269,46 @@ function reducer(state: IState, action: Action): IState {
chapters: [], chapters: [],
}; };
return { return {
...state, ...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
stories: [...state.stories, story], currentWorldId: action.worldId,
currentStoryId: story.id, currentStoryId: story.id,
currentTab: 'story',
}; };
} }
case 'RENAME_STORY': { case 'RENAME_STORY': {
return { return updateStory(state, action.worldId, action.id, s => ({ ...s, title: action.title }));
...state,
stories: state.stories.map(s =>
s.id === action.id ? { ...s, title: action.title } : s
),
};
} }
case 'EDIT_STORY': { case 'EDIT_STORY': {
return { return updateStory(state, action.worldId, action.id, s => ({
...state, ...s,
stories: state.stories.map(s => text: action.text,
s.id === action.id lastEditedText: action.highlightText,
? { ...s, text: action.text, lastEditedText: action.highlightText } }));
: s
),
};
} }
case 'ADD_LORE_ENTRY': { case 'EDIT_SCRATCHPAD': {
return { return updateStory(state, action.worldId, action.id, s => ({ ...s, scratchpad: action.text }));
...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 'DELETE_STORY': { case 'DELETE_STORY': {
const remaining = state.stories.filter(s => s.id !== action.id);
const deletingCurrent = state.currentStoryId === action.id; const deletingCurrent = state.currentStoryId === action.id;
return { return {
...state, ...updateWorld(state, action.worldId, w => ({
stories: remaining, ...w,
stories: w.stories.filter(s => s.id !== action.id),
})),
currentStoryId: deletingCurrent ? null : state.currentStoryId, currentStoryId: deletingCurrent ? null : state.currentStoryId,
}; };
} }
case 'SELECT_STORY': {
return {
...state,
currentWorldId: action.worldId,
currentStoryId: action.id,
currentTab: 'story',
};
}
case 'DUPLICATE_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; if (!original) return state;
const newStory: Story = { const newStory: Story = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -287,254 +316,182 @@ function reducer(state: IState, action: Action): IState {
text: '', text: '',
scratchpad: '', scratchpad: '',
lore: [...original.lore], lore: [...original.lore],
characters: original.characters, characters: [...original.characters],
locations: original.locations, locations: [...original.locations],
chatMessages: [], chatMessages: [],
chapters: [], chapters: [],
}; };
return { return {
...state, ...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })),
stories: [...state.stories, newStory],
currentStoryId: newStory.id, currentStoryId: newStory.id,
currentTab: 'story',
}; };
} }
case 'SELECT_STORY': { case 'ADD_LORE_ENTRY': {
return { ...state, currentStoryId: action.id, currentTab: 'story' }; 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': { case 'ADD_CHAT_MESSAGE': {
return { return updateStory(state, action.worldId, action.storyId, s => {
...state, const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id);
stories: state.stories.map(s => { if (existingIndex !== -1) {
if (s.id !== action.storyId) return s; const updatedMessages = [...s.chatMessages];
const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id); updatedMessages[existingIndex] = action.message;
if (existingIndex !== -1) { return { ...s, chatMessages: updatedMessages };
// Overwrite existing message with same id }
const updatedMessages = [...s.chatMessages]; return { ...s, chatMessages: [...s.chatMessages, action.message] };
updatedMessages[existingIndex] = action.message; });
return { ...s, chatMessages: updatedMessages };
}
// Append new message
return { ...s, chatMessages: [...s.chatMessages, action.message] };
}),
};
} }
case 'CLEAR_CHAT': { case 'CLEAR_CHAT': {
return { return updateStory(state, action.worldId, action.storyId, s => ({ ...s, chatMessages: [] }));
...state,
stories: state.stories.map(s =>
s.id === action.storyId ? { ...s, chatMessages: [] } : s
),
};
} }
case 'SET_CONNECTION': { case 'SET_CONNECTION': {
return { return { ...state, connection: action.connection };
...state,
connection: action.connection,
};
} }
case 'SET_MODEL': { case 'SET_MODEL': {
return { return { ...state, model: action.model };
...state,
model: action.model,
};
} }
case 'SET_ENABLE_THINKING': { case 'SET_ENABLE_THINKING': {
return { return { ...state, enableThinking: action.enable };
...state,
enableThinking: action.enable,
};
} }
case 'SET_BANNED_TOKENS': { case 'SET_BANNED_TOKENS': {
return { return { ...state, bannedTokens: action.tokens };
...state,
bannedTokens: action.tokens,
};
} }
case 'ADD_CHARACTER': { case 'ADD_CHARACTER': {
return { return updateLoreContainer(state, action.worldId, action.storyId, c => ({
...state, characters: [...c.characters, action.character],
stories: state.stories.map(s => }));
s.id === action.storyId
? { ...s, characters: [...s.characters, action.character] }
: s
),
};
} }
case 'EDIT_CHARACTER': { case 'EDIT_CHARACTER': {
return { return updateLoreContainer(state, action.worldId, action.storyId, c => ({
...state, characters: c.characters.map(ch =>
stories: state.stories.map(s => ch.id === action.characterId ? { ...ch, ...action.updates } : ch
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, ...action.updates }
: c
),
}
: s
), ),
}; }));
} }
case 'DELETE_CHARACTER': { case 'DELETE_CHARACTER': {
return { return updateLoreContainer(state, action.worldId, action.storyId, c => {
...state, const deleted = c.characters.find(ch => ch.id === action.characterId);
stories: state.stories.map(s => { if (!deleted) return {};
if (s.id !== action.storyId) return s; return {
const deletedChar = s.characters.find(c => c.id === action.characterId); characters: c.characters
if (!deletedChar) return s; .filter(ch => ch.id !== action.characterId)
return { .map(ch => ({
...s, ...ch,
characters: s.characters relations: ch.relations.filter(r => r.name !== deleted.name),
.filter(c => c.id !== action.characterId) })),
.map(c => ({ };
...c, });
relations: c.relations.filter(r => r.name !== deletedChar.name),
})),
};
}),
};
} }
case 'ADD_CHARACTER_RELATION': { case 'ADD_CHARACTER_RELATION': {
return { return updateLoreContainer(state, action.worldId, action.storyId, c => ({
...state, characters: c.characters.map(ch =>
stories: state.stories.map(s => ch.id === action.characterId
s.id === action.storyId ? { ...ch, relations: [...ch.relations, action.relation] }
? { : ch
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, relations: [...c.relations, action.relation] }
: c
),
}
: s
), ),
}; }));
} }
case 'EDIT_CHARACTER_RELATION': { case 'EDIT_CHARACTER_RELATION': {
return { return updateLoreContainer(state, action.worldId, action.storyId, c => ({
...state, characters: c.characters.map(ch =>
stories: state.stories.map(s => ch.id === action.characterId
s.id === action.storyId
? { ? {
...s, ...ch,
characters: s.characters.map(c => relations: ch.relations.map(r =>
c.id === action.characterId r.name === action.targetName ? { ...r, ...action.updates } : r
? {
...c,
relations: c.relations.map(r =>
r.name === action.targetName
? { ...r, ...action.updates }
: r
),
}
: c
), ),
} }
: s : ch
), ),
}; }));
} }
case 'DELETE_CHARACTER_RELATION': { case 'DELETE_CHARACTER_RELATION': {
return { return updateLoreContainer(state, action.worldId, action.storyId, c => ({
...state, characters: c.characters.map(ch =>
stories: state.stories.map(s => ch.id === action.characterId
s.id === action.storyId ? { ...ch, relations: ch.relations.filter(r => r.name !== action.targetName) }
? { : ch
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, relations: c.relations.filter(r => r.name !== action.targetName) }
: c
),
}
: s
), ),
}; }));
} }
case 'ADD_LOCATION': { case 'ADD_LOCATION': {
return { return updateLoreContainer(state, action.worldId, action.storyId, c => ({
...state, locations: [...c.locations, action.location],
stories: state.stories.map(s => }));
s.id === action.storyId
? { ...s, locations: [...s.locations, action.location] }
: s
),
};
} }
case 'EDIT_LOCATION': { case 'EDIT_LOCATION': {
return { return updateLoreContainer(state, action.worldId, action.storyId, c => ({
...state, locations: c.locations.map(l =>
stories: state.stories.map(s => l.id === action.locationId ? { ...l, ...action.updates } : l
s.id === action.storyId
? {
...s,
locations: s.locations.map(l =>
l.id === action.locationId
? { ...l, ...action.updates }
: l
),
}
: s
), ),
}; }));
} }
case 'DELETE_LOCATION': { case 'DELETE_LOCATION': {
return { return updateLoreContainer(state, action.worldId, action.storyId, c => ({
...state, locations: c.locations.filter(l => l.id !== action.locationId),
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
),
};
} }
case 'STORE_CHAPTER_SUMMARY': { case 'STORE_CHAPTER_SUMMARY': {
return { return updateStory(state, action.worldId, action.storyId, s => {
...state, const chapters = s.chapters ?? [];
stories: state.stories.map(s => { const existing = chapters.find(c => c.header === action.header);
if (s.id !== action.storyId) return s; const updated = existing
const chapters = s.chapters ?? []; ? Chapters.storeSummary(existing, action.hash, action.summary)
const existing = chapters.find(c => c.header === action.header); : Chapters.storeSummary(Chapters.emptyChapter(action.header), action.hash, action.summary);
const updated = existing return {
? Chapters.storeSummary(existing, action.hash, action.summary) ...s,
: Chapters.storeSummary(Chapters.emptyChapter(action.header), action.hash, action.summary); chapters: existing
return { ? chapters.map(c => c.header === action.header ? updated : c)
...s, : [...chapters, updated],
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 ───────────────────────────────────────────────────────────────── // ─── Context ─────────────────────────────────────────────────────────────────
export interface AppState { export interface AppState {
stories: Story[]; worlds: World[];
currentWorld: World | null;
currentStory: Story | null; currentStory: Story | null;
/** Combined lore/characters/locations: world-level merged with story-level */
mergedLore: LoreEntry[];
mergedCharacters: Character[];
mergedLocations: Location[];
currentTab: Tab; currentTab: Tab;
chatOpen: boolean; chatOpen: boolean;
connection: LLM.Connection | null; connection: LLM.Connection | null;
@ -563,18 +525,41 @@ export const useAppState = () => useContext(StateContext);
export const StateContextProvider = ({ children }: { children?: any }) => { export const StateContextProvider = ({ children }: { children?: any }) => {
const [state, dispatch] = useRemoteReducer('storywriter.state', reducer, DEFAULT_STATE); const [state, dispatch] = useRemoteReducer('storywriter.state', reducer, DEFAULT_STATE);
const value = useMemo<AppState>(() => ({ const value = useMemo<AppState>(() => {
stories: state.stories, const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null, const currentStory = currentWorld?.stories.find(s => s.id === state.currentStoryId) ?? null;
currentTab: state.currentTab,
chatOpen: state.chatOpen, // Merge world-level + story-level, story takes priority (appended after)
connection: state.connection, const mergedLore = [
model: state.model, ...(currentWorld?.lore ?? []),
enableThinking: state.enableThinking, ...(currentStory?.lore ?? []),
bannedTokens: state.bannedTokens ?? [], ];
systemInstruction: state.systemInstruction ?? '', const mergedCharacters = [
dispatch, ...(currentWorld?.characters ?? []),
}), [state]); ...(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 ( return (
<StateContext.Provider value={value}> <StateContext.Provider value={value}>

View File

@ -176,8 +176,8 @@ namespace Prompt {
}; };
export function formatCharactersMarkdown(state: AppState): string { export function formatCharactersMarkdown(state: AppState): string {
const { currentStory } = state; const { mergedCharacters } = state;
if (!currentStory || !currentStory.characters?.length) { if (!mergedCharacters?.length) {
return ''; return '';
} }
@ -185,7 +185,7 @@ namespace Prompt {
lines.push('## Characters\n'); lines.push('## Characters\n');
// Sort characters by importance (protagonist first, cameo last) // Sort characters by importance (protagonist first, cameo last)
const sortedCharacters = [...currentStory.characters].sort((a, b) => { const sortedCharacters = [...mergedCharacters].sort((a, b) => {
const importanceOrder = [ const importanceOrder = [
CharacterRole.Protagonist, CharacterRole.Protagonist,
CharacterRole.Main, CharacterRole.Main,
@ -231,15 +231,15 @@ namespace Prompt {
} }
export function formatLoreMarkdown(state: AppState): string { export function formatLoreMarkdown(state: AppState): string {
const { currentStory } = state; const { mergedLore } = state;
if (!currentStory?.lore?.length) { if (!mergedLore?.length) {
return ''; return '';
} }
const lines: string[] = []; const lines: string[] = [];
lines.push('## Lore\n'); lines.push('## Lore\n');
for (const entry of currentStory.lore) { for (const entry of mergedLore) {
lines.push(`### ${entry.title}`); lines.push(`### ${entry.title}`);
if (entry.text) { if (entry.text) {
lines.push(entry.text); lines.push(entry.text);
@ -251,15 +251,15 @@ namespace Prompt {
} }
export function formatLocationsMarkdown(state: AppState): string { export function formatLocationsMarkdown(state: AppState): string {
const { currentStory } = state; const { mergedLocations } = state;
if (!currentStory || !currentStory.locations?.length) { if (!mergedLocations?.length) {
return ''; return '';
} }
const lines: string[] = []; const lines: string[] = [];
lines.push('## Locations\n'); lines.push('## Locations\n');
for (const location of currentStory.locations) { for (const location of mergedLocations) {
lines.push(`### ${location.name}`); lines.push(`### ${location.name}`);
const description = location.shortDescription || location.description; const description = location.shortDescription || location.description;

View File

@ -22,7 +22,7 @@ export namespace Tools {
if (!appState.currentStory) { if (!appState.currentStory) {
return 'Error: No story selected'; 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) { if (!character) {
return `Error: Character "${args.name.trim()}" not found`; return `Error: Character "${args.name.trim()}" not found`;
} }
@ -50,13 +50,14 @@ export namespace Tools {
}), }),
'set_character': tool({ 'set_character': tool({
handler: async (args, appState) => { handler: async (args, appState) => {
if (!appState.currentStory) { if (!appState.currentStory || !appState.currentWorld) {
return 'Error: No story selected'; 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) { 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<Character> = {}; const definedUpdates: Partial<Character> = {};
if (args.shortDescription !== undefined) { if (args.shortDescription !== undefined) {
definedUpdates.shortDescription = args.shortDescription; definedUpdates.shortDescription = args.shortDescription;
@ -75,25 +76,22 @@ export namespace Tools {
} }
appState.dispatch({ appState.dispatch({
type: 'EDIT_CHARACTER', type: 'EDIT_CHARACTER',
storyId: appState.currentStory.id, worldId: appState.currentWorld.id,
storyId: inStory ? appState.currentStory.id : null,
characterId: existingCharacter.id, characterId: existingCharacter.id,
updates: definedUpdates, updates: definedUpdates,
}); });
appState.dispatch({ appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'characters' });
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'characters'
});
return `Character "${args.name.trim()}" updated successfully`; return `Character "${args.name.trim()}" updated successfully`;
} else { } else {
// Add new character - validate required fields // Add new character — add to story level by default
if (!args.shortDescription) { if (!args.shortDescription) {
return 'Error: shortDescription is required when adding a new character'; return 'Error: shortDescription is required when adding a new character';
} }
if (args.role === undefined) { if (args.role === undefined) {
return 'Error: role is required when adding a new character'; 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[] = []; const invalidRelations: string[] = [];
for (const rel of args.relations || []) { for (const rel of args.relations || []) {
if (!existingCharacterNames.has(rel.name)) { if (!existingCharacterNames.has(rel.name)) {
@ -102,6 +100,7 @@ export namespace Tools {
} }
appState.dispatch({ appState.dispatch({
type: 'ADD_CHARACTER', type: 'ADD_CHARACTER',
worldId: appState.currentWorld.id,
storyId: appState.currentStory.id, storyId: appState.currentStory.id,
character: { character: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -113,11 +112,7 @@ export namespace Tools {
relations: args.relations || [], relations: args.relations || [],
}, },
}); });
appState.dispatch({ appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'characters' });
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'characters'
});
let message = `Character "${args.name.trim()}" added successfully`; let message = `Character "${args.name.trim()}" added successfully`;
if (invalidRelations.length > 0) { if (invalidRelations.length > 0) {
message += `.\nNote: found invalid relations to non-existent characters: ${invalidRelations.join(', ')}`; message += `.\nNote: found invalid relations to non-existent characters: ${invalidRelations.join(', ')}`;
@ -143,49 +138,43 @@ export namespace Tools {
}), }),
'set_character_relation': tool({ 'set_character_relation': tool({
handler: async (args, appState) => { handler: async (args, appState) => {
if (!appState.currentStory) { if (!appState.currentStory || !appState.currentWorld) {
return 'Error: No story selected'; 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) { if (!character) {
return `Error: Character "${args.character_name.trim()}" not found`; 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) { if (!targetCharacter) {
return `Error: Target character "${args.target_name.trim()}" not found, please add it first.`; 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()); const existingRelationIndex = character.relations.findIndex(r => r.name === args.target_name.trim());
if (existingRelationIndex !== -1) { if (existingRelationIndex !== -1) {
// Edit existing relation
appState.dispatch({ appState.dispatch({
type: 'EDIT_CHARACTER_RELATION', type: 'EDIT_CHARACTER_RELATION',
storyId: appState.currentStory.id, worldId: appState.currentWorld.id,
storyId,
characterId: character.id, characterId: character.id,
targetName: args.target_name.trim(), targetName: args.target_name.trim(),
updates: { relation: args.relation.trim() }, updates: { relation: args.relation.trim() },
}); });
appState.dispatch({ appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'characters' });
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'characters'
});
return `Relation updated: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`; return `Relation updated: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`;
} else { } else {
// Add new relation
appState.dispatch({ appState.dispatch({
type: 'ADD_CHARACTER_RELATION', type: 'ADD_CHARACTER_RELATION',
storyId: appState.currentStory.id, worldId: appState.currentWorld.id,
storyId,
characterId: character.id, characterId: character.id,
relation: { relation: {
name: args.target_name.trim(), name: args.target_name.trim(),
relation: args.relation.trim(), relation: args.relation.trim(),
}, },
}); });
appState.dispatch({ appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'characters' });
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'characters'
});
return `Relation added: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`; return `Relation added: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`;
} }
}, },
@ -201,7 +190,7 @@ export namespace Tools {
if (!appState.currentStory) { if (!appState.currentStory) {
return 'Error: No story selected'; 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) { if (!location) {
return `Error: Location "${args.name.trim()}" not found`; return `Error: Location "${args.name.trim()}" not found`;
} }
@ -220,12 +209,12 @@ export namespace Tools {
}), }),
'set_location': tool({ 'set_location': tool({
handler: async (args, appState) => { handler: async (args, appState) => {
if (!appState.currentStory) { if (!appState.currentStory || !appState.currentWorld) {
return 'Error: No story selected'; 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) { if (existingLocation) {
// Edit existing location const inStory = appState.currentStory.locations.some(l => l.id === existingLocation.id);
const definedUpdates: Partial<Location> = {}; const definedUpdates: Partial<Location> = {};
if (args.shortDescription) { if (args.shortDescription) {
definedUpdates.shortDescription = args.shortDescription; definedUpdates.shortDescription = args.shortDescription;
@ -238,18 +227,14 @@ export namespace Tools {
} }
appState.dispatch({ appState.dispatch({
type: 'EDIT_LOCATION', type: 'EDIT_LOCATION',
storyId: appState.currentStory.id, worldId: appState.currentWorld.id,
storyId: inStory ? appState.currentStory.id : null,
locationId: existingLocation.id, locationId: existingLocation.id,
updates: definedUpdates, updates: definedUpdates,
}); });
appState.dispatch({ appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'locations' });
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'locations'
});
return `Location "${args.name.trim()}" updated successfully`; return `Location "${args.name.trim()}" updated successfully`;
} else { } else {
// Add new location - validate required fields
if (!args.shortDescription) { if (!args.shortDescription) {
return 'Error: shortDescription is required when adding a new location'; return 'Error: shortDescription is required when adding a new location';
} }
@ -258,6 +243,7 @@ export namespace Tools {
} }
appState.dispatch({ appState.dispatch({
type: 'ADD_LOCATION', type: 'ADD_LOCATION',
worldId: appState.currentWorld.id,
storyId: appState.currentStory.id, storyId: appState.currentStory.id,
location: { location: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -267,11 +253,7 @@ export namespace Tools {
scale: args.scale, scale: args.scale,
}, },
}); });
appState.dispatch({ appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'locations' });
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'locations'
});
return `Location "${args.name.trim()}" added successfully`; return `Location "${args.name.trim()}" added successfully`;
} }
}, },
@ -287,27 +269,26 @@ export namespace Tools {
}), }),
'set_lore_entry': tool({ 'set_lore_entry': tool({
handler: async (args, appState) => { handler: async (args, appState) => {
if (!appState.currentStory) { if (!appState.currentStory || !appState.currentWorld) {
return 'Error: No story selected'; 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) { if (existingEntry) {
const inStory = appState.currentStory.lore.some(e => e.id === existingEntry.id);
appState.dispatch({ appState.dispatch({
type: 'EDIT_LORE_ENTRY', type: 'EDIT_LORE_ENTRY',
storyId: appState.currentStory.id, worldId: appState.currentWorld.id,
storyId: inStory ? appState.currentStory.id : null,
entryId: existingEntry.id, entryId: existingEntry.id,
updates: { text: args.text }, updates: { text: args.text },
}); });
appState.dispatch({ appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'lore' });
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'lore'
});
return `Lore entry "${existingEntry.title}" updated successfully`; return `Lore entry "${existingEntry.title}" updated successfully`;
} else { } else {
appState.dispatch({ appState.dispatch({
type: 'ADD_LORE_ENTRY', type: 'ADD_LORE_ENTRY',
worldId: appState.currentWorld.id,
storyId: appState.currentStory.id, storyId: appState.currentStory.id,
entry: { entry: {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -315,11 +296,7 @@ export namespace Tools {
text: args.text, text: args.text,
}, },
}); });
appState.dispatch({ appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'lore' });
type: 'SET_CURRENT_TAB',
id: appState.currentStory.id,
tab: 'lore'
});
return `Lore entry "${args.title.trim()}" added successfully`; return `Lore entry "${args.title.trim()}" added successfully`;
} }
}, },
@ -331,7 +308,7 @@ export namespace Tools {
}), }),
'edit_text': tool({ 'edit_text': tool({
handler: async (args, appState) => { handler: async (args, appState) => {
if (!appState.currentStory) { if (!appState.currentStory || !appState.currentWorld) {
return 'Error: No story selected'; return 'Error: No story selected';
} }
const target = args.target ?? 'story'; const target = args.target ?? 'story';
@ -341,8 +318,8 @@ export namespace Tools {
const dispatchEdit = (text: string) => appState.dispatch( const dispatchEdit = (text: string) => appState.dispatch(
isScratchpad isScratchpad
? { type: 'EDIT_SCRATCHPAD', id: appState.currentStory!.id, text } ? { type: 'EDIT_SCRATCHPAD', worldId: appState.currentWorld!.id, id: appState.currentStory!.id, text }
: { type: 'EDIT_STORY', id: appState.currentStory!.id, text, highlightText: args.new_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 // Append mode: when old_text is not provided, append new_text
@ -397,7 +374,7 @@ export namespace Tools {
} }
dispatchEdit(currentText + '\n' + args.new_text); 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 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!` ? `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.`; : `Text appended to ${target} successfully.`;
@ -417,7 +394,7 @@ export namespace Tools {
} }
dispatchEdit(currentText.replaceAll(args.old_text, args.new_text)); 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`; 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.`, 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 }[] = [ const sources: { name: string; content: string }[] = [
{ name: 'story', content: appState.currentStory.text }, { 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.title },
{ name: `lore:${e.title}`, content: e.text }, { 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.name },
{ name: `character:${c.name}`, content: c.shortDescription }, { name: `character:${c.name}`, content: c.shortDescription },
{ name: `character:${c.name}`, content: c.description || '' }, { name: `character:${c.name}`, content: c.description || '' },
@ -448,7 +425,7 @@ export namespace Tools {
{ name: `character:${c.name}`, content: `relation: ${rel.name} (${rel.relation})` } { 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.name },
{ name: `location:${l.name}`, content: l.shortDescription }, { name: `location:${l.name}`, content: l.shortDescription },
{ name: `location:${l.name}`, content: l.description || '' }, { name: `location:${l.name}`, content: l.description || '' },
@ -486,7 +463,7 @@ export namespace Tools {
} }
return result; 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({ parameters: Type.Object({
pattern: Type.String({ description: 'The JS regex pattern to search for' }), 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)' })), case_sensitive: Type.Optional(Type.Boolean({ description: 'If true, search is case-sensitive (default: false)' })),

View File

@ -11,8 +11,8 @@ export function useChapterSummarization() {
const [isSummarizing, setIsSummarizing] = useState(false); const [isSummarizing, setIsSummarizing] = useState(false);
const summarizeAll = async () => { const summarizeAll = async () => {
const { currentStory, connection, model, dispatch } = stateRef.current; const { currentWorld, currentStory, connection, model, dispatch } = stateRef.current;
if (!currentStory || !connection || !model || isSummarizing) return; if (!currentWorld || !currentStory || !connection || !model || isSummarizing) return;
setIsSummarizing(true); setIsSummarizing(true);
try { try {
@ -34,6 +34,7 @@ export function useChapterSummarization() {
const newSummary = await LLM.summarize(connection, model.id, body); const newSummary = await LLM.summarize(connection, model.id, body);
dispatch({ dispatch({
type: 'STORE_CHAPTER_SUMMARY', type: 'STORE_CHAPTER_SUMMARY',
worldId: currentWorld.id,
storyId: currentStory.id, storyId: currentStory.id,
header: parsedChapter.header, header: parsedChapter.header,
hash, hash,
@ -48,6 +49,7 @@ export function useChapterSummarization() {
// Clean up stale cache entries // Clean up stale cache entries
dispatch({ dispatch({
type: 'CLEAN_CHAPTER_SUMMARIES', type: 'CLEAN_CHAPTER_SUMMARIES',
worldId: currentWorld.id,
storyId: currentStory.id, storyId: currentStory.id,
validHashes, validHashes,
}); });