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;
color: var(--text);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.titleWorld {
color: var(--text-muted);
font-weight: normal;
}
.titleSep {
color: var(--text-muted);
font-weight: normal;
font-size: 24px;
}

View File

@ -5,6 +5,57 @@
height: 100%;
}
.worldGroup {
display: flex;
flex-direction: column;
}
.storiesList {
display: flex;
flex-direction: column;
gap: 1px;
padding-left: 20px;
border-left: 1px solid var(--border);
margin-left: 10px;
margin-bottom: 4px;
}
.storyItem {
padding-left: 4px;
}
.worldTitle {
display: flex;
align-items: center;
gap: 5px;
font-weight: 500;
}
.expandButton {
flex-shrink: 0;
padding: 4px 3px;
background: transparent;
border: none;
outline: none;
cursor: pointer;
color: var(--text-muted);
border-radius: 2px;
display: flex;
align-items: center;
&:hover {
background: var(--bg-hover);
color: var(--text);
}
}
.emptyStories {
padding: 4px 8px;
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
.newButton {
width: 100%;
padding: 6px 8px;

View File

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

View File

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

View File

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

View File

@ -11,9 +11,10 @@ import { LoreEditor } from "./lore-editor";
import { Menu } from "./menu";
import { useInputCallback } from "@common/hooks/useInputCallback";
import Prompt from "../utils/prompt";
import { BookOpen, List, Users, MapPin, BookMarked, FileText, Code, Layers, MessageSquare, type LucideIcon } from "lucide-preact";
import { BookOpen, List, Users, MapPin, BookMarked, FileText, Code, Layers, MessageSquare, Globe, type LucideIcon } from "lucide-preact";
const TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
// Tabs available when a story is selected
const STORY_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
{ id: "menu", label: "Menu", icon: List },
{ id: "story", label: "Story", icon: BookOpen },
{ id: "chapters", label: "Chapters", icon: Layers },
@ -24,19 +25,27 @@ const TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
{ id: "prompt", label: "Prompt", icon: Code },
];
// Tabs available when only a world is selected (no story)
const WORLD_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
{ id: "menu", label: "Menu", icon: List },
{ id: "lore", label: "Lore", icon: BookMarked },
{ id: "characters", label: "Characters", icon: Users },
{ id: "locations", label: "Locations", icon: MapPin },
];
export const Editor = () => {
const appState = useAppState();
const { currentStory, currentTab, chatOpen, dispatch } = appState;
const { currentWorld, currentStory, currentTab, chatOpen, dispatch } = appState;
const handleInput = useInputCallback((text: string) => {
if (!currentStory) return;
dispatch({ type: 'EDIT_STORY', id: currentStory.id, text });
}, [currentStory?.id]);
if (!currentStory || !currentWorld) return;
dispatch({ type: 'EDIT_STORY', worldId: currentWorld.id, id: currentStory.id, text });
}, [currentStory?.id, currentWorld?.id]);
const handleScratchpadInput = useInputCallback((text: string) => {
if (!currentStory) return;
dispatch({ type: 'EDIT_SCRATCHPAD', id: currentStory.id, text });
}, [currentStory?.id]);
if (!currentStory || !currentWorld) return;
dispatch({ type: 'EDIT_SCRATCHPAD', worldId: currentWorld.id, id: currentStory.id, text });
}, [currentStory?.id, currentWorld?.id]);
const handleTabChange = (tab: Tab) => {
dispatch({ type: 'SET_CURRENT_TAB', tab });
@ -89,11 +98,23 @@ export const Editor = () => {
}
});
return () => cancelAnimationFrame(raf);
}, [currentStory?.id, currentTab]);
}, [currentStory?.id, currentWorld?.id, currentTab]);
const hasSelection = currentWorld !== null;
const tabs = currentStory ? STORY_TABS : currentWorld ? WORLD_TABS : [{ id: "menu" as Tab, label: "Menu", icon: List }];
// Title bar: show world > story or just world
const titleBar = currentStory
? <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 (
<div class={styles.editor}>
{currentStory && <div class={styles.title}>{currentStory.title}</div>}
{titleBar}
<div class={clsx(styles.content, currentTab === 'menu' && styles.menuContent)} ref={contentRef}>
{currentTab === "menu" && (
<Menu />
@ -106,13 +127,13 @@ export const Editor = () => {
placeholder="Start writing your story..."
/>
)}
{currentTab === "lore" && currentStory && (
{currentTab === "lore" && (currentStory || currentWorld) && (
<LoreEditor />
)}
{currentTab === "characters" && currentStory && (
{currentTab === "characters" && (currentStory || currentWorld) && (
<CharacterEditor />
)}
{currentTab === "locations" && currentStory && (
{currentTab === "locations" && (currentStory || currentWorld) && (
<LocationEditor />
)}
{currentTab === "chapters" && currentStory && (
@ -131,7 +152,7 @@ export const Editor = () => {
)}
</div>
<div class={styles.tabs}>
{TABS.filter(tab => currentStory || tab.id === 'menu').map((tab) => (
{tabs.filter(tab => hasSelection || tab.id === 'menu').map((tab) => (
<button
key={tab.id}
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>
</button>
))}
<button
class={clsx(styles.tab, styles.tabRight, chatOpen && styles.active)}
onClick={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })}
title="Chat"
>
<MessageSquare size={15} />
<span class={styles.tabLabel}>Chat</span>
</button>
{currentStory && (
<button
class={clsx(styles.tab, styles.tabRight, chatOpen && styles.active)}
onClick={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })}
title="Chat"
>
<MessageSquare size={15} />
<span class={styles.tabLabel}>Chat</span>
</button>
)}
</div>
</div>
);

View File

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

View File

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

View File

@ -4,9 +4,43 @@ import { SettingsModal } from "./settings-modal";
import { useAppState } from "../contexts/state";
import { useBool } from "@common/hooks/useBool";
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 { 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 ───────────────────────────────────────────────────────────────
@ -21,45 +55,21 @@ interface StoryItemProps {
const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }: StoryItemProps) => {
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) {
return (
<div class={clsx(styles.itemWrapper, active && styles.active)}>
<input
class={styles.input}
value={editTitle}
onInput={setEditTitle}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
autoFocus
<div class={clsx(styles.itemWrapper, styles.storyItem, active && styles.active)}>
<RenameInput
value={story.title}
onSubmit={(t) => { onRename(t); isEditing.setFalse(); }}
onCancel={isEditing.setFalse}
/>
</div>
);
}
return (
<div class={clsx(styles.itemWrapper, active && styles.active)}>
<div class={clsx(styles.itemWrapper, styles.storyItem, active && styles.active)}>
<button
class={clsx(styles.item, active && styles.active)}
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 ─────────────────────────────────────────────────────────────
export const Menu = () => {
const { stories, currentStory, dispatch } = useAppState();
const { worlds, currentWorld, currentStory, dispatch } = useAppState();
const isConnectionSettingsOpen = useBool(false);
const isSettingsOpen = useBool(false);
const handleCreate = () => {
dispatch({ type: 'CREATE_STORY', title: 'New Story' });
const handleCreateWorld = () => {
dispatch({ type: 'CREATE_WORLD', title: 'New World' });
};
const handleSelect = (id: string) => {
dispatch({ type: 'SELECT_STORY', id });
const handleSelectWorld = (worldId: string) => {
dispatch({ type: 'SELECT_WORLD', worldId });
};
const handleRename = (id: string, newTitle: string) => {
dispatch({ type: 'RENAME_STORY', id, title: newTitle });
const handleRenameWorld = (worldId: string, title: string) => {
dispatch({ type: 'RENAME_WORLD', worldId, title });
};
const handleDelete = (id: string) => {
const story = stories.find(s => s.id === id);
if (!story) return;
if (confirm(`Delete "${story.title}"?`)) {
dispatch({ type: 'DELETE_STORY', id });
const handleDeleteWorld = (worldId: string) => {
const world = worlds.find(w => w.id === worldId);
if (!world) return;
if (confirm(`Delete world "${world.title}" and all its stories?`)) {
dispatch({ type: 'DELETE_WORLD', worldId });
}
};
const handleDuplicate = (id: string) => {
dispatch({ type: 'DUPLICATE_STORY', id });
const handleCreateStory = (worldId: string) => {
dispatch({ type: 'CREATE_STORY', worldId, title: 'New Story' });
};
const handleSelectStory = (worldId: string, storyId: string) => {
dispatch({ type: 'SELECT_STORY', worldId, id: storyId });
};
const handleRenameStory = (worldId: string, storyId: string, title: string) => {
dispatch({ type: 'RENAME_STORY', worldId, id: storyId, title });
};
const handleDeleteStory = (worldId: string, storyId: string) => {
const world = worlds.find(w => w.id === worldId);
const story = world?.stories.find(s => s.id === storyId);
if (!story) return;
if (confirm(`Delete "${story.title}"?`)) {
dispatch({ type: 'DELETE_STORY', worldId, id: storyId });
}
};
const handleDuplicateStory = (worldId: string, storyId: string) => {
dispatch({ type: 'DUPLICATE_STORY', worldId, id: storyId });
};
return (
<div class={styles.menu}>
<button class={styles.newButton} onClick={handleCreate}>
<Plus size={16} /> New Story
<button class={styles.newButton} onClick={handleCreateWorld}>
<Plus size={16} /> New World
</button>
<div class={styles.list}>
{stories.map(story => (
<StoryItem
key={story.id}
story={story}
active={story.id === currentStory?.id}
onSelect={() => handleSelect(story.id)}
onRename={(newTitle) => handleRename(story.id, newTitle)}
onDelete={() => handleDelete(story.id)}
onDuplicate={() => handleDuplicate(story.id)}
{worlds.map(world => (
<WorldItem
key={world.id}
world={world}
activeWorldId={currentWorld?.id ?? null}
activeStoryId={currentStory?.id ?? null}
onSelectWorld={() => handleSelectWorld(world.id)}
onRenameWorld={(title) => handleRenameWorld(world.id, title)}
onDeleteWorld={() => handleDeleteWorld(world.id)}
onCreateStory={() => handleCreateStory(world.id)}
onSelectStory={(storyId) => handleSelectStory(world.id, storyId)}
onRenameStory={(storyId, title) => handleRenameStory(world.id, storyId, title)}
onDeleteStory={(storyId) => handleDeleteStory(world.id, storyId)}
onDuplicateStory={(storyId) => handleDuplicateStory(world.id, storyId)}
/>
))}
</div>

View File

@ -1,9 +1,9 @@
import { createContext } from "preact";
import { useContext, useMemo } from "preact/hooks";
import { useRemoteReducer } from "@common/hooks/useRemote";
import LLM from "../utils/llm";
import Chapters from "../utils/chapters";
import { useRemoteReducer } from "@common/hooks/useRemote";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -77,10 +77,20 @@ export interface Story {
lastEditedText?: string;
}
export interface World {
id: string;
title: string;
lore: LoreEntry[];
characters: Character[];
locations: Location[];
stories: Story[];
}
// ─── State ───────────────────────────────────────────────────────────────────
interface IState {
stories: Story[];
worlds: World[];
currentWorldId: string | null;
currentStoryId: string | null;
currentTab: Tab;
chatOpen: boolean;
@ -94,42 +104,85 @@ interface IState {
// ─── Actions ─────────────────────────────────────────────────────────────────
type Action =
| { type: 'CREATE_STORY'; title: string }
| { type: 'RENAME_STORY'; id: string; title: string }
| { type: 'EDIT_STORY'; id: string; text: string; highlightText?: string }
| { type: 'EDIT_SCRATCHPAD'; id: string; text: string }
| { type: 'ADD_LORE_ENTRY'; storyId: string; entry: LoreEntry }
| { type: 'EDIT_LORE_ENTRY'; storyId: string; entryId: string; updates: Partial<LoreEntry> }
| { type: 'DELETE_LORE_ENTRY'; storyId: string; entryId: string }
| { type: 'REORDER_LORE_ENTRIES'; storyId: string; entryIds: string[] }
// World actions
| { type: 'CREATE_WORLD'; title: string }
| { type: 'RENAME_WORLD'; worldId: string; title: string }
| { type: 'DELETE_WORLD'; worldId: string }
| { type: 'SELECT_WORLD'; worldId: string }
// Story actions
| { type: 'CREATE_STORY'; worldId: string; title: string }
| { type: 'RENAME_STORY'; worldId: string; id: string; title: string }
| { type: 'EDIT_STORY'; worldId: string; id: string; text: string; highlightText?: string }
| { type: 'EDIT_SCRATCHPAD'; worldId: string; id: string; text: string }
| { type: 'DELETE_STORY'; worldId: string; id: string }
| { type: 'SELECT_STORY'; worldId: string; id: string }
| { type: 'DUPLICATE_STORY'; worldId: string; id: string }
// Story lore
| { type: 'ADD_LORE_ENTRY'; worldId: string; storyId: string | null; entry: LoreEntry }
| { type: 'EDIT_LORE_ENTRY'; worldId: string; storyId: string | null; entryId: string; updates: Partial<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_CURRENT_TAB'; tab: Tab }
| { type: 'SET_CHAT_OPEN'; open: boolean }
| { type: 'DELETE_STORY'; id: string }
| { type: 'SELECT_STORY'; id: string }
| { type: 'DUPLICATE_STORY'; id: string }
| { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage }
| { type: 'CLEAR_CHAT'; storyId: string }
// Chat
| { type: 'ADD_CHAT_MESSAGE'; worldId: string; storyId: string; message: ChatMessage }
| { type: 'CLEAR_CHAT'; worldId: string; storyId: string }
// Connection
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null }
| { type: 'SET_ENABLE_THINKING'; enable: boolean }
| { type: 'SET_BANNED_TOKENS'; tokens: string[] }
| { type: 'ADD_CHARACTER'; storyId: string; character: Character }
| { type: 'EDIT_CHARACTER'; storyId: string; characterId: string; updates: Partial<Omit<Character, 'id' | 'name' | 'relations'>> }
| { type: 'DELETE_CHARACTER'; storyId: string; characterId: string }
| { type: 'ADD_CHARACTER_RELATION'; storyId: string; characterId: string; relation: Character['relations'][number] }
| { type: 'EDIT_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string; updates: Partial<Character['relations'][number]> }
| { type: 'DELETE_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string }
| { type: 'ADD_LOCATION'; storyId: string; location: Location }
| { type: 'EDIT_LOCATION'; storyId: string; locationId: string; updates: Partial<Location> }
| { type: 'DELETE_LOCATION'; storyId: string; locationId: string }
| { type: 'STORE_CHAPTER_SUMMARY'; storyId: string; header: string; hash: Chapters.Hash; summary: string }
| { type: 'CLEAN_CHAPTER_SUMMARIES'; storyId: string; validHashes: Record<string, Chapters.Hash[]> };
// Characters
| { type: 'ADD_CHARACTER'; worldId: string; storyId: string | null; character: Character }
| { type: 'EDIT_CHARACTER'; worldId: string; storyId: string | null; characterId: string; updates: Partial<Omit<Character, 'id' | 'name' | 'relations'>> }
| { type: 'DELETE_CHARACTER'; worldId: string; storyId: string | null; characterId: string }
| { type: 'ADD_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; relation: Character['relations'][number] }
| { type: 'EDIT_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; targetName: string; updates: Partial<Character['relations'][number]> }
| { type: 'DELETE_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; targetName: string }
// Locations
| { type: 'ADD_LOCATION'; worldId: string; storyId: string | null; location: Location }
| { type: 'EDIT_LOCATION'; worldId: string; storyId: string | null; locationId: string; updates: Partial<Location> }
| { 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 ───────────────────────────────────────────────────────────
const DEFAULT_STATE: IState = {
stories: [],
worlds: [],
currentWorldId: null,
currentStoryId: null,
currentTab: 'menu',
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 {
switch (action.type) {
case 'CREATE_WORLD': {
const world: World = {
id: crypto.randomUUID(),
title: action.title,
lore: [],
characters: [],
locations: [],
stories: [],
};
return {
...state,
worlds: [...state.worlds, world],
currentWorldId: world.id,
currentStoryId: null,
currentTab: 'lore',
};
}
case 'RENAME_WORLD': {
return updateWorld(state, action.worldId, w => ({ ...w, title: action.title }));
}
case 'DELETE_WORLD': {
const remaining = state.worlds.filter(w => w.id !== action.worldId);
const deletingCurrent = state.currentWorldId === action.worldId;
return {
...state,
worlds: remaining,
currentWorldId: deletingCurrent ? null : state.currentWorldId,
currentStoryId: deletingCurrent ? null : state.currentStoryId,
};
}
case 'SELECT_WORLD': {
return {
...state,
currentWorldId: action.worldId,
currentStoryId: null,
currentTab: 'lore',
};
}
case 'CREATE_STORY': {
const story: Story = {
id: crypto.randomUUID(),
@ -178,108 +269,46 @@ function reducer(state: IState, action: Action): IState {
chapters: [],
};
return {
...state,
stories: [...state.stories, story],
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
currentWorldId: action.worldId,
currentStoryId: story.id,
currentTab: 'story',
};
}
case 'RENAME_STORY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.id ? { ...s, title: action.title } : s
),
};
return updateStory(state, action.worldId, action.id, s => ({ ...s, title: action.title }));
}
case 'EDIT_STORY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.id
? { ...s, text: action.text, lastEditedText: action.highlightText }
: s
),
};
return updateStory(state, action.worldId, action.id, s => ({
...s,
text: action.text,
lastEditedText: action.highlightText,
}));
}
case 'ADD_LORE_ENTRY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, lore: [...s.lore, action.entry] }
: s
),
};
}
case 'EDIT_LORE_ENTRY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
lore: s.lore.map(e =>
e.id === action.entryId
? { ...e, ...action.updates }
: e
),
}
: s
),
};
}
case 'DELETE_LORE_ENTRY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, lore: s.lore.filter(e => e.id !== action.entryId) }
: s
),
};
}
case 'REORDER_LORE_ENTRIES': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const entryMap = new Map(s.lore.map(e => [e.id, e]));
const reordered = action.entryIds
.map(id => entryMap.get(id))
.filter((e): e is LoreEntry => e !== undefined);
// Add any entries that weren't in the new order (safety)
for (const entry of s.lore) {
if (!action.entryIds.includes(entry.id)) {
reordered.push(entry);
}
}
return { ...s, lore: reordered };
}),
};
}
case 'SET_SYSTEM_INSTRUCTION': {
return {
...state,
systemInstruction: action.systemInstruction,
};
}
case 'SET_CURRENT_TAB': {
return { ...state, currentTab: action.tab };
}
case 'SET_CHAT_OPEN': {
return { ...state, chatOpen: action.open };
case 'EDIT_SCRATCHPAD': {
return updateStory(state, action.worldId, action.id, s => ({ ...s, scratchpad: action.text }));
}
case 'DELETE_STORY': {
const remaining = state.stories.filter(s => s.id !== action.id);
const deletingCurrent = state.currentStoryId === action.id;
return {
...state,
stories: remaining,
...updateWorld(state, action.worldId, w => ({
...w,
stories: w.stories.filter(s => s.id !== action.id),
})),
currentStoryId: deletingCurrent ? null : state.currentStoryId,
};
}
case 'SELECT_STORY': {
return {
...state,
currentWorldId: action.worldId,
currentStoryId: action.id,
currentTab: 'story',
};
}
case 'DUPLICATE_STORY': {
const original = state.stories.find(s => s.id === action.id);
const world = state.worlds.find(w => w.id === action.worldId);
const original = world?.stories.find(s => s.id === action.id);
if (!original) return state;
const newStory: Story = {
id: crypto.randomUUID(),
@ -287,254 +316,182 @@ function reducer(state: IState, action: Action): IState {
text: '',
scratchpad: '',
lore: [...original.lore],
characters: original.characters,
locations: original.locations,
characters: [...original.characters],
locations: [...original.locations],
chatMessages: [],
chapters: [],
};
return {
...state,
stories: [...state.stories, newStory],
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })),
currentStoryId: newStory.id,
currentTab: 'story',
};
}
case 'SELECT_STORY': {
return { ...state, currentStoryId: action.id, currentTab: 'story' };
case 'ADD_LORE_ENTRY': {
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
lore: [...c.lore, action.entry],
}));
}
case 'EDIT_LORE_ENTRY': {
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
lore: c.lore.map(e => e.id === action.entryId ? { ...e, ...action.updates } : e),
}));
}
case 'DELETE_LORE_ENTRY': {
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
lore: c.lore.filter(e => e.id !== action.entryId),
}));
}
case 'REORDER_LORE_ENTRIES': {
return updateLoreContainer(state, action.worldId, action.storyId, c => {
const entryMap = new Map(c.lore.map(e => [e.id, e]));
const reordered = action.entryIds
.map(id => entryMap.get(id))
.filter((e): e is LoreEntry => e !== undefined);
for (const entry of c.lore) {
if (!action.entryIds.includes(entry.id)) reordered.push(entry);
}
return { lore: reordered };
});
}
case 'SET_SYSTEM_INSTRUCTION': {
return { ...state, systemInstruction: action.systemInstruction };
}
case 'SET_CURRENT_TAB': {
return { ...state, currentTab: action.tab };
}
case 'SET_CHAT_OPEN': {
return { ...state, chatOpen: action.open };
}
case 'ADD_CHAT_MESSAGE': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id);
if (existingIndex !== -1) {
// Overwrite existing message with same id
const updatedMessages = [...s.chatMessages];
updatedMessages[existingIndex] = action.message;
return { ...s, chatMessages: updatedMessages };
}
// Append new message
return { ...s, chatMessages: [...s.chatMessages, action.message] };
}),
};
return updateStory(state, action.worldId, action.storyId, s => {
const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id);
if (existingIndex !== -1) {
const updatedMessages = [...s.chatMessages];
updatedMessages[existingIndex] = action.message;
return { ...s, chatMessages: updatedMessages };
}
return { ...s, chatMessages: [...s.chatMessages, action.message] };
});
}
case 'CLEAR_CHAT': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId ? { ...s, chatMessages: [] } : s
),
};
return updateStory(state, action.worldId, action.storyId, s => ({ ...s, chatMessages: [] }));
}
case 'SET_CONNECTION': {
return {
...state,
connection: action.connection,
};
return { ...state, connection: action.connection };
}
case 'SET_MODEL': {
return {
...state,
model: action.model,
};
return { ...state, model: action.model };
}
case 'SET_ENABLE_THINKING': {
return {
...state,
enableThinking: action.enable,
};
return { ...state, enableThinking: action.enable };
}
case 'SET_BANNED_TOKENS': {
return {
...state,
bannedTokens: action.tokens,
};
return { ...state, bannedTokens: action.tokens };
}
case 'ADD_CHARACTER': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, characters: [...s.characters, action.character] }
: s
),
};
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
characters: [...c.characters, action.character],
}));
}
case 'EDIT_CHARACTER': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, ...action.updates }
: c
),
}
: s
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
characters: c.characters.map(ch =>
ch.id === action.characterId ? { ...ch, ...action.updates } : ch
),
};
}));
}
case 'DELETE_CHARACTER': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const deletedChar = s.characters.find(c => c.id === action.characterId);
if (!deletedChar) return s;
return {
...s,
characters: s.characters
.filter(c => c.id !== action.characterId)
.map(c => ({
...c,
relations: c.relations.filter(r => r.name !== deletedChar.name),
})),
};
}),
};
return updateLoreContainer(state, action.worldId, action.storyId, c => {
const deleted = c.characters.find(ch => ch.id === action.characterId);
if (!deleted) return {};
return {
characters: c.characters
.filter(ch => ch.id !== action.characterId)
.map(ch => ({
...ch,
relations: ch.relations.filter(r => r.name !== deleted.name),
})),
};
});
}
case 'ADD_CHARACTER_RELATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, relations: [...c.relations, action.relation] }
: c
),
}
: s
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
characters: c.characters.map(ch =>
ch.id === action.characterId
? { ...ch, relations: [...ch.relations, action.relation] }
: ch
),
};
}));
}
case 'EDIT_CHARACTER_RELATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
characters: c.characters.map(ch =>
ch.id === action.characterId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? {
...c,
relations: c.relations.map(r =>
r.name === action.targetName
? { ...r, ...action.updates }
: r
),
}
: c
...ch,
relations: ch.relations.map(r =>
r.name === action.targetName ? { ...r, ...action.updates } : r
),
}
: s
: ch
),
};
}));
}
case 'DELETE_CHARACTER_RELATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, relations: c.relations.filter(r => r.name !== action.targetName) }
: c
),
}
: s
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
characters: c.characters.map(ch =>
ch.id === action.characterId
? { ...ch, relations: ch.relations.filter(r => r.name !== action.targetName) }
: ch
),
};
}));
}
case 'ADD_LOCATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, locations: [...s.locations, action.location] }
: s
),
};
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
locations: [...c.locations, action.location],
}));
}
case 'EDIT_LOCATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
locations: s.locations.map(l =>
l.id === action.locationId
? { ...l, ...action.updates }
: l
),
}
: s
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
locations: c.locations.map(l =>
l.id === action.locationId ? { ...l, ...action.updates } : l
),
};
}));
}
case 'DELETE_LOCATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, locations: s.locations.filter(l => l.id !== action.locationId) }
: s
),
};
}
case 'CLEAN_CHAPTER_SUMMARIES': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const chapters = (s.chapters ?? [])
.filter(c => action.validHashes[c.header] !== undefined)
.map(c => {
const valid = new Set(action.validHashes[c.header]);
const summaryCache = Object.fromEntries(
Object.entries(c.summaryCache).filter(([hash]) => valid.has(hash))
);
return { ...c, summaryCache };
});
return { ...s, chapters };
}),
};
}
case 'EDIT_SCRATCHPAD': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.id ? { ...s, scratchpad: action.text } : s
),
};
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
locations: c.locations.filter(l => l.id !== action.locationId),
}));
}
case 'STORE_CHAPTER_SUMMARY': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const chapters = s.chapters ?? [];
const existing = chapters.find(c => c.header === action.header);
const updated = existing
? Chapters.storeSummary(existing, action.hash, action.summary)
: Chapters.storeSummary(Chapters.emptyChapter(action.header), action.hash, action.summary);
return {
...s,
chapters: existing
? chapters.map(c => c.header === action.header ? updated : c)
: [...chapters, updated],
};
}),
};
return updateStory(state, action.worldId, action.storyId, s => {
const chapters = s.chapters ?? [];
const existing = chapters.find(c => c.header === action.header);
const updated = existing
? Chapters.storeSummary(existing, action.hash, action.summary)
: Chapters.storeSummary(Chapters.emptyChapter(action.header), action.hash, action.summary);
return {
...s,
chapters: existing
? chapters.map(c => c.header === action.header ? updated : c)
: [...chapters, updated],
};
});
}
case 'CLEAN_CHAPTER_SUMMARIES': {
return updateStory(state, action.worldId, action.storyId, s => {
const chapters = (s.chapters ?? [])
.filter(c => action.validHashes[c.header] !== undefined)
.map(c => {
const valid = new Set(action.validHashes[c.header]);
const summaryCache = Object.fromEntries(
Object.entries(c.summaryCache).filter(([hash]) => valid.has(hash))
);
return { ...c, summaryCache };
});
return { ...s, chapters };
});
}
}
}
@ -542,8 +499,13 @@ function reducer(state: IState, action: Action): IState {
// ─── Context ─────────────────────────────────────────────────────────────────
export interface AppState {
stories: Story[];
worlds: World[];
currentWorld: World | null;
currentStory: Story | null;
/** Combined lore/characters/locations: world-level merged with story-level */
mergedLore: LoreEntry[];
mergedCharacters: Character[];
mergedLocations: Location[];
currentTab: Tab;
chatOpen: boolean;
connection: LLM.Connection | null;
@ -563,18 +525,41 @@ export const useAppState = () => useContext(StateContext);
export const StateContextProvider = ({ children }: { children?: any }) => {
const [state, dispatch] = useRemoteReducer('storywriter.state', reducer, DEFAULT_STATE);
const value = useMemo<AppState>(() => ({
stories: state.stories,
currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null,
currentTab: state.currentTab,
chatOpen: state.chatOpen,
connection: state.connection,
model: state.model,
enableThinking: state.enableThinking,
bannedTokens: state.bannedTokens ?? [],
systemInstruction: state.systemInstruction ?? '',
dispatch,
}), [state]);
const value = useMemo<AppState>(() => {
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
const currentStory = currentWorld?.stories.find(s => s.id === state.currentStoryId) ?? null;
// Merge world-level + story-level, story takes priority (appended after)
const mergedLore = [
...(currentWorld?.lore ?? []),
...(currentStory?.lore ?? []),
];
const mergedCharacters = [
...(currentWorld?.characters ?? []),
...(currentStory?.characters ?? []),
];
const mergedLocations = [
...(currentWorld?.locations ?? []),
...(currentStory?.locations ?? []),
];
return {
worlds: state.worlds,
currentWorld,
currentStory,
mergedLore,
mergedCharacters,
mergedLocations,
currentTab: state.currentTab,
chatOpen: state.chatOpen,
connection: state.connection,
model: state.model,
enableThinking: state.enableThinking,
bannedTokens: state.bannedTokens ?? [],
systemInstruction: state.systemInstruction ?? '',
dispatch,
};
}, [state]);
return (
<StateContext.Provider value={value}>

View File

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

View File

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

View File

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