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,
@ -139,8 +192,8 @@ const DEFAULT_STATE: IState = {
bannedTokens: [],
systemInstruction: `You are a creative writing assistant. Help the user develop their story by writing engaging content, maintaining consistency with the established characters, settings, and plot. Follow the user's instructions while staying true to the story's tone and style.
Write using markdown to highlight special parts.
Supported markdown subset:
Write using markdown to highlight special parts.
Supported markdown subset:
- *italic*
- **bold**
- "quotes"
@ -153,7 +206,7 @@ Supported markdown subset:
- Ordered lists
- Unordered lists (only with \`- \` markers)
- Only top-level lists (no nesting)
Show the chapters with \`# Chapter\` headers.
Add important details not yet ready to be included in the story to the scratchpad: character motivations, hidden plot points, etc.
You **must** use \`edit_text\` tool to write to the story.
@ -165,6 +218,44 @@ The most actual state of the story is provided below, use it as ground truth.`,
function reducer(state: IState, action: Action): IState {
switch (action.type) {
case 'CREATE_WORLD': {
const world: World = {
id: crypto.randomUUID(),
title: action.title,
lore: [],
characters: [],
locations: [],
stories: [],
};
return {
...state,
worlds: [...state.worlds, world],
currentWorldId: world.id,
currentStoryId: null,
currentTab: 'lore',
};
}
case 'RENAME_WORLD': {
return updateWorld(state, action.worldId, w => ({ ...w, title: action.title }));
}
case 'DELETE_WORLD': {
const remaining = state.worlds.filter(w => w.id !== action.worldId);
const deletingCurrent = state.currentWorldId === action.worldId;
return {
...state,
worlds: remaining,
currentWorldId: deletingCurrent ? null : state.currentWorldId,
currentStoryId: deletingCurrent ? null : state.currentStoryId,
};
}
case 'SELECT_WORLD': {
return {
...state,
currentWorldId: action.worldId,
currentStoryId: null,
currentTab: 'lore',
};
}
case 'CREATE_STORY': {
const story: Story = {
id: crypto.randomUUID(),
@ -178,108 +269,46 @@ function reducer(state: IState, action: Action): IState {
chapters: [],
};
return {
...state,
stories: [...state.stories, story],
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
currentWorldId: action.worldId,
currentStoryId: story.id,
currentTab: 'story',
};
}
case 'RENAME_STORY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.id ? { ...s, title: action.title } : s
),
};
return updateStory(state, action.worldId, action.id, s => ({ ...s, title: action.title }));
}
case 'EDIT_STORY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.id
? { ...s, text: action.text, lastEditedText: action.highlightText }
: s
),
};
return updateStory(state, action.worldId, action.id, s => ({
...s,
text: action.text,
lastEditedText: action.highlightText,
}));
}
case 'ADD_LORE_ENTRY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, lore: [...s.lore, action.entry] }
: s
),
};
}
case 'EDIT_LORE_ENTRY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
lore: s.lore.map(e =>
e.id === action.entryId
? { ...e, ...action.updates }
: e
),
}
: s
),
};
}
case 'DELETE_LORE_ENTRY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, lore: s.lore.filter(e => e.id !== action.entryId) }
: s
),
};
}
case 'REORDER_LORE_ENTRIES': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const entryMap = new Map(s.lore.map(e => [e.id, e]));
const reordered = action.entryIds
.map(id => entryMap.get(id))
.filter((e): e is LoreEntry => e !== undefined);
// Add any entries that weren't in the new order (safety)
for (const entry of s.lore) {
if (!action.entryIds.includes(entry.id)) {
reordered.push(entry);
}
}
return { ...s, lore: reordered };
}),
};
}
case 'SET_SYSTEM_INSTRUCTION': {
return {
...state,
systemInstruction: action.systemInstruction,
};
}
case 'SET_CURRENT_TAB': {
return { ...state, currentTab: action.tab };
}
case 'SET_CHAT_OPEN': {
return { ...state, chatOpen: action.open };
case 'EDIT_SCRATCHPAD': {
return updateStory(state, action.worldId, action.id, s => ({ ...s, scratchpad: action.text }));
}
case 'DELETE_STORY': {
const remaining = state.stories.filter(s => s.id !== action.id);
const deletingCurrent = state.currentStoryId === action.id;
return {
...state,
stories: remaining,
...updateWorld(state, action.worldId, w => ({
...w,
stories: w.stories.filter(s => s.id !== action.id),
})),
currentStoryId: deletingCurrent ? null : state.currentStoryId,
};
}
case 'SELECT_STORY': {
return {
...state,
currentWorldId: action.worldId,
currentStoryId: action.id,
currentTab: 'story',
};
}
case 'DUPLICATE_STORY': {
const original = state.stories.find(s => s.id === action.id);
const world = state.worlds.find(w => w.id === action.worldId);
const original = world?.stories.find(s => s.id === action.id);
if (!original) return state;
const newStory: Story = {
id: crypto.randomUUID(),
@ -287,254 +316,182 @@ function reducer(state: IState, action: Action): IState {
text: '',
scratchpad: '',
lore: [...original.lore],
characters: original.characters,
locations: original.locations,
characters: [...original.characters],
locations: [...original.locations],
chatMessages: [],
chapters: [],
};
return {
...state,
stories: [...state.stories, newStory],
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })),
currentStoryId: newStory.id,
currentTab: 'story',
};
}
case 'SELECT_STORY': {
return { ...state, currentStoryId: action.id, currentTab: 'story' };
case 'ADD_LORE_ENTRY': {
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
lore: [...c.lore, action.entry],
}));
}
case 'EDIT_LORE_ENTRY': {
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
lore: c.lore.map(e => e.id === action.entryId ? { ...e, ...action.updates } : e),
}));
}
case 'DELETE_LORE_ENTRY': {
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
lore: c.lore.filter(e => e.id !== action.entryId),
}));
}
case 'REORDER_LORE_ENTRIES': {
return updateLoreContainer(state, action.worldId, action.storyId, c => {
const entryMap = new Map(c.lore.map(e => [e.id, e]));
const reordered = action.entryIds
.map(id => entryMap.get(id))
.filter((e): e is LoreEntry => e !== undefined);
for (const entry of c.lore) {
if (!action.entryIds.includes(entry.id)) reordered.push(entry);
}
return { lore: reordered };
});
}
case 'SET_SYSTEM_INSTRUCTION': {
return { ...state, systemInstruction: action.systemInstruction };
}
case 'SET_CURRENT_TAB': {
return { ...state, currentTab: action.tab };
}
case 'SET_CHAT_OPEN': {
return { ...state, chatOpen: action.open };
}
case 'ADD_CHAT_MESSAGE': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id);
if (existingIndex !== -1) {
// Overwrite existing message with same id
const updatedMessages = [...s.chatMessages];
updatedMessages[existingIndex] = action.message;
return { ...s, chatMessages: updatedMessages };
}
// Append new message
return { ...s, chatMessages: [...s.chatMessages, action.message] };
}),
};
return updateStory(state, action.worldId, action.storyId, s => {
const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id);
if (existingIndex !== -1) {
const updatedMessages = [...s.chatMessages];
updatedMessages[existingIndex] = action.message;
return { ...s, chatMessages: updatedMessages };
}
return { ...s, chatMessages: [...s.chatMessages, action.message] };
});
}
case 'CLEAR_CHAT': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId ? { ...s, chatMessages: [] } : s
),
};
return updateStory(state, action.worldId, action.storyId, s => ({ ...s, chatMessages: [] }));
}
case 'SET_CONNECTION': {
return {
...state,
connection: action.connection,
};
return { ...state, connection: action.connection };
}
case 'SET_MODEL': {
return {
...state,
model: action.model,
};
return { ...state, model: action.model };
}
case 'SET_ENABLE_THINKING': {
return {
...state,
enableThinking: action.enable,
};
return { ...state, enableThinking: action.enable };
}
case 'SET_BANNED_TOKENS': {
return {
...state,
bannedTokens: action.tokens,
};
return { ...state, bannedTokens: action.tokens };
}
case 'ADD_CHARACTER': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, characters: [...s.characters, action.character] }
: s
),
};
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
characters: [...c.characters, action.character],
}));
}
case 'EDIT_CHARACTER': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, ...action.updates }
: c
),
}
: s
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
characters: c.characters.map(ch =>
ch.id === action.characterId ? { ...ch, ...action.updates } : ch
),
};
}));
}
case 'DELETE_CHARACTER': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const deletedChar = s.characters.find(c => c.id === action.characterId);
if (!deletedChar) return s;
return {
...s,
characters: s.characters
.filter(c => c.id !== action.characterId)
.map(c => ({
...c,
relations: c.relations.filter(r => r.name !== deletedChar.name),
})),
};
}),
};
return updateLoreContainer(state, action.worldId, action.storyId, c => {
const deleted = c.characters.find(ch => ch.id === action.characterId);
if (!deleted) return {};
return {
characters: c.characters
.filter(ch => ch.id !== action.characterId)
.map(ch => ({
...ch,
relations: ch.relations.filter(r => r.name !== deleted.name),
})),
};
});
}
case 'ADD_CHARACTER_RELATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, relations: [...c.relations, action.relation] }
: c
),
}
: s
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
characters: c.characters.map(ch =>
ch.id === action.characterId
? { ...ch, relations: [...ch.relations, action.relation] }
: ch
),
};
}));
}
case 'EDIT_CHARACTER_RELATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
characters: c.characters.map(ch =>
ch.id === action.characterId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? {
...c,
relations: c.relations.map(r =>
r.name === action.targetName
? { ...r, ...action.updates }
: r
),
}
: c
...ch,
relations: ch.relations.map(r =>
r.name === action.targetName ? { ...r, ...action.updates } : r
),
}
: s
: ch
),
};
}));
}
case 'DELETE_CHARACTER_RELATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
characters: s.characters.map(c =>
c.id === action.characterId
? { ...c, relations: c.relations.filter(r => r.name !== action.targetName) }
: c
),
}
: s
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
characters: c.characters.map(ch =>
ch.id === action.characterId
? { ...ch, relations: ch.relations.filter(r => r.name !== action.targetName) }
: ch
),
};
}));
}
case 'ADD_LOCATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, locations: [...s.locations, action.location] }
: s
),
};
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
locations: [...c.locations, action.location],
}));
}
case 'EDIT_LOCATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? {
...s,
locations: s.locations.map(l =>
l.id === action.locationId
? { ...l, ...action.updates }
: l
),
}
: s
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
locations: c.locations.map(l =>
l.id === action.locationId ? { ...l, ...action.updates } : l
),
};
}));
}
case 'DELETE_LOCATION': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.storyId
? { ...s, locations: s.locations.filter(l => l.id !== action.locationId) }
: s
),
};
}
case 'CLEAN_CHAPTER_SUMMARIES': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const chapters = (s.chapters ?? [])
.filter(c => action.validHashes[c.header] !== undefined)
.map(c => {
const valid = new Set(action.validHashes[c.header]);
const summaryCache = Object.fromEntries(
Object.entries(c.summaryCache).filter(([hash]) => valid.has(hash))
);
return { ...c, summaryCache };
});
return { ...s, chapters };
}),
};
}
case 'EDIT_SCRATCHPAD': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.id ? { ...s, scratchpad: action.text } : s
),
};
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
locations: c.locations.filter(l => l.id !== action.locationId),
}));
}
case 'STORE_CHAPTER_SUMMARY': {
return {
...state,
stories: state.stories.map(s => {
if (s.id !== action.storyId) return s;
const chapters = s.chapters ?? [];
const existing = chapters.find(c => c.header === action.header);
const updated = existing
? Chapters.storeSummary(existing, action.hash, action.summary)
: Chapters.storeSummary(Chapters.emptyChapter(action.header), action.hash, action.summary);
return {
...s,
chapters: existing
? chapters.map(c => c.header === action.header ? updated : c)
: [...chapters, updated],
};
}),
};
return updateStory(state, action.worldId, action.storyId, s => {
const chapters = s.chapters ?? [];
const existing = chapters.find(c => c.header === action.header);
const updated = existing
? Chapters.storeSummary(existing, action.hash, action.summary)
: Chapters.storeSummary(Chapters.emptyChapter(action.header), action.hash, action.summary);
return {
...s,
chapters: existing
? chapters.map(c => c.header === action.header ? updated : c)
: [...chapters, updated],
};
});
}
case 'CLEAN_CHAPTER_SUMMARIES': {
return updateStory(state, action.worldId, action.storyId, s => {
const chapters = (s.chapters ?? [])
.filter(c => action.validHashes[c.header] !== undefined)
.map(c => {
const valid = new Set(action.validHashes[c.header]);
const summaryCache = Object.fromEntries(
Object.entries(c.summaryCache).filter(([hash]) => valid.has(hash))
);
return { ...c, summaryCache };
});
return { ...s, chapters };
});
}
}
}
@ -542,8 +499,13 @@ function reducer(state: IState, action: Action): IState {
// ─── Context ─────────────────────────────────────────────────────────────────
export interface AppState {
stories: Story[];
worlds: World[];
currentWorld: World | null;
currentStory: Story | null;
/** Combined lore/characters/locations: world-level merged with story-level */
mergedLore: LoreEntry[];
mergedCharacters: Character[];
mergedLocations: Location[];
currentTab: Tab;
chatOpen: boolean;
connection: LLM.Connection | null;
@ -563,18 +525,41 @@ export const useAppState = () => useContext(StateContext);
export const StateContextProvider = ({ children }: { children?: any }) => {
const [state, dispatch] = useRemoteReducer('storywriter.state', reducer, DEFAULT_STATE);
const value = useMemo<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,
});