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,
|
||||
|
|
@ -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