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,
@ -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,
}); });