Worlds
This commit is contained in:
parent
ad1239273a
commit
64b913374e
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
↓
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)' })),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue