Worlds
This commit is contained in:
parent
ad1239273a
commit
64b913374e
|
|
@ -15,6 +15,21 @@
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleWorld {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleSep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,57 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.worldGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storiesList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
padding-left: 20px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyItem {
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worldTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandButton {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px 3px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyStories {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.newButton {
|
.newButton {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { highlight } from "@common/highlight";
|
||||||
import styles from "../assets/chapters-editor.module.css";
|
import styles from "../assets/chapters-editor.module.css";
|
||||||
|
|
||||||
export const ChaptersEditor = () => {
|
export const ChaptersEditor = () => {
|
||||||
const { currentStory, dispatch } = useAppState();
|
const { currentWorld, currentStory, dispatch } = useAppState();
|
||||||
|
|
||||||
if (!currentStory) return null;
|
if (!currentStory) return null;
|
||||||
|
|
||||||
|
|
@ -50,6 +50,7 @@ export const ChaptersEditor = () => {
|
||||||
placeholder="Not summarized yet..."
|
placeholder="Not summarized yet..."
|
||||||
onInput={(e) => dispatch({
|
onInput={(e) => dispatch({
|
||||||
type: 'STORE_CHAPTER_SUMMARY',
|
type: 'STORE_CHAPTER_SUMMARY',
|
||||||
|
worldId: currentWorld!.id,
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
header: parsedChapter.header,
|
header: parsedChapter.header,
|
||||||
hash,
|
hash,
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,26 @@ import LLM from "../utils/llm";
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
|
|
||||||
export const CharacterEditor = () => {
|
export const CharacterEditor = () => {
|
||||||
const { currentStory, dispatch, connection, model } = useAppState();
|
const { currentWorld, currentStory, mergedCharacters, dispatch, connection, model } = useAppState();
|
||||||
const [newNickname, setNewNickname] = useState<Record<string, string>>({});
|
const [newNickname, setNewNickname] = useState<Record<string, string>>({});
|
||||||
const [newRelation, setNewRelation] = useState<Record<string, { name: string; relation: string }>>({});
|
const [newRelation, setNewRelation] = useState<Record<string, { name: string; relation: string }>>({});
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||||
const [generatingShortDesc, setGeneratingShortDesc] = useState<string | null>(null);
|
const [generatingShortDesc, setGeneratingShortDesc] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!currentStory) {
|
if (!currentWorld) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a story is selected, edit story-level characters; otherwise world-level
|
||||||
|
const storyId = currentStory?.id ?? null;
|
||||||
|
const worldId = currentWorld.id;
|
||||||
|
const characters = currentStory ? currentStory.characters : currentWorld.characters;
|
||||||
|
|
||||||
const handleAddCharacter = () => {
|
const handleAddCharacter = () => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'ADD_CHARACTER',
|
type: 'ADD_CHARACTER',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
character: {
|
character: {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: 'New Character',
|
name: 'New Character',
|
||||||
|
|
@ -34,7 +40,8 @@ export const CharacterEditor = () => {
|
||||||
const handleEditCharacter = (characterId: string, field: keyof Character, value: any) => {
|
const handleEditCharacter = (characterId: string, field: keyof Character, value: any) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'EDIT_CHARACTER',
|
type: 'EDIT_CHARACTER',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
characterId,
|
characterId,
|
||||||
updates: { [field]: value },
|
updates: { [field]: value },
|
||||||
});
|
});
|
||||||
|
|
@ -43,7 +50,8 @@ export const CharacterEditor = () => {
|
||||||
const handleDeleteCharacter = (characterId: string) => {
|
const handleDeleteCharacter = (characterId: string) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'DELETE_CHARACTER',
|
type: 'DELETE_CHARACTER',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
characterId,
|
characterId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -54,7 +62,8 @@ export const CharacterEditor = () => {
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'ADD_CHARACTER_RELATION',
|
type: 'ADD_CHARACTER_RELATION',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
characterId,
|
characterId,
|
||||||
relation: { name: rel.name.trim(), relation: rel.relation.trim() },
|
relation: { name: rel.name.trim(), relation: rel.relation.trim() },
|
||||||
});
|
});
|
||||||
|
|
@ -64,7 +73,8 @@ export const CharacterEditor = () => {
|
||||||
const handleEditRelation = (characterId: string, targetName: string, field: 'name' | 'relation', value: string) => {
|
const handleEditRelation = (characterId: string, targetName: string, field: 'name' | 'relation', value: string) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'EDIT_CHARACTER_RELATION',
|
type: 'EDIT_CHARACTER_RELATION',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
characterId,
|
characterId,
|
||||||
targetName,
|
targetName,
|
||||||
updates: { [field]: value },
|
updates: { [field]: value },
|
||||||
|
|
@ -74,7 +84,8 @@ export const CharacterEditor = () => {
|
||||||
const handleDeleteRelation = (characterId: string, targetName: string) => {
|
const handleDeleteRelation = (characterId: string, targetName: string) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'DELETE_CHARACTER_RELATION',
|
type: 'DELETE_CHARACTER_RELATION',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
characterId,
|
characterId,
|
||||||
targetName,
|
targetName,
|
||||||
});
|
});
|
||||||
|
|
@ -84,7 +95,7 @@ export const CharacterEditor = () => {
|
||||||
const nickname = (newNickname[characterId] || '').trim();
|
const nickname = (newNickname[characterId] || '').trim();
|
||||||
if (!nickname) return;
|
if (!nickname) return;
|
||||||
|
|
||||||
const character = currentStory.characters.find(c => c.id === characterId);
|
const character = characters.find(c => c.id === characterId);
|
||||||
if (character) {
|
if (character) {
|
||||||
handleEditCharacter(characterId, 'nicknames', [...character.nicknames, nickname]);
|
handleEditCharacter(characterId, 'nicknames', [...character.nicknames, nickname]);
|
||||||
setNewNickname({ ...newNickname, [characterId]: '' });
|
setNewNickname({ ...newNickname, [characterId]: '' });
|
||||||
|
|
@ -92,7 +103,7 @@ export const CharacterEditor = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNicknameDelete = (characterId: string, nickname: string) => {
|
const handleNicknameDelete = (characterId: string, nickname: string) => {
|
||||||
const character = currentStory.characters.find(c => c.id === characterId);
|
const character = characters.find(c => c.id === characterId);
|
||||||
if (character) {
|
if (character) {
|
||||||
handleEditCharacter(characterId, 'nicknames', character.nicknames.filter(n => n !== nickname));
|
handleEditCharacter(characterId, 'nicknames', character.nicknames.filter(n => n !== nickname));
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +117,7 @@ export const CharacterEditor = () => {
|
||||||
const handleGenerateShortDescription = async (characterId: string) => {
|
const handleGenerateShortDescription = async (characterId: string) => {
|
||||||
if (!connection || !model) return;
|
if (!connection || !model) return;
|
||||||
|
|
||||||
const character = currentStory.characters.find(c => c.id === characterId);
|
const character = characters.find(c => c.id === characterId);
|
||||||
if (!character || !character.description.trim()) return;
|
if (!character || !character.description.trim()) return;
|
||||||
|
|
||||||
setGeneratingShortDesc(characterId);
|
setGeneratingShortDesc(characterId);
|
||||||
|
|
@ -123,18 +134,18 @@ export const CharacterEditor = () => {
|
||||||
return (
|
return (
|
||||||
<div class={styles.characterEditor}>
|
<div class={styles.characterEditor}>
|
||||||
<div class={styles.header}>
|
<div class={styles.header}>
|
||||||
<h2>Characters</h2>
|
<h2>{currentStory ? 'Story Characters' : 'World Characters'}</h2>
|
||||||
<button class={styles.addButton} onClick={handleAddCharacter}>
|
<button class={styles.addButton} onClick={handleAddCharacter}>
|
||||||
+ Add Character
|
+ Add Character
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={styles.list}>
|
<div class={styles.list}>
|
||||||
{currentStory.characters.length === 0 && (
|
{characters.length === 0 && (
|
||||||
<p class={styles.empty}>No characters yet. Add your first character!</p>
|
<p class={styles.empty}>No characters yet. Add your first character!</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStory.characters.map((character) => (
|
{characters.map((character) => (
|
||||||
<div key={character.id} class={styles.characterCard}>
|
<div key={character.id} class={styles.characterCard}>
|
||||||
<div class={styles.cardHeader}>
|
<div class={styles.cardHeader}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -275,7 +286,7 @@ export const CharacterEditor = () => {
|
||||||
onInput={(e) => handleNewRelationChange(character.id, 'name', e.currentTarget.value)}
|
onInput={(e) => handleNewRelationChange(character.id, 'name', e.currentTarget.value)}
|
||||||
>
|
>
|
||||||
<option value="" disabled>Select character</option>
|
<option value="" disabled>Select character</option>
|
||||||
{currentStory.characters
|
{mergedCharacters
|
||||||
.filter(c => c.id !== character.id)
|
.filter(c => c.id !== character.id)
|
||||||
.map(c => (
|
.map(c => (
|
||||||
<option key={c.id} value={c.name}>{c.name}</option>
|
<option key={c.id} value={c.name}>{c.name}</option>
|
||||||
|
|
@ -313,7 +324,7 @@ export const CharacterEditor = () => {
|
||||||
value={rel.name}
|
value={rel.name}
|
||||||
onInput={(e) => handleEditRelation(character.id, rel.name, 'name', e.currentTarget.value)}
|
onInput={(e) => handleEditRelation(character.id, rel.name, 'name', e.currentTarget.value)}
|
||||||
>
|
>
|
||||||
{currentStory.characters
|
{mergedCharacters
|
||||||
.filter(c => c.id !== character.id)
|
.filter(c => c.id !== character.id)
|
||||||
.map(c => (
|
.map(c => (
|
||||||
<option key={c.id} value={c.name}>{c.name}</option>
|
<option key={c.id} value={c.name}>{c.name}</option>
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
|
||||||
|
|
||||||
export const ChatSidebar = () => {
|
export const ChatSidebar = () => {
|
||||||
const appState = useAppState();
|
const appState = useAppState();
|
||||||
const { currentStory, dispatch, connection, model, enableThinking, chatOpen } = appState;
|
const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen } = appState;
|
||||||
const { summarizeAll, isSummarizing } = useChapterSummarization();
|
const { summarizeAll, isSummarizing } = useChapterSummarization();
|
||||||
const [input, setInput] = useInputState('');
|
const [input, setInput] = useInputState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
@ -126,11 +126,12 @@ export const ChatSidebar = () => {
|
||||||
}, [currentStory, connection, model, input, currentStory?.chatMessages.length]);
|
}, [currentStory, connection, model, input, currentStory?.chatMessages.length]);
|
||||||
|
|
||||||
const sendMessage = useCallback(async (newMessages: ChatMessage[]) => {
|
const sendMessage = useCallback(async (newMessages: ChatMessage[]) => {
|
||||||
if (!currentStory || !connection || !model) return;
|
if (!currentStory || !currentWorld || !connection || !model) return;
|
||||||
|
|
||||||
for (const message of newMessages) {
|
for (const message of newMessages) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'ADD_CHAT_MESSAGE',
|
type: 'ADD_CHAT_MESSAGE',
|
||||||
|
worldId: currentWorld.id,
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
|
|
@ -139,6 +140,7 @@ export const ChatSidebar = () => {
|
||||||
const assistantMessageId = crypto.randomUUID();
|
const assistantMessageId = crypto.randomUUID();
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'ADD_CHAT_MESSAGE',
|
type: 'ADD_CHAT_MESSAGE',
|
||||||
|
worldId: currentWorld.id,
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
message: {
|
message: {
|
||||||
id: assistantMessageId,
|
id: assistantMessageId,
|
||||||
|
|
@ -182,6 +184,7 @@ export const ChatSidebar = () => {
|
||||||
if (content || reasoningContent) {
|
if (content || reasoningContent) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'ADD_CHAT_MESSAGE',
|
type: 'ADD_CHAT_MESSAGE',
|
||||||
|
worldId: currentWorld.id,
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
message: {
|
message: {
|
||||||
id: assistantMessageId,
|
id: assistantMessageId,
|
||||||
|
|
@ -202,6 +205,7 @@ export const ChatSidebar = () => {
|
||||||
};
|
};
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'ADD_CHAT_MESSAGE',
|
type: 'ADD_CHAT_MESSAGE',
|
||||||
|
worldId: currentWorld.id,
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
message: assistantMessage,
|
message: assistantMessage,
|
||||||
});
|
});
|
||||||
|
|
@ -221,6 +225,7 @@ export const ChatSidebar = () => {
|
||||||
};
|
};
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'ADD_CHAT_MESSAGE',
|
type: 'ADD_CHAT_MESSAGE',
|
||||||
|
worldId: currentWorld.id,
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
|
|
@ -291,9 +296,10 @@ export const ChatSidebar = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
if (!currentStory) return;
|
if (!currentStory || !currentWorld) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'CLEAR_CHAT',
|
type: 'CLEAR_CHAT',
|
||||||
|
worldId: currentWorld.id,
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ import { LoreEditor } from "./lore-editor";
|
||||||
import { Menu } from "./menu";
|
import { Menu } from "./menu";
|
||||||
import { useInputCallback } from "@common/hooks/useInputCallback";
|
import { useInputCallback } from "@common/hooks/useInputCallback";
|
||||||
import Prompt from "../utils/prompt";
|
import Prompt from "../utils/prompt";
|
||||||
import { BookOpen, List, Users, MapPin, BookMarked, FileText, Code, Layers, MessageSquare, type LucideIcon } from "lucide-preact";
|
import { BookOpen, List, Users, MapPin, BookMarked, FileText, Code, Layers, MessageSquare, Globe, type LucideIcon } from "lucide-preact";
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
|
// Tabs available when a story is selected
|
||||||
|
const STORY_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
|
||||||
{ id: "menu", label: "Menu", icon: List },
|
{ id: "menu", label: "Menu", icon: List },
|
||||||
{ id: "story", label: "Story", icon: BookOpen },
|
{ id: "story", label: "Story", icon: BookOpen },
|
||||||
{ id: "chapters", label: "Chapters", icon: Layers },
|
{ id: "chapters", label: "Chapters", icon: Layers },
|
||||||
|
|
@ -24,19 +25,27 @@ const TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
|
||||||
{ id: "prompt", label: "Prompt", icon: Code },
|
{ id: "prompt", label: "Prompt", icon: Code },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Tabs available when only a world is selected (no story)
|
||||||
|
const WORLD_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
|
||||||
|
{ id: "menu", label: "Menu", icon: List },
|
||||||
|
{ id: "lore", label: "Lore", icon: BookMarked },
|
||||||
|
{ id: "characters", label: "Characters", icon: Users },
|
||||||
|
{ id: "locations", label: "Locations", icon: MapPin },
|
||||||
|
];
|
||||||
|
|
||||||
export const Editor = () => {
|
export const Editor = () => {
|
||||||
const appState = useAppState();
|
const appState = useAppState();
|
||||||
const { currentStory, currentTab, chatOpen, dispatch } = appState;
|
const { currentWorld, currentStory, currentTab, chatOpen, dispatch } = appState;
|
||||||
|
|
||||||
const handleInput = useInputCallback((text: string) => {
|
const handleInput = useInputCallback((text: string) => {
|
||||||
if (!currentStory) return;
|
if (!currentStory || !currentWorld) return;
|
||||||
dispatch({ type: 'EDIT_STORY', id: currentStory.id, text });
|
dispatch({ type: 'EDIT_STORY', worldId: currentWorld.id, id: currentStory.id, text });
|
||||||
}, [currentStory?.id]);
|
}, [currentStory?.id, currentWorld?.id]);
|
||||||
|
|
||||||
const handleScratchpadInput = useInputCallback((text: string) => {
|
const handleScratchpadInput = useInputCallback((text: string) => {
|
||||||
if (!currentStory) return;
|
if (!currentStory || !currentWorld) return;
|
||||||
dispatch({ type: 'EDIT_SCRATCHPAD', id: currentStory.id, text });
|
dispatch({ type: 'EDIT_SCRATCHPAD', worldId: currentWorld.id, id: currentStory.id, text });
|
||||||
}, [currentStory?.id]);
|
}, [currentStory?.id, currentWorld?.id]);
|
||||||
|
|
||||||
const handleTabChange = (tab: Tab) => {
|
const handleTabChange = (tab: Tab) => {
|
||||||
dispatch({ type: 'SET_CURRENT_TAB', tab });
|
dispatch({ type: 'SET_CURRENT_TAB', tab });
|
||||||
|
|
@ -89,11 +98,23 @@ export const Editor = () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [currentStory?.id, currentTab]);
|
}, [currentStory?.id, currentWorld?.id, currentTab]);
|
||||||
|
|
||||||
|
const hasSelection = currentWorld !== null;
|
||||||
|
const tabs = currentStory ? STORY_TABS : currentWorld ? WORLD_TABS : [{ id: "menu" as Tab, label: "Menu", icon: List }];
|
||||||
|
|
||||||
|
// Title bar: show world > story or just world
|
||||||
|
const titleBar = currentStory
|
||||||
|
? <div class={styles.title}>
|
||||||
|
<span class={styles.titleWorld}>{currentWorld?.title}</span>
|
||||||
|
<span class={styles.titleSep}>/</span>{currentStory.title}</div>
|
||||||
|
: currentWorld
|
||||||
|
? <div class={styles.title}><Globe size={24} />{currentWorld.title}</div>
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.editor}>
|
<div class={styles.editor}>
|
||||||
{currentStory && <div class={styles.title}>{currentStory.title}</div>}
|
{titleBar}
|
||||||
<div class={clsx(styles.content, currentTab === 'menu' && styles.menuContent)} ref={contentRef}>
|
<div class={clsx(styles.content, currentTab === 'menu' && styles.menuContent)} ref={contentRef}>
|
||||||
{currentTab === "menu" && (
|
{currentTab === "menu" && (
|
||||||
<Menu />
|
<Menu />
|
||||||
|
|
@ -106,13 +127,13 @@ export const Editor = () => {
|
||||||
placeholder="Start writing your story..."
|
placeholder="Start writing your story..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentTab === "lore" && currentStory && (
|
{currentTab === "lore" && (currentStory || currentWorld) && (
|
||||||
<LoreEditor />
|
<LoreEditor />
|
||||||
)}
|
)}
|
||||||
{currentTab === "characters" && currentStory && (
|
{currentTab === "characters" && (currentStory || currentWorld) && (
|
||||||
<CharacterEditor />
|
<CharacterEditor />
|
||||||
)}
|
)}
|
||||||
{currentTab === "locations" && currentStory && (
|
{currentTab === "locations" && (currentStory || currentWorld) && (
|
||||||
<LocationEditor />
|
<LocationEditor />
|
||||||
)}
|
)}
|
||||||
{currentTab === "chapters" && currentStory && (
|
{currentTab === "chapters" && currentStory && (
|
||||||
|
|
@ -131,7 +152,7 @@ export const Editor = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.tabs}>
|
<div class={styles.tabs}>
|
||||||
{TABS.filter(tab => currentStory || tab.id === 'menu').map((tab) => (
|
{tabs.filter(tab => hasSelection || tab.id === 'menu').map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
class={clsx(styles.tab, currentTab === tab.id && styles.active, tab.right && styles.tabRight)}
|
class={clsx(styles.tab, currentTab === tab.id && styles.active, tab.right && styles.tabRight)}
|
||||||
|
|
@ -142,6 +163,7 @@ export const Editor = () => {
|
||||||
<span class={styles.tabLabel}>{tab.label}</span>
|
<span class={styles.tabLabel}>{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{currentStory && (
|
||||||
<button
|
<button
|
||||||
class={clsx(styles.tab, styles.tabRight, chatOpen && styles.active)}
|
class={clsx(styles.tab, styles.tabRight, chatOpen && styles.active)}
|
||||||
onClick={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })}
|
onClick={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })}
|
||||||
|
|
@ -150,6 +172,7 @@ export const Editor = () => {
|
||||||
<MessageSquare size={15} />
|
<MessageSquare size={15} />
|
||||||
<span class={styles.tabLabel}>Chat</span>
|
<span class={styles.tabLabel}>Chat</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,24 @@ import LLM from "../utils/llm";
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
|
|
||||||
export const LocationEditor = () => {
|
export const LocationEditor = () => {
|
||||||
const { currentStory, dispatch, connection, model } = useAppState();
|
const { currentWorld, currentStory, dispatch, connection, model } = useAppState();
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||||
const [generatingShortDesc, setGeneratingShortDesc] = useState<string | null>(null);
|
const [generatingShortDesc, setGeneratingShortDesc] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!currentStory) {
|
if (!currentWorld) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a story is selected, edit story-level locations; otherwise world-level
|
||||||
|
const storyId = currentStory?.id ?? null;
|
||||||
|
const worldId = currentWorld.id;
|
||||||
|
const locations = currentStory ? currentStory.locations : currentWorld.locations;
|
||||||
|
|
||||||
const handleAddLocation = () => {
|
const handleAddLocation = () => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'ADD_LOCATION',
|
type: 'ADD_LOCATION',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
location: {
|
location: {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: 'New Location',
|
name: 'New Location',
|
||||||
|
|
@ -30,7 +36,8 @@ export const LocationEditor = () => {
|
||||||
const handleEditLocation = (locationId: string, field: keyof Location, value: any) => {
|
const handleEditLocation = (locationId: string, field: keyof Location, value: any) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'EDIT_LOCATION',
|
type: 'EDIT_LOCATION',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
locationId,
|
locationId,
|
||||||
updates: { [field]: value },
|
updates: { [field]: value },
|
||||||
});
|
});
|
||||||
|
|
@ -39,7 +46,8 @@ export const LocationEditor = () => {
|
||||||
const handleDeleteLocation = (locationId: string) => {
|
const handleDeleteLocation = (locationId: string) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'DELETE_LOCATION',
|
type: 'DELETE_LOCATION',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
locationId,
|
locationId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -47,7 +55,7 @@ export const LocationEditor = () => {
|
||||||
const handleGenerateShortDescription = async (locationId: string) => {
|
const handleGenerateShortDescription = async (locationId: string) => {
|
||||||
if (!connection || !model) return;
|
if (!connection || !model) return;
|
||||||
|
|
||||||
const location = currentStory.locations.find(l => l.id === locationId);
|
const location = locations.find(l => l.id === locationId);
|
||||||
if (!location || !location.description.trim()) return;
|
if (!location || !location.description.trim()) return;
|
||||||
|
|
||||||
setGeneratingShortDesc(locationId);
|
setGeneratingShortDesc(locationId);
|
||||||
|
|
@ -64,18 +72,18 @@ export const LocationEditor = () => {
|
||||||
return (
|
return (
|
||||||
<div class={styles.locationEditor}>
|
<div class={styles.locationEditor}>
|
||||||
<div class={styles.header}>
|
<div class={styles.header}>
|
||||||
<h2>Locations</h2>
|
<h2>{currentStory ? 'Story Locations' : 'World Locations'}</h2>
|
||||||
<button class={styles.addButton} onClick={handleAddLocation}>
|
<button class={styles.addButton} onClick={handleAddLocation}>
|
||||||
+ Add Location
|
+ Add Location
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={styles.list}>
|
<div class={styles.list}>
|
||||||
{currentStory.locations.length === 0 && (
|
{locations.length === 0 && (
|
||||||
<p class={styles.empty}>No locations yet. Add your first location!</p>
|
<p class={styles.empty}>No locations yet. Add your first location!</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStory.locations.map((location) => (
|
{locations.map((location) => (
|
||||||
<div key={location.id} class={styles.locationCard}>
|
<div key={location.id} class={styles.locationCard}>
|
||||||
<div class={styles.cardHeader}>
|
<div class={styles.cardHeader}>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,26 @@ import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
|
|
||||||
export const LoreEditor = () => {
|
export const LoreEditor = () => {
|
||||||
const { currentStory, dispatch } = useAppState();
|
const { currentWorld, currentStory, dispatch } = useAppState();
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [newTitle, setNewTitle] = useState('');
|
const [newTitle, setNewTitle] = useState('');
|
||||||
|
|
||||||
if (!currentStory) {
|
if (!currentWorld) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a story is selected, edit story-level lore; otherwise world-level lore
|
||||||
|
const storyId = currentStory?.id ?? null;
|
||||||
|
const worldId = currentWorld.id;
|
||||||
|
const lore = currentStory ? currentStory.lore : currentWorld.lore;
|
||||||
|
|
||||||
const handleAddEntry = () => {
|
const handleAddEntry = () => {
|
||||||
if (!newTitle.trim()) return;
|
if (!newTitle.trim()) return;
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'ADD_LORE_ENTRY',
|
type: 'ADD_LORE_ENTRY',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
entry: {
|
entry: {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: newTitle.trim(),
|
title: newTitle.trim(),
|
||||||
|
|
@ -31,7 +37,8 @@ export const LoreEditor = () => {
|
||||||
const handleEditEntry = (entryId: string, field: keyof LoreEntry, value: string) => {
|
const handleEditEntry = (entryId: string, field: keyof LoreEntry, value: string) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'EDIT_LORE_ENTRY',
|
type: 'EDIT_LORE_ENTRY',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
entryId,
|
entryId,
|
||||||
updates: { [field]: value },
|
updates: { [field]: value },
|
||||||
});
|
});
|
||||||
|
|
@ -40,29 +47,32 @@ export const LoreEditor = () => {
|
||||||
const handleDeleteEntry = (entryId: string) => {
|
const handleDeleteEntry = (entryId: string) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'DELETE_LORE_ENTRY',
|
type: 'DELETE_LORE_ENTRY',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
entryId,
|
entryId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveUp = (index: number) => {
|
const handleMoveUp = (index: number) => {
|
||||||
if (index === 0) return;
|
if (index === 0) return;
|
||||||
const entryIds = currentStory.lore.map(e => e.id);
|
const entryIds = lore.map(e => e.id);
|
||||||
[entryIds[index - 1], entryIds[index]] = [entryIds[index], entryIds[index - 1]];
|
[entryIds[index - 1], entryIds[index]] = [entryIds[index], entryIds[index - 1]];
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'REORDER_LORE_ENTRIES',
|
type: 'REORDER_LORE_ENTRIES',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
entryIds,
|
entryIds,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveDown = (index: number) => {
|
const handleMoveDown = (index: number) => {
|
||||||
if (index === currentStory.lore.length - 1) return;
|
if (index === lore.length - 1) return;
|
||||||
const entryIds = currentStory.lore.map(e => e.id);
|
const entryIds = lore.map(e => e.id);
|
||||||
[entryIds[index], entryIds[index + 1]] = [entryIds[index + 1], entryIds[index]];
|
[entryIds[index], entryIds[index + 1]] = [entryIds[index + 1], entryIds[index]];
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'REORDER_LORE_ENTRIES',
|
type: 'REORDER_LORE_ENTRIES',
|
||||||
storyId: currentStory.id,
|
worldId,
|
||||||
|
storyId,
|
||||||
entryIds,
|
entryIds,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -70,7 +80,7 @@ export const LoreEditor = () => {
|
||||||
return (
|
return (
|
||||||
<div class={styles.loreEditor}>
|
<div class={styles.loreEditor}>
|
||||||
<div class={styles.header}>
|
<div class={styles.header}>
|
||||||
<h2>Lore</h2>
|
<h2>{currentStory ? 'Story Lore' : 'World Lore'}</h2>
|
||||||
<div class={styles.addEntry}>
|
<div class={styles.addEntry}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -92,11 +102,11 @@ export const LoreEditor = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={styles.list}>
|
<div class={styles.list}>
|
||||||
{currentStory.lore.length === 0 && (
|
{lore.length === 0 && (
|
||||||
<p class={styles.empty}>No lore entries yet. Add your first entry!</p>
|
<p class={styles.empty}>No lore entries yet. Add your first entry!</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStory.lore.map((entry, index) => (
|
{lore.map((entry, index) => (
|
||||||
<div key={entry.id} class={styles.entryCard}>
|
<div key={entry.id} class={styles.entryCard}>
|
||||||
<div class={styles.cardHeader}>
|
<div class={styles.cardHeader}>
|
||||||
<div class={styles.titleRow}>
|
<div class={styles.titleRow}>
|
||||||
|
|
@ -139,7 +149,7 @@ export const LoreEditor = () => {
|
||||||
<button
|
<button
|
||||||
class={styles.moveButton}
|
class={styles.moveButton}
|
||||||
onClick={() => handleMoveDown(index)}
|
onClick={() => handleMoveDown(index)}
|
||||||
disabled={index === currentStory.lore.length - 1}
|
disabled={index === lore.length - 1}
|
||||||
title="Move down"
|
title="Move down"
|
||||||
>
|
>
|
||||||
↓
|
↓
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,43 @@ import { SettingsModal } from "./settings-modal";
|
||||||
import { useAppState } from "../contexts/state";
|
import { useAppState } from "../contexts/state";
|
||||||
import { useBool } from "@common/hooks/useBool";
|
import { useBool } from "@common/hooks/useBool";
|
||||||
import { useInputState } from "@common/hooks/useInputState";
|
import { useInputState } from "@common/hooks/useInputState";
|
||||||
import type { Story } from "../contexts/state";
|
import type { World, Story } from "../contexts/state";
|
||||||
import styles from '../assets/menu.module.css';
|
import styles from '../assets/menu.module.css';
|
||||||
import { Pencil, X, Plus, Plug, Settings, Copy } from "lucide-preact";
|
import { Pencil, X, Plus, Plug, Settings, Copy, ChevronRight, ChevronDown, Globe } from "lucide-preact";
|
||||||
|
|
||||||
|
// ─── Inline Rename Input ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RenameInputProps {
|
||||||
|
value: string;
|
||||||
|
onSubmit: (title: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RenameInput = ({ value, onSubmit, onCancel, className }: RenameInputProps) => {
|
||||||
|
const [editTitle, setEditTitle] = useInputState(value);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (editTitle.trim()) onSubmit(editTitle.trim());
|
||||||
|
else onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') handleSubmit();
|
||||||
|
else if (e.key === 'Escape') onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
class={clsx(styles.input, className)}
|
||||||
|
value={editTitle}
|
||||||
|
onInput={setEditTitle}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleSubmit}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Story Item ───────────────────────────────────────────────────────────────
|
// ─── Story Item ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -21,45 +55,21 @@ interface StoryItemProps {
|
||||||
|
|
||||||
const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }: StoryItemProps) => {
|
const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }: StoryItemProps) => {
|
||||||
const isEditing = useBool(false);
|
const isEditing = useBool(false);
|
||||||
const [editTitle, setEditTitle] = useInputState(story.title);
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (editTitle.trim()) {
|
|
||||||
onRename(editTitle.trim());
|
|
||||||
}
|
|
||||||
isEditing.setFalse();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleSubmit();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setEditTitle(story.title);
|
|
||||||
isEditing.setFalse();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
handleSubmit();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
return (
|
return (
|
||||||
<div class={clsx(styles.itemWrapper, active && styles.active)}>
|
<div class={clsx(styles.itemWrapper, styles.storyItem, active && styles.active)}>
|
||||||
<input
|
<RenameInput
|
||||||
class={styles.input}
|
value={story.title}
|
||||||
value={editTitle}
|
onSubmit={(t) => { onRename(t); isEditing.setFalse(); }}
|
||||||
onInput={setEditTitle}
|
onCancel={isEditing.setFalse}
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={clsx(styles.itemWrapper, active && styles.active)}>
|
<div class={clsx(styles.itemWrapper, styles.storyItem, active && styles.active)}>
|
||||||
<button
|
<button
|
||||||
class={clsx(styles.item, active && styles.active)}
|
class={clsx(styles.item, active && styles.active)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
|
@ -82,52 +92,167 @@ const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate }:
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── World Item ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface WorldItemProps {
|
||||||
|
world: World;
|
||||||
|
activeWorldId: string | null;
|
||||||
|
activeStoryId: string | null;
|
||||||
|
onSelectWorld: () => void;
|
||||||
|
onRenameWorld: (title: string) => void;
|
||||||
|
onDeleteWorld: () => void;
|
||||||
|
onCreateStory: () => void;
|
||||||
|
onSelectStory: (storyId: string) => void;
|
||||||
|
onRenameStory: (storyId: string, title: string) => void;
|
||||||
|
onDeleteStory: (storyId: string) => void;
|
||||||
|
onDuplicateStory: (storyId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorldItem = ({
|
||||||
|
world, activeWorldId, activeStoryId,
|
||||||
|
onSelectWorld, onRenameWorld, onDeleteWorld, onCreateStory,
|
||||||
|
onSelectStory, onRenameStory, onDeleteStory, onDuplicateStory,
|
||||||
|
}: WorldItemProps) => {
|
||||||
|
const isRenaming = useBool(false);
|
||||||
|
const isExpanded = useBool(activeWorldId === world.id);
|
||||||
|
|
||||||
|
const isWorldActive = activeWorldId === world.id && !activeStoryId;
|
||||||
|
|
||||||
|
const toggleExpand = (e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
isExpanded.toggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles.worldGroup}>
|
||||||
|
{isRenaming.value ? (
|
||||||
|
<div class={clsx(styles.itemWrapper, isWorldActive && styles.active)}>
|
||||||
|
<RenameInput
|
||||||
|
value={world.title}
|
||||||
|
onSubmit={(t) => { onRenameWorld(t); isRenaming.setFalse(); }}
|
||||||
|
onCancel={isRenaming.setFalse}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class={clsx(styles.itemWrapper, isWorldActive && styles.active)}>
|
||||||
|
<button class={styles.expandButton} onClick={toggleExpand} title={isExpanded.value ? 'Collapse' : 'Expand'}>
|
||||||
|
{isExpanded.value ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={clsx(styles.item, styles.worldTitle, isWorldActive && styles.active)}
|
||||||
|
onClick={onSelectWorld}
|
||||||
|
onDblClick={isRenaming.setTrue}
|
||||||
|
>
|
||||||
|
<Globe size={13} />
|
||||||
|
{world.title}
|
||||||
|
</button>
|
||||||
|
<div class={styles.actions}>
|
||||||
|
<button class={styles.actionButton} onClick={onCreateStory} title="New Story">
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
<button class={styles.actionButton} onClick={isRenaming.setTrue} title="Rename">
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button class={styles.actionButton} onClick={onDeleteWorld} title="Delete World">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isExpanded.value && (
|
||||||
|
<div class={styles.storiesList}>
|
||||||
|
{world.stories.map(story => (
|
||||||
|
<StoryItem
|
||||||
|
key={story.id}
|
||||||
|
story={story}
|
||||||
|
active={activeStoryId === story.id && activeWorldId === world.id}
|
||||||
|
onSelect={() => onSelectStory(story.id)}
|
||||||
|
onRename={(title) => onRenameStory(story.id, title)}
|
||||||
|
onDelete={() => onDeleteStory(story.id)}
|
||||||
|
onDuplicate={() => onDuplicateStory(story.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{world.stories.length === 0 && (
|
||||||
|
<div class={styles.emptyStories}>No stories yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Menu Sidebar ─────────────────────────────────────────────────────────────
|
// ─── Menu Sidebar ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const Menu = () => {
|
export const Menu = () => {
|
||||||
const { stories, currentStory, dispatch } = useAppState();
|
const { worlds, currentWorld, currentStory, dispatch } = useAppState();
|
||||||
const isConnectionSettingsOpen = useBool(false);
|
const isConnectionSettingsOpen = useBool(false);
|
||||||
const isSettingsOpen = useBool(false);
|
const isSettingsOpen = useBool(false);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreateWorld = () => {
|
||||||
dispatch({ type: 'CREATE_STORY', title: 'New Story' });
|
dispatch({ type: 'CREATE_WORLD', title: 'New World' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (id: string) => {
|
const handleSelectWorld = (worldId: string) => {
|
||||||
dispatch({ type: 'SELECT_STORY', id });
|
dispatch({ type: 'SELECT_WORLD', worldId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRename = (id: string, newTitle: string) => {
|
const handleRenameWorld = (worldId: string, title: string) => {
|
||||||
dispatch({ type: 'RENAME_STORY', id, title: newTitle });
|
dispatch({ type: 'RENAME_WORLD', worldId, title });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDeleteWorld = (worldId: string) => {
|
||||||
const story = stories.find(s => s.id === id);
|
const world = worlds.find(w => w.id === worldId);
|
||||||
if (!story) return;
|
if (!world) return;
|
||||||
if (confirm(`Delete "${story.title}"?`)) {
|
if (confirm(`Delete world "${world.title}" and all its stories?`)) {
|
||||||
dispatch({ type: 'DELETE_STORY', id });
|
dispatch({ type: 'DELETE_WORLD', worldId });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicate = (id: string) => {
|
const handleCreateStory = (worldId: string) => {
|
||||||
dispatch({ type: 'DUPLICATE_STORY', id });
|
dispatch({ type: 'CREATE_STORY', worldId, title: 'New Story' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectStory = (worldId: string, storyId: string) => {
|
||||||
|
dispatch({ type: 'SELECT_STORY', worldId, id: storyId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameStory = (worldId: string, storyId: string, title: string) => {
|
||||||
|
dispatch({ type: 'RENAME_STORY', worldId, id: storyId, title });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteStory = (worldId: string, storyId: string) => {
|
||||||
|
const world = worlds.find(w => w.id === worldId);
|
||||||
|
const story = world?.stories.find(s => s.id === storyId);
|
||||||
|
if (!story) return;
|
||||||
|
if (confirm(`Delete "${story.title}"?`)) {
|
||||||
|
dispatch({ type: 'DELETE_STORY', worldId, id: storyId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicateStory = (worldId: string, storyId: string) => {
|
||||||
|
dispatch({ type: 'DUPLICATE_STORY', worldId, id: storyId });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.menu}>
|
<div class={styles.menu}>
|
||||||
<button class={styles.newButton} onClick={handleCreate}>
|
<button class={styles.newButton} onClick={handleCreateWorld}>
|
||||||
<Plus size={16} /> New Story
|
<Plus size={16} /> New World
|
||||||
</button>
|
</button>
|
||||||
<div class={styles.list}>
|
<div class={styles.list}>
|
||||||
{stories.map(story => (
|
{worlds.map(world => (
|
||||||
<StoryItem
|
<WorldItem
|
||||||
key={story.id}
|
key={world.id}
|
||||||
story={story}
|
world={world}
|
||||||
active={story.id === currentStory?.id}
|
activeWorldId={currentWorld?.id ?? null}
|
||||||
onSelect={() => handleSelect(story.id)}
|
activeStoryId={currentStory?.id ?? null}
|
||||||
onRename={(newTitle) => handleRename(story.id, newTitle)}
|
onSelectWorld={() => handleSelectWorld(world.id)}
|
||||||
onDelete={() => handleDelete(story.id)}
|
onRenameWorld={(title) => handleRenameWorld(world.id, title)}
|
||||||
onDuplicate={() => handleDuplicate(story.id)}
|
onDeleteWorld={() => handleDeleteWorld(world.id)}
|
||||||
|
onCreateStory={() => handleCreateStory(world.id)}
|
||||||
|
onSelectStory={(storyId) => handleSelectStory(world.id, storyId)}
|
||||||
|
onRenameStory={(storyId, title) => handleRenameStory(world.id, storyId, title)}
|
||||||
|
onDeleteStory={(storyId) => handleDeleteStory(world.id, storyId)}
|
||||||
|
onDuplicateStory={(storyId) => handleDuplicateStory(world.id, storyId)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { createContext } from "preact";
|
import { createContext } from "preact";
|
||||||
import { useContext, useMemo } from "preact/hooks";
|
import { useContext, useMemo } from "preact/hooks";
|
||||||
|
import { useRemoteReducer } from "@common/hooks/useRemote";
|
||||||
|
|
||||||
import LLM from "../utils/llm";
|
import LLM from "../utils/llm";
|
||||||
import Chapters from "../utils/chapters";
|
import Chapters from "../utils/chapters";
|
||||||
import { useRemoteReducer } from "@common/hooks/useRemote";
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -77,10 +77,20 @@ export interface Story {
|
||||||
lastEditedText?: string;
|
lastEditedText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface World {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
lore: LoreEntry[];
|
||||||
|
characters: Character[];
|
||||||
|
locations: Location[];
|
||||||
|
stories: Story[];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
stories: Story[];
|
worlds: World[];
|
||||||
|
currentWorldId: string | null;
|
||||||
currentStoryId: string | null;
|
currentStoryId: string | null;
|
||||||
currentTab: Tab;
|
currentTab: Tab;
|
||||||
chatOpen: boolean;
|
chatOpen: boolean;
|
||||||
|
|
@ -94,42 +104,85 @@ interface IState {
|
||||||
// ─── Actions ─────────────────────────────────────────────────────────────────
|
// ─── Actions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: 'CREATE_STORY'; title: string }
|
// World actions
|
||||||
| { type: 'RENAME_STORY'; id: string; title: string }
|
| { type: 'CREATE_WORLD'; title: string }
|
||||||
| { type: 'EDIT_STORY'; id: string; text: string; highlightText?: string }
|
| { type: 'RENAME_WORLD'; worldId: string; title: string }
|
||||||
| { type: 'EDIT_SCRATCHPAD'; id: string; text: string }
|
| { type: 'DELETE_WORLD'; worldId: string }
|
||||||
| { type: 'ADD_LORE_ENTRY'; storyId: string; entry: LoreEntry }
|
| { type: 'SELECT_WORLD'; worldId: string }
|
||||||
| { type: 'EDIT_LORE_ENTRY'; storyId: string; entryId: string; updates: Partial<LoreEntry> }
|
// Story actions
|
||||||
| { type: 'DELETE_LORE_ENTRY'; storyId: string; entryId: string }
|
| { type: 'CREATE_STORY'; worldId: string; title: string }
|
||||||
| { type: 'REORDER_LORE_ENTRIES'; storyId: string; entryIds: string[] }
|
| { type: 'RENAME_STORY'; worldId: string; id: string; title: string }
|
||||||
|
| { type: 'EDIT_STORY'; worldId: string; id: string; text: string; highlightText?: string }
|
||||||
|
| { type: 'EDIT_SCRATCHPAD'; worldId: string; id: string; text: string }
|
||||||
|
| { type: 'DELETE_STORY'; worldId: string; id: string }
|
||||||
|
| { type: 'SELECT_STORY'; worldId: string; id: string }
|
||||||
|
| { type: 'DUPLICATE_STORY'; worldId: string; id: string }
|
||||||
|
// Story lore
|
||||||
|
| { type: 'ADD_LORE_ENTRY'; worldId: string; storyId: string | null; entry: LoreEntry }
|
||||||
|
| { type: 'EDIT_LORE_ENTRY'; worldId: string; storyId: string | null; entryId: string; updates: Partial<LoreEntry> }
|
||||||
|
| { type: 'DELETE_LORE_ENTRY'; worldId: string; storyId: string | null; entryId: string }
|
||||||
|
| { type: 'REORDER_LORE_ENTRIES'; worldId: string; storyId: string | null; entryIds: string[] }
|
||||||
|
// Settings
|
||||||
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
|
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
|
||||||
| { type: 'SET_CURRENT_TAB'; tab: Tab }
|
| { type: 'SET_CURRENT_TAB'; tab: Tab }
|
||||||
| { type: 'SET_CHAT_OPEN'; open: boolean }
|
| { type: 'SET_CHAT_OPEN'; open: boolean }
|
||||||
| { type: 'DELETE_STORY'; id: string }
|
// Chat
|
||||||
| { type: 'SELECT_STORY'; id: string }
|
| { type: 'ADD_CHAT_MESSAGE'; worldId: string; storyId: string; message: ChatMessage }
|
||||||
| { type: 'DUPLICATE_STORY'; id: string }
|
| { type: 'CLEAR_CHAT'; worldId: string; storyId: string }
|
||||||
| { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage }
|
// Connection
|
||||||
| { type: 'CLEAR_CHAT'; storyId: string }
|
|
||||||
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
|
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
|
||||||
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null }
|
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null }
|
||||||
| { type: 'SET_ENABLE_THINKING'; enable: boolean }
|
| { type: 'SET_ENABLE_THINKING'; enable: boolean }
|
||||||
| { type: 'SET_BANNED_TOKENS'; tokens: string[] }
|
| { type: 'SET_BANNED_TOKENS'; tokens: string[] }
|
||||||
| { type: 'ADD_CHARACTER'; storyId: string; character: Character }
|
// Characters
|
||||||
| { type: 'EDIT_CHARACTER'; storyId: string; characterId: string; updates: Partial<Omit<Character, 'id' | 'name' | 'relations'>> }
|
| { type: 'ADD_CHARACTER'; worldId: string; storyId: string | null; character: Character }
|
||||||
| { type: 'DELETE_CHARACTER'; storyId: string; characterId: string }
|
| { type: 'EDIT_CHARACTER'; worldId: string; storyId: string | null; characterId: string; updates: Partial<Omit<Character, 'id' | 'name' | 'relations'>> }
|
||||||
| { type: 'ADD_CHARACTER_RELATION'; storyId: string; characterId: string; relation: Character['relations'][number] }
|
| { type: 'DELETE_CHARACTER'; worldId: string; storyId: string | null; characterId: string }
|
||||||
| { type: 'EDIT_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string; updates: Partial<Character['relations'][number]> }
|
| { type: 'ADD_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; relation: Character['relations'][number] }
|
||||||
| { type: 'DELETE_CHARACTER_RELATION'; storyId: string; characterId: string; targetName: string }
|
| { type: 'EDIT_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; targetName: string; updates: Partial<Character['relations'][number]> }
|
||||||
| { type: 'ADD_LOCATION'; storyId: string; location: Location }
|
| { type: 'DELETE_CHARACTER_RELATION'; worldId: string; storyId: string | null; characterId: string; targetName: string }
|
||||||
| { type: 'EDIT_LOCATION'; storyId: string; locationId: string; updates: Partial<Location> }
|
// Locations
|
||||||
| { type: 'DELETE_LOCATION'; storyId: string; locationId: string }
|
| { type: 'ADD_LOCATION'; worldId: string; storyId: string | null; location: Location }
|
||||||
| { type: 'STORE_CHAPTER_SUMMARY'; storyId: string; header: string; hash: Chapters.Hash; summary: string }
|
| { type: 'EDIT_LOCATION'; worldId: string; storyId: string | null; locationId: string; updates: Partial<Location> }
|
||||||
| { type: 'CLEAN_CHAPTER_SUMMARIES'; storyId: string; validHashes: Record<string, Chapters.Hash[]> };
|
| { type: 'DELETE_LOCATION'; worldId: string; storyId: string | null; locationId: string }
|
||||||
|
// Chapters
|
||||||
|
| { type: 'STORE_CHAPTER_SUMMARY'; worldId: string; storyId: string; header: string; hash: Chapters.Hash; summary: string }
|
||||||
|
| { type: 'CLEAN_CHAPTER_SUMMARIES'; worldId: string; storyId: string; validHashes: Record<string, Chapters.Hash[]> };
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function updateWorld(state: IState, worldId: string, updater: (w: World) => World): IState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
worlds: state.worlds.map(w => w.id === worldId ? updater(w) : w),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStory(state: IState, worldId: string, storyId: string, updater: (s: Story) => Story): IState {
|
||||||
|
return updateWorld(state, worldId, w => ({
|
||||||
|
...w,
|
||||||
|
stories: w.stories.map(s => s.id === storyId ? updater(s) : s),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function updateLoreContainer(
|
||||||
|
state: IState,
|
||||||
|
worldId: string,
|
||||||
|
storyId: string | null,
|
||||||
|
updater: (c: { lore: LoreEntry[]; characters: Character[]; locations: Location[] }) => Partial<{ lore: LoreEntry[]; characters: Character[]; locations: Location[] }>
|
||||||
|
): IState {
|
||||||
|
if (storyId) {
|
||||||
|
return updateStory(state, worldId, storyId, s => ({ ...s, ...updater(s) }));
|
||||||
|
}
|
||||||
|
return updateWorld(state, worldId, w => ({ ...w, ...updater(w) }));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Initial State ───────────────────────────────────────────────────────────
|
// ─── Initial State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_STATE: IState = {
|
const DEFAULT_STATE: IState = {
|
||||||
stories: [],
|
worlds: [],
|
||||||
|
currentWorldId: null,
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
currentTab: 'menu',
|
currentTab: 'menu',
|
||||||
chatOpen: false,
|
chatOpen: false,
|
||||||
|
|
@ -165,6 +218,44 @@ The most actual state of the story is provided below, use it as ground truth.`,
|
||||||
|
|
||||||
function reducer(state: IState, action: Action): IState {
|
function reducer(state: IState, action: Action): IState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
case 'CREATE_WORLD': {
|
||||||
|
const world: World = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: action.title,
|
||||||
|
lore: [],
|
||||||
|
characters: [],
|
||||||
|
locations: [],
|
||||||
|
stories: [],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
worlds: [...state.worlds, world],
|
||||||
|
currentWorldId: world.id,
|
||||||
|
currentStoryId: null,
|
||||||
|
currentTab: 'lore',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'RENAME_WORLD': {
|
||||||
|
return updateWorld(state, action.worldId, w => ({ ...w, title: action.title }));
|
||||||
|
}
|
||||||
|
case 'DELETE_WORLD': {
|
||||||
|
const remaining = state.worlds.filter(w => w.id !== action.worldId);
|
||||||
|
const deletingCurrent = state.currentWorldId === action.worldId;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
worlds: remaining,
|
||||||
|
currentWorldId: deletingCurrent ? null : state.currentWorldId,
|
||||||
|
currentStoryId: deletingCurrent ? null : state.currentStoryId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'SELECT_WORLD': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentWorldId: action.worldId,
|
||||||
|
currentStoryId: null,
|
||||||
|
currentTab: 'lore',
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'CREATE_STORY': {
|
case 'CREATE_STORY': {
|
||||||
const story: Story = {
|
const story: Story = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
|
@ -178,108 +269,46 @@ function reducer(state: IState, action: Action): IState {
|
||||||
chapters: [],
|
chapters: [],
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...state,
|
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
|
||||||
stories: [...state.stories, story],
|
currentWorldId: action.worldId,
|
||||||
currentStoryId: story.id,
|
currentStoryId: story.id,
|
||||||
|
currentTab: 'story',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'RENAME_STORY': {
|
case 'RENAME_STORY': {
|
||||||
return {
|
return updateStory(state, action.worldId, action.id, s => ({ ...s, title: action.title }));
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s =>
|
|
||||||
s.id === action.id ? { ...s, title: action.title } : s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'EDIT_STORY': {
|
case 'EDIT_STORY': {
|
||||||
return {
|
return updateStory(state, action.worldId, action.id, s => ({
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s =>
|
|
||||||
s.id === action.id
|
|
||||||
? { ...s, text: action.text, lastEditedText: action.highlightText }
|
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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,
|
...s,
|
||||||
lore: s.lore.map(e =>
|
text: action.text,
|
||||||
e.id === action.entryId
|
lastEditedText: action.highlightText,
|
||||||
? { ...e, ...action.updates }
|
}));
|
||||||
: e
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
: s
|
case 'EDIT_SCRATCHPAD': {
|
||||||
),
|
return updateStory(state, action.worldId, action.id, s => ({ ...s, scratchpad: action.text }));
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'DELETE_LORE_ENTRY': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s =>
|
|
||||||
s.id === action.storyId
|
|
||||||
? { ...s, lore: s.lore.filter(e => e.id !== action.entryId) }
|
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'REORDER_LORE_ENTRIES': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s => {
|
|
||||||
if (s.id !== action.storyId) return s;
|
|
||||||
const entryMap = new Map(s.lore.map(e => [e.id, e]));
|
|
||||||
const reordered = action.entryIds
|
|
||||||
.map(id => entryMap.get(id))
|
|
||||||
.filter((e): e is LoreEntry => e !== undefined);
|
|
||||||
// Add any entries that weren't in the new order (safety)
|
|
||||||
for (const entry of s.lore) {
|
|
||||||
if (!action.entryIds.includes(entry.id)) {
|
|
||||||
reordered.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...s, lore: reordered };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'SET_SYSTEM_INSTRUCTION': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
systemInstruction: action.systemInstruction,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'SET_CURRENT_TAB': {
|
|
||||||
return { ...state, currentTab: action.tab };
|
|
||||||
}
|
|
||||||
case 'SET_CHAT_OPEN': {
|
|
||||||
return { ...state, chatOpen: action.open };
|
|
||||||
}
|
}
|
||||||
case 'DELETE_STORY': {
|
case 'DELETE_STORY': {
|
||||||
const remaining = state.stories.filter(s => s.id !== action.id);
|
|
||||||
const deletingCurrent = state.currentStoryId === action.id;
|
const deletingCurrent = state.currentStoryId === action.id;
|
||||||
return {
|
return {
|
||||||
...state,
|
...updateWorld(state, action.worldId, w => ({
|
||||||
stories: remaining,
|
...w,
|
||||||
|
stories: w.stories.filter(s => s.id !== action.id),
|
||||||
|
})),
|
||||||
currentStoryId: deletingCurrent ? null : state.currentStoryId,
|
currentStoryId: deletingCurrent ? null : state.currentStoryId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'SELECT_STORY': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentWorldId: action.worldId,
|
||||||
|
currentStoryId: action.id,
|
||||||
|
currentTab: 'story',
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'DUPLICATE_STORY': {
|
case 'DUPLICATE_STORY': {
|
||||||
const original = state.stories.find(s => s.id === action.id);
|
const world = state.worlds.find(w => w.id === action.worldId);
|
||||||
|
const original = world?.stories.find(s => s.id === action.id);
|
||||||
if (!original) return state;
|
if (!original) return state;
|
||||||
const newStory: Story = {
|
const newStory: Story = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
|
@ -287,241 +316,156 @@ function reducer(state: IState, action: Action): IState {
|
||||||
text: '',
|
text: '',
|
||||||
scratchpad: '',
|
scratchpad: '',
|
||||||
lore: [...original.lore],
|
lore: [...original.lore],
|
||||||
characters: original.characters,
|
characters: [...original.characters],
|
||||||
locations: original.locations,
|
locations: [...original.locations],
|
||||||
chatMessages: [],
|
chatMessages: [],
|
||||||
chapters: [],
|
chapters: [],
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...state,
|
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })),
|
||||||
stories: [...state.stories, newStory],
|
|
||||||
currentStoryId: newStory.id,
|
currentStoryId: newStory.id,
|
||||||
|
currentTab: 'story',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'SELECT_STORY': {
|
case 'ADD_LORE_ENTRY': {
|
||||||
return { ...state, currentStoryId: action.id, currentTab: 'story' };
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
|
lore: [...c.lore, action.entry],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
case 'EDIT_LORE_ENTRY': {
|
||||||
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
|
lore: c.lore.map(e => e.id === action.entryId ? { ...e, ...action.updates } : e),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
case 'DELETE_LORE_ENTRY': {
|
||||||
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
|
lore: c.lore.filter(e => e.id !== action.entryId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
case 'REORDER_LORE_ENTRIES': {
|
||||||
|
return updateLoreContainer(state, action.worldId, action.storyId, c => {
|
||||||
|
const entryMap = new Map(c.lore.map(e => [e.id, e]));
|
||||||
|
const reordered = action.entryIds
|
||||||
|
.map(id => entryMap.get(id))
|
||||||
|
.filter((e): e is LoreEntry => e !== undefined);
|
||||||
|
for (const entry of c.lore) {
|
||||||
|
if (!action.entryIds.includes(entry.id)) reordered.push(entry);
|
||||||
|
}
|
||||||
|
return { lore: reordered };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'SET_SYSTEM_INSTRUCTION': {
|
||||||
|
return { ...state, systemInstruction: action.systemInstruction };
|
||||||
|
}
|
||||||
|
case 'SET_CURRENT_TAB': {
|
||||||
|
return { ...state, currentTab: action.tab };
|
||||||
|
}
|
||||||
|
case 'SET_CHAT_OPEN': {
|
||||||
|
return { ...state, chatOpen: action.open };
|
||||||
}
|
}
|
||||||
case 'ADD_CHAT_MESSAGE': {
|
case 'ADD_CHAT_MESSAGE': {
|
||||||
return {
|
return updateStory(state, action.worldId, action.storyId, s => {
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s => {
|
|
||||||
if (s.id !== action.storyId) return s;
|
|
||||||
const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id);
|
const existingIndex = s.chatMessages.findIndex(m => m.id === action.message.id);
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
// Overwrite existing message with same id
|
|
||||||
const updatedMessages = [...s.chatMessages];
|
const updatedMessages = [...s.chatMessages];
|
||||||
updatedMessages[existingIndex] = action.message;
|
updatedMessages[existingIndex] = action.message;
|
||||||
return { ...s, chatMessages: updatedMessages };
|
return { ...s, chatMessages: updatedMessages };
|
||||||
}
|
}
|
||||||
// Append new message
|
|
||||||
return { ...s, chatMessages: [...s.chatMessages, action.message] };
|
return { ...s, chatMessages: [...s.chatMessages, action.message] };
|
||||||
}),
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'CLEAR_CHAT': {
|
case 'CLEAR_CHAT': {
|
||||||
return {
|
return updateStory(state, action.worldId, action.storyId, s => ({ ...s, chatMessages: [] }));
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s =>
|
|
||||||
s.id === action.storyId ? { ...s, chatMessages: [] } : s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'SET_CONNECTION': {
|
case 'SET_CONNECTION': {
|
||||||
return {
|
return { ...state, connection: action.connection };
|
||||||
...state,
|
|
||||||
connection: action.connection,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'SET_MODEL': {
|
case 'SET_MODEL': {
|
||||||
return {
|
return { ...state, model: action.model };
|
||||||
...state,
|
|
||||||
model: action.model,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'SET_ENABLE_THINKING': {
|
case 'SET_ENABLE_THINKING': {
|
||||||
return {
|
return { ...state, enableThinking: action.enable };
|
||||||
...state,
|
|
||||||
enableThinking: action.enable,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'SET_BANNED_TOKENS': {
|
case 'SET_BANNED_TOKENS': {
|
||||||
return {
|
return { ...state, bannedTokens: action.tokens };
|
||||||
...state,
|
|
||||||
bannedTokens: action.tokens,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'ADD_CHARACTER': {
|
case 'ADD_CHARACTER': {
|
||||||
return {
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
...state,
|
characters: [...c.characters, action.character],
|
||||||
stories: state.stories.map(s =>
|
}));
|
||||||
s.id === action.storyId
|
|
||||||
? { ...s, characters: [...s.characters, action.character] }
|
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'EDIT_CHARACTER': {
|
case 'EDIT_CHARACTER': {
|
||||||
return {
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
...state,
|
characters: c.characters.map(ch =>
|
||||||
stories: state.stories.map(s =>
|
ch.id === action.characterId ? { ...ch, ...action.updates } : ch
|
||||||
s.id === action.storyId
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
characters: s.characters.map(c =>
|
|
||||||
c.id === action.characterId
|
|
||||||
? { ...c, ...action.updates }
|
|
||||||
: c
|
|
||||||
),
|
),
|
||||||
}
|
}));
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'DELETE_CHARACTER': {
|
case 'DELETE_CHARACTER': {
|
||||||
|
return updateLoreContainer(state, action.worldId, action.storyId, c => {
|
||||||
|
const deleted = c.characters.find(ch => ch.id === action.characterId);
|
||||||
|
if (!deleted) return {};
|
||||||
return {
|
return {
|
||||||
...state,
|
characters: c.characters
|
||||||
stories: state.stories.map(s => {
|
.filter(ch => ch.id !== action.characterId)
|
||||||
if (s.id !== action.storyId) return s;
|
.map(ch => ({
|
||||||
const deletedChar = s.characters.find(c => c.id === action.characterId);
|
...ch,
|
||||||
if (!deletedChar) return s;
|
relations: ch.relations.filter(r => r.name !== deleted.name),
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
characters: s.characters
|
|
||||||
.filter(c => c.id !== action.characterId)
|
|
||||||
.map(c => ({
|
|
||||||
...c,
|
|
||||||
relations: c.relations.filter(r => r.name !== deletedChar.name),
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'ADD_CHARACTER_RELATION': {
|
case 'ADD_CHARACTER_RELATION': {
|
||||||
return {
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
...state,
|
characters: c.characters.map(ch =>
|
||||||
stories: state.stories.map(s =>
|
ch.id === action.characterId
|
||||||
s.id === action.storyId
|
? { ...ch, relations: [...ch.relations, action.relation] }
|
||||||
? {
|
: ch
|
||||||
...s,
|
|
||||||
characters: s.characters.map(c =>
|
|
||||||
c.id === action.characterId
|
|
||||||
? { ...c, relations: [...c.relations, action.relation] }
|
|
||||||
: c
|
|
||||||
),
|
),
|
||||||
}
|
}));
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'EDIT_CHARACTER_RELATION': {
|
case 'EDIT_CHARACTER_RELATION': {
|
||||||
return {
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
...state,
|
characters: c.characters.map(ch =>
|
||||||
stories: state.stories.map(s =>
|
ch.id === action.characterId
|
||||||
s.id === action.storyId
|
|
||||||
? {
|
? {
|
||||||
...s,
|
...ch,
|
||||||
characters: s.characters.map(c =>
|
relations: ch.relations.map(r =>
|
||||||
c.id === action.characterId
|
r.name === action.targetName ? { ...r, ...action.updates } : r
|
||||||
? {
|
|
||||||
...c,
|
|
||||||
relations: c.relations.map(r =>
|
|
||||||
r.name === action.targetName
|
|
||||||
? { ...r, ...action.updates }
|
|
||||||
: r
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: c
|
: ch
|
||||||
),
|
),
|
||||||
}
|
}));
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'DELETE_CHARACTER_RELATION': {
|
case 'DELETE_CHARACTER_RELATION': {
|
||||||
return {
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
...state,
|
characters: c.characters.map(ch =>
|
||||||
stories: state.stories.map(s =>
|
ch.id === action.characterId
|
||||||
s.id === action.storyId
|
? { ...ch, relations: ch.relations.filter(r => r.name !== action.targetName) }
|
||||||
? {
|
: ch
|
||||||
...s,
|
|
||||||
characters: s.characters.map(c =>
|
|
||||||
c.id === action.characterId
|
|
||||||
? { ...c, relations: c.relations.filter(r => r.name !== action.targetName) }
|
|
||||||
: c
|
|
||||||
),
|
),
|
||||||
}
|
}));
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'ADD_LOCATION': {
|
case 'ADD_LOCATION': {
|
||||||
return {
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
...state,
|
locations: [...c.locations, action.location],
|
||||||
stories: state.stories.map(s =>
|
}));
|
||||||
s.id === action.storyId
|
|
||||||
? { ...s, locations: [...s.locations, action.location] }
|
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'EDIT_LOCATION': {
|
case 'EDIT_LOCATION': {
|
||||||
return {
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
...state,
|
locations: c.locations.map(l =>
|
||||||
stories: state.stories.map(s =>
|
l.id === action.locationId ? { ...l, ...action.updates } : l
|
||||||
s.id === action.storyId
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
locations: s.locations.map(l =>
|
|
||||||
l.id === action.locationId
|
|
||||||
? { ...l, ...action.updates }
|
|
||||||
: l
|
|
||||||
),
|
),
|
||||||
}
|
}));
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'DELETE_LOCATION': {
|
case 'DELETE_LOCATION': {
|
||||||
return {
|
return updateLoreContainer(state, action.worldId, action.storyId, c => ({
|
||||||
...state,
|
locations: c.locations.filter(l => l.id !== action.locationId),
|
||||||
stories: state.stories.map(s =>
|
}));
|
||||||
s.id === action.storyId
|
|
||||||
? { ...s, locations: s.locations.filter(l => l.id !== action.locationId) }
|
|
||||||
: s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'CLEAN_CHAPTER_SUMMARIES': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s => {
|
|
||||||
if (s.id !== action.storyId) return s;
|
|
||||||
const chapters = (s.chapters ?? [])
|
|
||||||
.filter(c => action.validHashes[c.header] !== undefined)
|
|
||||||
.map(c => {
|
|
||||||
const valid = new Set(action.validHashes[c.header]);
|
|
||||||
const summaryCache = Object.fromEntries(
|
|
||||||
Object.entries(c.summaryCache).filter(([hash]) => valid.has(hash))
|
|
||||||
);
|
|
||||||
return { ...c, summaryCache };
|
|
||||||
});
|
|
||||||
return { ...s, chapters };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'EDIT_SCRATCHPAD': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s =>
|
|
||||||
s.id === action.id ? { ...s, scratchpad: action.text } : s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'STORE_CHAPTER_SUMMARY': {
|
case 'STORE_CHAPTER_SUMMARY': {
|
||||||
return {
|
return updateStory(state, action.worldId, action.storyId, s => {
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s => {
|
|
||||||
if (s.id !== action.storyId) return s;
|
|
||||||
const chapters = s.chapters ?? [];
|
const chapters = s.chapters ?? [];
|
||||||
const existing = chapters.find(c => c.header === action.header);
|
const existing = chapters.find(c => c.header === action.header);
|
||||||
const updated = existing
|
const updated = existing
|
||||||
|
|
@ -533,8 +477,21 @@ function reducer(state: IState, action: Action): IState {
|
||||||
? chapters.map(c => c.header === action.header ? updated : c)
|
? chapters.map(c => c.header === action.header ? updated : c)
|
||||||
: [...chapters, updated],
|
: [...chapters, updated],
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
};
|
}
|
||||||
|
case 'CLEAN_CHAPTER_SUMMARIES': {
|
||||||
|
return updateStory(state, action.worldId, action.storyId, s => {
|
||||||
|
const chapters = (s.chapters ?? [])
|
||||||
|
.filter(c => action.validHashes[c.header] !== undefined)
|
||||||
|
.map(c => {
|
||||||
|
const valid = new Set(action.validHashes[c.header]);
|
||||||
|
const summaryCache = Object.fromEntries(
|
||||||
|
Object.entries(c.summaryCache).filter(([hash]) => valid.has(hash))
|
||||||
|
);
|
||||||
|
return { ...c, summaryCache };
|
||||||
|
});
|
||||||
|
return { ...s, chapters };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -542,8 +499,13 @@ function reducer(state: IState, action: Action): IState {
|
||||||
// ─── Context ─────────────────────────────────────────────────────────────────
|
// ─── Context ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
stories: Story[];
|
worlds: World[];
|
||||||
|
currentWorld: World | null;
|
||||||
currentStory: Story | null;
|
currentStory: Story | null;
|
||||||
|
/** Combined lore/characters/locations: world-level merged with story-level */
|
||||||
|
mergedLore: LoreEntry[];
|
||||||
|
mergedCharacters: Character[];
|
||||||
|
mergedLocations: Location[];
|
||||||
currentTab: Tab;
|
currentTab: Tab;
|
||||||
chatOpen: boolean;
|
chatOpen: boolean;
|
||||||
connection: LLM.Connection | null;
|
connection: LLM.Connection | null;
|
||||||
|
|
@ -563,9 +525,31 @@ export const useAppState = () => useContext(StateContext);
|
||||||
export const StateContextProvider = ({ children }: { children?: any }) => {
|
export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||||
const [state, dispatch] = useRemoteReducer('storywriter.state', reducer, DEFAULT_STATE);
|
const [state, dispatch] = useRemoteReducer('storywriter.state', reducer, DEFAULT_STATE);
|
||||||
|
|
||||||
const value = useMemo<AppState>(() => ({
|
const value = useMemo<AppState>(() => {
|
||||||
stories: state.stories,
|
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
|
||||||
currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null,
|
const currentStory = currentWorld?.stories.find(s => s.id === state.currentStoryId) ?? null;
|
||||||
|
|
||||||
|
// 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,
|
currentTab: state.currentTab,
|
||||||
chatOpen: state.chatOpen,
|
chatOpen: state.chatOpen,
|
||||||
connection: state.connection,
|
connection: state.connection,
|
||||||
|
|
@ -574,7 +558,8 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||||
bannedTokens: state.bannedTokens ?? [],
|
bannedTokens: state.bannedTokens ?? [],
|
||||||
systemInstruction: state.systemInstruction ?? '',
|
systemInstruction: state.systemInstruction ?? '',
|
||||||
dispatch,
|
dispatch,
|
||||||
}), [state]);
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateContext.Provider value={value}>
|
<StateContext.Provider value={value}>
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,8 @@ namespace Prompt {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatCharactersMarkdown(state: AppState): string {
|
export function formatCharactersMarkdown(state: AppState): string {
|
||||||
const { currentStory } = state;
|
const { mergedCharacters } = state;
|
||||||
if (!currentStory || !currentStory.characters?.length) {
|
if (!mergedCharacters?.length) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,7 +185,7 @@ namespace Prompt {
|
||||||
lines.push('## Characters\n');
|
lines.push('## Characters\n');
|
||||||
|
|
||||||
// Sort characters by importance (protagonist first, cameo last)
|
// Sort characters by importance (protagonist first, cameo last)
|
||||||
const sortedCharacters = [...currentStory.characters].sort((a, b) => {
|
const sortedCharacters = [...mergedCharacters].sort((a, b) => {
|
||||||
const importanceOrder = [
|
const importanceOrder = [
|
||||||
CharacterRole.Protagonist,
|
CharacterRole.Protagonist,
|
||||||
CharacterRole.Main,
|
CharacterRole.Main,
|
||||||
|
|
@ -231,15 +231,15 @@ namespace Prompt {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLoreMarkdown(state: AppState): string {
|
export function formatLoreMarkdown(state: AppState): string {
|
||||||
const { currentStory } = state;
|
const { mergedLore } = state;
|
||||||
if (!currentStory?.lore?.length) {
|
if (!mergedLore?.length) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push('## Lore\n');
|
lines.push('## Lore\n');
|
||||||
|
|
||||||
for (const entry of currentStory.lore) {
|
for (const entry of mergedLore) {
|
||||||
lines.push(`### ${entry.title}`);
|
lines.push(`### ${entry.title}`);
|
||||||
if (entry.text) {
|
if (entry.text) {
|
||||||
lines.push(entry.text);
|
lines.push(entry.text);
|
||||||
|
|
@ -251,15 +251,15 @@ namespace Prompt {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLocationsMarkdown(state: AppState): string {
|
export function formatLocationsMarkdown(state: AppState): string {
|
||||||
const { currentStory } = state;
|
const { mergedLocations } = state;
|
||||||
if (!currentStory || !currentStory.locations?.length) {
|
if (!mergedLocations?.length) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push('## Locations\n');
|
lines.push('## Locations\n');
|
||||||
|
|
||||||
for (const location of currentStory.locations) {
|
for (const location of mergedLocations) {
|
||||||
lines.push(`### ${location.name}`);
|
lines.push(`### ${location.name}`);
|
||||||
|
|
||||||
const description = location.shortDescription || location.description;
|
const description = location.shortDescription || location.description;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export namespace Tools {
|
||||||
if (!appState.currentStory) {
|
if (!appState.currentStory) {
|
||||||
return 'Error: No story selected';
|
return 'Error: No story selected';
|
||||||
}
|
}
|
||||||
const character = appState.currentStory.characters.find(c => c.name === args.name.trim());
|
const character = appState.mergedCharacters.find(c => c.name === args.name.trim());
|
||||||
if (!character) {
|
if (!character) {
|
||||||
return `Error: Character "${args.name.trim()}" not found`;
|
return `Error: Character "${args.name.trim()}" not found`;
|
||||||
}
|
}
|
||||||
|
|
@ -50,13 +50,14 @@ export namespace Tools {
|
||||||
}),
|
}),
|
||||||
'set_character': tool({
|
'set_character': tool({
|
||||||
handler: async (args, appState) => {
|
handler: async (args, appState) => {
|
||||||
if (!appState.currentStory) {
|
if (!appState.currentStory || !appState.currentWorld) {
|
||||||
return 'Error: No story selected';
|
return 'Error: No story selected';
|
||||||
}
|
}
|
||||||
const existingCharacter = appState.currentStory.characters.find(c => c.name === args.name.trim());
|
const existingCharacter = appState.mergedCharacters.find(c => c.name === args.name.trim());
|
||||||
|
|
||||||
if (existingCharacter) {
|
if (existingCharacter) {
|
||||||
// Edit existing character
|
// Edit existing character — find which container it lives in
|
||||||
|
const inStory = appState.currentStory.characters.some(c => c.id === existingCharacter.id);
|
||||||
const definedUpdates: Partial<Character> = {};
|
const definedUpdates: Partial<Character> = {};
|
||||||
if (args.shortDescription !== undefined) {
|
if (args.shortDescription !== undefined) {
|
||||||
definedUpdates.shortDescription = args.shortDescription;
|
definedUpdates.shortDescription = args.shortDescription;
|
||||||
|
|
@ -75,25 +76,22 @@ export namespace Tools {
|
||||||
}
|
}
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'EDIT_CHARACTER',
|
type: 'EDIT_CHARACTER',
|
||||||
storyId: appState.currentStory.id,
|
worldId: appState.currentWorld.id,
|
||||||
|
storyId: inStory ? appState.currentStory.id : null,
|
||||||
characterId: existingCharacter.id,
|
characterId: existingCharacter.id,
|
||||||
updates: definedUpdates,
|
updates: definedUpdates,
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'characters' });
|
||||||
type: 'SET_CURRENT_TAB',
|
|
||||||
id: appState.currentStory.id,
|
|
||||||
tab: 'characters'
|
|
||||||
});
|
|
||||||
return `Character "${args.name.trim()}" updated successfully`;
|
return `Character "${args.name.trim()}" updated successfully`;
|
||||||
} else {
|
} else {
|
||||||
// Add new character - validate required fields
|
// Add new character — add to story level by default
|
||||||
if (!args.shortDescription) {
|
if (!args.shortDescription) {
|
||||||
return 'Error: shortDescription is required when adding a new character';
|
return 'Error: shortDescription is required when adding a new character';
|
||||||
}
|
}
|
||||||
if (args.role === undefined) {
|
if (args.role === undefined) {
|
||||||
return 'Error: role is required when adding a new character';
|
return 'Error: role is required when adding a new character';
|
||||||
}
|
}
|
||||||
const existingCharacterNames = new Set(appState.currentStory.characters.map(c => c.name));
|
const existingCharacterNames = new Set(appState.mergedCharacters.map(c => c.name));
|
||||||
const invalidRelations: string[] = [];
|
const invalidRelations: string[] = [];
|
||||||
for (const rel of args.relations || []) {
|
for (const rel of args.relations || []) {
|
||||||
if (!existingCharacterNames.has(rel.name)) {
|
if (!existingCharacterNames.has(rel.name)) {
|
||||||
|
|
@ -102,6 +100,7 @@ export namespace Tools {
|
||||||
}
|
}
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'ADD_CHARACTER',
|
type: 'ADD_CHARACTER',
|
||||||
|
worldId: appState.currentWorld.id,
|
||||||
storyId: appState.currentStory.id,
|
storyId: appState.currentStory.id,
|
||||||
character: {
|
character: {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
|
@ -113,11 +112,7 @@ export namespace Tools {
|
||||||
relations: args.relations || [],
|
relations: args.relations || [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'characters' });
|
||||||
type: 'SET_CURRENT_TAB',
|
|
||||||
id: appState.currentStory.id,
|
|
||||||
tab: 'characters'
|
|
||||||
});
|
|
||||||
let message = `Character "${args.name.trim()}" added successfully`;
|
let message = `Character "${args.name.trim()}" added successfully`;
|
||||||
if (invalidRelations.length > 0) {
|
if (invalidRelations.length > 0) {
|
||||||
message += `.\nNote: found invalid relations to non-existent characters: ${invalidRelations.join(', ')}`;
|
message += `.\nNote: found invalid relations to non-existent characters: ${invalidRelations.join(', ')}`;
|
||||||
|
|
@ -143,49 +138,43 @@ export namespace Tools {
|
||||||
}),
|
}),
|
||||||
'set_character_relation': tool({
|
'set_character_relation': tool({
|
||||||
handler: async (args, appState) => {
|
handler: async (args, appState) => {
|
||||||
if (!appState.currentStory) {
|
if (!appState.currentStory || !appState.currentWorld) {
|
||||||
return 'Error: No story selected';
|
return 'Error: No story selected';
|
||||||
}
|
}
|
||||||
const character = appState.currentStory.characters.find(c => c.name === args.character_name.trim());
|
const character = appState.mergedCharacters.find(c => c.name === args.character_name.trim());
|
||||||
if (!character) {
|
if (!character) {
|
||||||
return `Error: Character "${args.character_name.trim()}" not found`;
|
return `Error: Character "${args.character_name.trim()}" not found`;
|
||||||
}
|
}
|
||||||
const targetCharacter = appState.currentStory.characters.find(c => c.name === args.target_name.trim());
|
const targetCharacter = appState.mergedCharacters.find(c => c.name === args.target_name.trim());
|
||||||
if (!targetCharacter) {
|
if (!targetCharacter) {
|
||||||
return `Error: Target character "${args.target_name.trim()}" not found, please add it first.`;
|
return `Error: Target character "${args.target_name.trim()}" not found, please add it first.`;
|
||||||
}
|
}
|
||||||
|
const inStory = appState.currentStory.characters.some(c => c.id === character.id);
|
||||||
|
const storyId = inStory ? appState.currentStory.id : null;
|
||||||
const existingRelationIndex = character.relations.findIndex(r => r.name === args.target_name.trim());
|
const existingRelationIndex = character.relations.findIndex(r => r.name === args.target_name.trim());
|
||||||
if (existingRelationIndex !== -1) {
|
if (existingRelationIndex !== -1) {
|
||||||
// Edit existing relation
|
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'EDIT_CHARACTER_RELATION',
|
type: 'EDIT_CHARACTER_RELATION',
|
||||||
storyId: appState.currentStory.id,
|
worldId: appState.currentWorld.id,
|
||||||
|
storyId,
|
||||||
characterId: character.id,
|
characterId: character.id,
|
||||||
targetName: args.target_name.trim(),
|
targetName: args.target_name.trim(),
|
||||||
updates: { relation: args.relation.trim() },
|
updates: { relation: args.relation.trim() },
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'characters' });
|
||||||
type: 'SET_CURRENT_TAB',
|
|
||||||
id: appState.currentStory.id,
|
|
||||||
tab: 'characters'
|
|
||||||
});
|
|
||||||
return `Relation updated: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`;
|
return `Relation updated: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`;
|
||||||
} else {
|
} else {
|
||||||
// Add new relation
|
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'ADD_CHARACTER_RELATION',
|
type: 'ADD_CHARACTER_RELATION',
|
||||||
storyId: appState.currentStory.id,
|
worldId: appState.currentWorld.id,
|
||||||
|
storyId,
|
||||||
characterId: character.id,
|
characterId: character.id,
|
||||||
relation: {
|
relation: {
|
||||||
name: args.target_name.trim(),
|
name: args.target_name.trim(),
|
||||||
relation: args.relation.trim(),
|
relation: args.relation.trim(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'characters' });
|
||||||
type: 'SET_CURRENT_TAB',
|
|
||||||
id: appState.currentStory.id,
|
|
||||||
tab: 'characters'
|
|
||||||
});
|
|
||||||
return `Relation added: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`;
|
return `Relation added: "${args.character_name.trim()}" -> "${args.target_name.trim()}" (${args.relation.trim()})`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -201,7 +190,7 @@ export namespace Tools {
|
||||||
if (!appState.currentStory) {
|
if (!appState.currentStory) {
|
||||||
return 'Error: No story selected';
|
return 'Error: No story selected';
|
||||||
}
|
}
|
||||||
const location = appState.currentStory.locations.find(l => l.name === args.name.trim());
|
const location = appState.mergedLocations.find(l => l.name === args.name.trim());
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return `Error: Location "${args.name.trim()}" not found`;
|
return `Error: Location "${args.name.trim()}" not found`;
|
||||||
}
|
}
|
||||||
|
|
@ -220,12 +209,12 @@ export namespace Tools {
|
||||||
}),
|
}),
|
||||||
'set_location': tool({
|
'set_location': tool({
|
||||||
handler: async (args, appState) => {
|
handler: async (args, appState) => {
|
||||||
if (!appState.currentStory) {
|
if (!appState.currentStory || !appState.currentWorld) {
|
||||||
return 'Error: No story selected';
|
return 'Error: No story selected';
|
||||||
}
|
}
|
||||||
const existingLocation = appState.currentStory.locations.find(l => l.name === args.name.trim());
|
const existingLocation = appState.mergedLocations.find(l => l.name === args.name.trim());
|
||||||
if (existingLocation) {
|
if (existingLocation) {
|
||||||
// Edit existing location
|
const inStory = appState.currentStory.locations.some(l => l.id === existingLocation.id);
|
||||||
const definedUpdates: Partial<Location> = {};
|
const definedUpdates: Partial<Location> = {};
|
||||||
if (args.shortDescription) {
|
if (args.shortDescription) {
|
||||||
definedUpdates.shortDescription = args.shortDescription;
|
definedUpdates.shortDescription = args.shortDescription;
|
||||||
|
|
@ -238,18 +227,14 @@ export namespace Tools {
|
||||||
}
|
}
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'EDIT_LOCATION',
|
type: 'EDIT_LOCATION',
|
||||||
storyId: appState.currentStory.id,
|
worldId: appState.currentWorld.id,
|
||||||
|
storyId: inStory ? appState.currentStory.id : null,
|
||||||
locationId: existingLocation.id,
|
locationId: existingLocation.id,
|
||||||
updates: definedUpdates,
|
updates: definedUpdates,
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'locations' });
|
||||||
type: 'SET_CURRENT_TAB',
|
|
||||||
id: appState.currentStory.id,
|
|
||||||
tab: 'locations'
|
|
||||||
});
|
|
||||||
return `Location "${args.name.trim()}" updated successfully`;
|
return `Location "${args.name.trim()}" updated successfully`;
|
||||||
} else {
|
} else {
|
||||||
// Add new location - validate required fields
|
|
||||||
if (!args.shortDescription) {
|
if (!args.shortDescription) {
|
||||||
return 'Error: shortDescription is required when adding a new location';
|
return 'Error: shortDescription is required when adding a new location';
|
||||||
}
|
}
|
||||||
|
|
@ -258,6 +243,7 @@ export namespace Tools {
|
||||||
}
|
}
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'ADD_LOCATION',
|
type: 'ADD_LOCATION',
|
||||||
|
worldId: appState.currentWorld.id,
|
||||||
storyId: appState.currentStory.id,
|
storyId: appState.currentStory.id,
|
||||||
location: {
|
location: {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
|
@ -267,11 +253,7 @@ export namespace Tools {
|
||||||
scale: args.scale,
|
scale: args.scale,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'locations' });
|
||||||
type: 'SET_CURRENT_TAB',
|
|
||||||
id: appState.currentStory.id,
|
|
||||||
tab: 'locations'
|
|
||||||
});
|
|
||||||
return `Location "${args.name.trim()}" added successfully`;
|
return `Location "${args.name.trim()}" added successfully`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -287,27 +269,26 @@ export namespace Tools {
|
||||||
}),
|
}),
|
||||||
'set_lore_entry': tool({
|
'set_lore_entry': tool({
|
||||||
handler: async (args, appState) => {
|
handler: async (args, appState) => {
|
||||||
if (!appState.currentStory) {
|
if (!appState.currentStory || !appState.currentWorld) {
|
||||||
return 'Error: No story selected';
|
return 'Error: No story selected';
|
||||||
}
|
}
|
||||||
const existingEntry = appState.currentStory.lore.find(e => e.title.toLowerCase() === args.title.trim().toLowerCase());
|
const existingEntry = appState.mergedLore.find(e => e.title.toLowerCase() === args.title.trim().toLowerCase());
|
||||||
|
|
||||||
if (existingEntry) {
|
if (existingEntry) {
|
||||||
|
const inStory = appState.currentStory.lore.some(e => e.id === existingEntry.id);
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'EDIT_LORE_ENTRY',
|
type: 'EDIT_LORE_ENTRY',
|
||||||
storyId: appState.currentStory.id,
|
worldId: appState.currentWorld.id,
|
||||||
|
storyId: inStory ? appState.currentStory.id : null,
|
||||||
entryId: existingEntry.id,
|
entryId: existingEntry.id,
|
||||||
updates: { text: args.text },
|
updates: { text: args.text },
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'lore' });
|
||||||
type: 'SET_CURRENT_TAB',
|
|
||||||
id: appState.currentStory.id,
|
|
||||||
tab: 'lore'
|
|
||||||
});
|
|
||||||
return `Lore entry "${existingEntry.title}" updated successfully`;
|
return `Lore entry "${existingEntry.title}" updated successfully`;
|
||||||
} else {
|
} else {
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'ADD_LORE_ENTRY',
|
type: 'ADD_LORE_ENTRY',
|
||||||
|
worldId: appState.currentWorld.id,
|
||||||
storyId: appState.currentStory.id,
|
storyId: appState.currentStory.id,
|
||||||
entry: {
|
entry: {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
|
@ -315,11 +296,7 @@ export namespace Tools {
|
||||||
text: args.text,
|
text: args.text,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
appState.dispatch({
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab: 'lore' });
|
||||||
type: 'SET_CURRENT_TAB',
|
|
||||||
id: appState.currentStory.id,
|
|
||||||
tab: 'lore'
|
|
||||||
});
|
|
||||||
return `Lore entry "${args.title.trim()}" added successfully`;
|
return `Lore entry "${args.title.trim()}" added successfully`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -331,7 +308,7 @@ export namespace Tools {
|
||||||
}),
|
}),
|
||||||
'edit_text': tool({
|
'edit_text': tool({
|
||||||
handler: async (args, appState) => {
|
handler: async (args, appState) => {
|
||||||
if (!appState.currentStory) {
|
if (!appState.currentStory || !appState.currentWorld) {
|
||||||
return 'Error: No story selected';
|
return 'Error: No story selected';
|
||||||
}
|
}
|
||||||
const target = args.target ?? 'story';
|
const target = args.target ?? 'story';
|
||||||
|
|
@ -341,8 +318,8 @@ export namespace Tools {
|
||||||
|
|
||||||
const dispatchEdit = (text: string) => appState.dispatch(
|
const dispatchEdit = (text: string) => appState.dispatch(
|
||||||
isScratchpad
|
isScratchpad
|
||||||
? { type: 'EDIT_SCRATCHPAD', id: appState.currentStory!.id, text }
|
? { type: 'EDIT_SCRATCHPAD', worldId: appState.currentWorld!.id, id: appState.currentStory!.id, text }
|
||||||
: { type: 'EDIT_STORY', id: appState.currentStory!.id, text, highlightText: args.new_text }
|
: { type: 'EDIT_STORY', worldId: appState.currentWorld!.id, id: appState.currentStory!.id, text, highlightText: args.new_text }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Append mode: when old_text is not provided, append new_text
|
// Append mode: when old_text is not provided, append new_text
|
||||||
|
|
@ -397,7 +374,7 @@ export namespace Tools {
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchEdit(currentText + '\n' + args.new_text);
|
dispatchEdit(currentText + '\n' + args.new_text);
|
||||||
appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab });
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab });
|
||||||
let message = cropped
|
let message = cropped
|
||||||
? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nNote: The rest was cropped due to ${LINES_LIMIT} lines limit!`
|
? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nNote: The rest was cropped due to ${LINES_LIMIT} lines limit!`
|
||||||
: `Text appended to ${target} successfully.`;
|
: `Text appended to ${target} successfully.`;
|
||||||
|
|
@ -417,7 +394,7 @@ export namespace Tools {
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchEdit(currentText.replaceAll(args.old_text, args.new_text));
|
dispatchEdit(currentText.replaceAll(args.old_text, args.new_text));
|
||||||
appState.dispatch({ type: 'SET_CURRENT_TAB', id: appState.currentStory.id, tab });
|
appState.dispatch({ type: 'SET_CURRENT_TAB', tab });
|
||||||
return `${target.charAt(0).toUpperCase() + target.slice(1)} edited successfully`;
|
return `${target.charAt(0).toUpperCase() + target.slice(1)} edited successfully`;
|
||||||
},
|
},
|
||||||
description: `Replace or append text in the story or scratchpad. When old_text is omitted, appends new_text to the end. Case-sensitive. When appending to the main story, you can add no more than ${LINES_LIMIT} non-empty lines at once.`,
|
description: `Replace or append text in the story or scratchpad. When old_text is omitted, appends new_text to the end. Case-sensitive. When appending to the main story, you can add no more than ${LINES_LIMIT} non-empty lines at once.`,
|
||||||
|
|
@ -436,11 +413,11 @@ export namespace Tools {
|
||||||
|
|
||||||
const sources: { name: string; content: string }[] = [
|
const sources: { name: string; content: string }[] = [
|
||||||
{ name: 'story', content: appState.currentStory.text },
|
{ name: 'story', content: appState.currentStory.text },
|
||||||
...appState.currentStory.lore.flatMap(e => [
|
...appState.mergedLore.flatMap(e => [
|
||||||
{ name: `lore:${e.title}`, content: e.title },
|
{ name: `lore:${e.title}`, content: e.title },
|
||||||
{ name: `lore:${e.title}`, content: e.text },
|
{ name: `lore:${e.title}`, content: e.text },
|
||||||
]),
|
]),
|
||||||
...appState.currentStory.characters.flatMap(c => [
|
...appState.mergedCharacters.flatMap(c => [
|
||||||
{ name: `character:${c.name}`, content: c.name },
|
{ name: `character:${c.name}`, content: c.name },
|
||||||
{ name: `character:${c.name}`, content: c.shortDescription },
|
{ name: `character:${c.name}`, content: c.shortDescription },
|
||||||
{ name: `character:${c.name}`, content: c.description || '' },
|
{ name: `character:${c.name}`, content: c.description || '' },
|
||||||
|
|
@ -448,7 +425,7 @@ export namespace Tools {
|
||||||
{ name: `character:${c.name}`, content: `relation: ${rel.name} (${rel.relation})` }
|
{ name: `character:${c.name}`, content: `relation: ${rel.name} (${rel.relation})` }
|
||||||
)),
|
)),
|
||||||
]),
|
]),
|
||||||
...appState.currentStory.locations.flatMap(l => [
|
...appState.mergedLocations.flatMap(l => [
|
||||||
{ name: `location:${l.name}`, content: l.name },
|
{ name: `location:${l.name}`, content: l.name },
|
||||||
{ name: `location:${l.name}`, content: l.shortDescription },
|
{ name: `location:${l.name}`, content: l.shortDescription },
|
||||||
{ name: `location:${l.name}`, content: l.description || '' },
|
{ name: `location:${l.name}`, content: l.description || '' },
|
||||||
|
|
@ -486,7 +463,7 @@ export namespace Tools {
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
description: 'Search for a pattern in the story text, lore, characters, and locations',
|
description: 'Search for a pattern in the story text, lore, characters, and locations (includes world-level data)',
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
pattern: Type.String({ description: 'The JS regex pattern to search for' }),
|
pattern: Type.String({ description: 'The JS regex pattern to search for' }),
|
||||||
case_sensitive: Type.Optional(Type.Boolean({ description: 'If true, search is case-sensitive (default: false)' })),
|
case_sensitive: Type.Optional(Type.Boolean({ description: 'If true, search is case-sensitive (default: false)' })),
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ export function useChapterSummarization() {
|
||||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||||
|
|
||||||
const summarizeAll = async () => {
|
const summarizeAll = async () => {
|
||||||
const { currentStory, connection, model, dispatch } = stateRef.current;
|
const { currentWorld, currentStory, connection, model, dispatch } = stateRef.current;
|
||||||
if (!currentStory || !connection || !model || isSummarizing) return;
|
if (!currentWorld || !currentStory || !connection || !model || isSummarizing) return;
|
||||||
|
|
||||||
setIsSummarizing(true);
|
setIsSummarizing(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -34,6 +34,7 @@ export function useChapterSummarization() {
|
||||||
const newSummary = await LLM.summarize(connection, model.id, body);
|
const newSummary = await LLM.summarize(connection, model.id, body);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'STORE_CHAPTER_SUMMARY',
|
type: 'STORE_CHAPTER_SUMMARY',
|
||||||
|
worldId: currentWorld.id,
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
header: parsedChapter.header,
|
header: parsedChapter.header,
|
||||||
hash,
|
hash,
|
||||||
|
|
@ -48,6 +49,7 @@ export function useChapterSummarization() {
|
||||||
// Clean up stale cache entries
|
// Clean up stale cache entries
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'CLEAN_CHAPTER_SUMMARIES',
|
type: 'CLEAN_CHAPTER_SUMMARIES',
|
||||||
|
worldId: currentWorld.id,
|
||||||
storyId: currentStory.id,
|
storyId: currentStory.id,
|
||||||
validHashes,
|
validHashes,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue