Chat-mode
This commit is contained in:
parent
62c71a02c3
commit
23ea3eb7db
|
|
@ -43,6 +43,11 @@
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content.chatContent {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.editable {
|
.editable {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatSidebar = () => {
|
export const ChatPanel = () => {
|
||||||
const appState = useAppState();
|
const appState = useAppState();
|
||||||
const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen } = appState;
|
const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen } = appState;
|
||||||
const { summarizeAll, isSummarizing } = useChapterSummarization();
|
const { summarizeAll, isSummarizing } = useChapterSummarization();
|
||||||
|
|
@ -307,115 +307,115 @@ export const ChatSidebar = () => {
|
||||||
const isDisabled = !currentStory || !connection || !model || isLoading;
|
const isDisabled = !currentStory || !connection || !model || isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar side="right" open={chatOpen} onToggle={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })} class={sidebarStyles.mobileOverlay}>
|
<div class={styles.chat}>
|
||||||
<div class={styles.chat}>
|
{!currentStory ? (
|
||||||
{!currentStory ? (
|
<div class={styles.placeholder}>
|
||||||
<div class={styles.placeholder}>
|
Select a chat to start
|
||||||
Select a story to start chatting
|
</div>
|
||||||
</div>
|
) : !connection || !model ? (
|
||||||
) : !connection || !model ? (
|
<div class={styles.placeholder}>
|
||||||
<div class={styles.placeholder}>
|
{!connection ? 'Connect to an LLM server' : 'Select a model'} to start chatting
|
||||||
{!connection ? 'Connect to an LLM server' : 'Select a model'} to start chatting
|
</div>
|
||||||
</div>
|
) : currentStory.chatMessages.length === 0 ? (
|
||||||
) : currentStory.chatMessages.length === 0 ? (
|
<div class={styles.placeholder}>
|
||||||
<div class={styles.placeholder}>
|
No messages yet
|
||||||
No messages yet
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div class={styles.messages} ref={messagesRef}>
|
||||||
<div class={styles.messages} ref={messagesRef}>
|
{currentStory.chatMessages.map((message) => (
|
||||||
{currentStory.chatMessages.map((message) => (
|
<div key={message.id} class={styles.message} data-role={message.role}>
|
||||||
<div key={message.id} class={styles.message} data-role={message.role}>
|
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
|
||||||
<RoleHeader message={message} chatMessages={currentStory.chatMessages} />
|
|
||||||
|
|
||||||
{message.role === 'assistant' && message.reasoning_content && (
|
{message.role === 'assistant' && message.reasoning_content && (
|
||||||
<div class={styles.reasoningContent}>
|
<div class={styles.reasoningContent}>
|
||||||
{message.reasoning_content}
|
{message.reasoning_content}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={styles.content}
|
class={styles.content}
|
||||||
dangerouslySetInnerHTML={{ __html: highlight(message.content, false).trim() }}
|
dangerouslySetInnerHTML={{ __html: highlight(message.content, false).trim() }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{message.role === 'assistant' && message.tool_calls && (
|
{message.role === 'assistant' && message.tool_calls && (
|
||||||
<div class={styles.toolCalls}>
|
<div class={styles.toolCalls}>
|
||||||
{message.tool_calls.map((tool) => (
|
{message.tool_calls.map((tool) => (
|
||||||
<span key={tool.id} class={styles.toolBadge}>
|
<span key={tool.id} class={styles.toolBadge}>
|
||||||
{tool.function.name}
|
{tool.function.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{error && (
|
|
||||||
<div class={clsx(styles.message, styles.errorMessage)} data-role="assistant">
|
|
||||||
<div class={styles.role}>error</div>
|
|
||||||
<div class={styles.errorText}>{error}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button class={styles.clearButton} onClick={handleClear}>
|
|
||||||
Clear chat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{currentStory && (
|
|
||||||
<div class={styles.inputContainer}>
|
|
||||||
<div class={styles.optionsRow}>
|
|
||||||
<label class={styles.toggleContainer}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enableThinking}
|
|
||||||
onChange={(e) => dispatch({
|
|
||||||
type: 'SET_ENABLE_THINKING',
|
|
||||||
enable: (e.target as HTMLInputElement).checked,
|
|
||||||
})}
|
|
||||||
disabled={isDisabled}
|
|
||||||
/>
|
|
||||||
<span>Enable thinking</span>
|
|
||||||
</label>
|
|
||||||
<div class={styles.tokenCounter}>
|
|
||||||
{tokenCount && <span>{tokenCount.taken} / {tokenCount.total} tokens</span>}
|
|
||||||
<button
|
|
||||||
class={styles.summarizeButton}
|
|
||||||
onClick={summarizeAll}
|
|
||||||
disabled={isSummarizing || !currentStory || !connection || !model}
|
|
||||||
title={isSummarizing ? 'Summarizing...' : 'Summarize'}>
|
|
||||||
<Sparkles size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ContentEditable
|
))}
|
||||||
autoLines
|
{error && (
|
||||||
class={styles.input}
|
<div class={clsx(styles.message, styles.errorMessage)} data-role="assistant">
|
||||||
value={input}
|
<div class={styles.role}>error</div>
|
||||||
onInput={setInput}
|
<div class={styles.errorText}>{error}</div>
|
||||||
onKeyDown={handleKeyDown}
|
</div>
|
||||||
placeholder={
|
)}
|
||||||
isLoading
|
<button class={styles.clearButton} onClick={handleClear}>
|
||||||
? 'Generating...'
|
Clear chat
|
||||||
: isDisabled
|
</button>
|
||||||
? 'Connect to an LLM server to chat'
|
</div>
|
||||||
: 'Type a message...'}
|
)}
|
||||||
disabled={isDisabled}
|
{currentStory && (
|
||||||
/>
|
<div class={styles.inputContainer}>
|
||||||
{isLoading ? (
|
<div class={styles.optionsRow}>
|
||||||
|
<label class={styles.toggleContainer}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enableThinking}
|
||||||
|
onChange={(e) => dispatch({
|
||||||
|
type: 'SET_ENABLE_THINKING',
|
||||||
|
enable: (e.target as HTMLInputElement).checked,
|
||||||
|
})}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
<span>Enable thinking</span>
|
||||||
|
</label>
|
||||||
|
<div class={styles.tokenCounter}>
|
||||||
|
{tokenCount && <span>{tokenCount.taken} / {tokenCount.total} tokens</span>}
|
||||||
<button
|
<button
|
||||||
class={styles.stopButton}
|
class={styles.summarizeButton}
|
||||||
onClick={handleStopGeneration}
|
onClick={summarizeAll}
|
||||||
>
|
disabled={isSummarizing || !currentStory || !connection || !model}
|
||||||
Stop
|
title={isSummarizing ? 'Summarizing...' : 'Summarize'}>
|
||||||
|
<Sparkles size={14} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
</div>
|
||||||
<div class={styles.buttonRow}>
|
</div>
|
||||||
<button
|
<ContentEditable
|
||||||
class={styles.sendButton}
|
autoLines
|
||||||
onClick={handleSendMessage}
|
class={styles.input}
|
||||||
disabled={isDisabled || !input.trim()}
|
value={input}
|
||||||
>
|
onInput={setInput}
|
||||||
Send
|
onKeyDown={handleKeyDown}
|
||||||
</button>
|
placeholder={
|
||||||
|
isLoading
|
||||||
|
? 'Generating...'
|
||||||
|
: isDisabled
|
||||||
|
? 'Connect to an LLM server to chat'
|
||||||
|
: 'Type a message...'}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
{isLoading ? (
|
||||||
|
<button
|
||||||
|
class={styles.stopButton}
|
||||||
|
onClick={handleStopGeneration}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div class={styles.buttonRow}>
|
||||||
|
<button
|
||||||
|
class={styles.sendButton}
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={isDisabled || !input.trim()}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
{!currentWorld?.chatOnly && (
|
||||||
<button
|
<button
|
||||||
class={styles.continueButton}
|
class={styles.continueButton}
|
||||||
onClick={handleContinue}
|
onClick={handleContinue}
|
||||||
|
|
@ -424,11 +424,32 @@ export const ChatSidebar = () => {
|
||||||
>
|
>
|
||||||
<ChevronsRight size={14} />
|
<ChevronsRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatSidebar = () => {
|
||||||
|
const { currentWorld, chatOpen, dispatch } = useAppState();
|
||||||
|
|
||||||
|
// In chat-only worlds, chat is a full editor tab — no sidebar needed
|
||||||
|
if (currentWorld?.chatOnly) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
side="right"
|
||||||
|
open={chatOpen}
|
||||||
|
onToggle={() => dispatch({
|
||||||
|
type: 'SET_CHAT_OPEN',
|
||||||
|
open: !chatOpen,
|
||||||
|
})}
|
||||||
|
class={sidebarStyles.mobileOverlay}
|
||||||
|
>
|
||||||
|
<ChatPanel />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,12 @@ import { LocationEditor } from "./location-editor";
|
||||||
import { ChaptersEditor } from "./chapters-editor";
|
import { ChaptersEditor } from "./chapters-editor";
|
||||||
import { LoreEditor } from "./lore-editor";
|
import { LoreEditor } from "./lore-editor";
|
||||||
import { Menu } from "./menu";
|
import { Menu } from "./menu";
|
||||||
|
import { ChatPanel } from "./chat-sidebar";
|
||||||
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, Globe, BrainCircuit, type LucideIcon } from "lucide-preact";
|
import { BookOpen, List, Users, MapPin, BookMarked, FileText, Code, Layers, MessageSquare, Globe, BrainCircuit, MessagesSquare, type LucideIcon } from "lucide-preact";
|
||||||
|
|
||||||
// Tabs available when a story is selected
|
// Tabs available when a story is selected (regular world)
|
||||||
const STORY_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
|
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 },
|
||||||
|
|
@ -25,7 +26,7 @@ const STORY_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)
|
// Tabs available when only a world is selected (no story, regular world)
|
||||||
const WORLD_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
|
const WORLD_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
|
||||||
{ id: "menu", label: "Menu", icon: List },
|
{ id: "menu", label: "Menu", icon: List },
|
||||||
{ id: "lore", label: "Lore", icon: BookMarked },
|
{ id: "lore", label: "Lore", icon: BookMarked },
|
||||||
|
|
@ -34,6 +35,20 @@ const WORLD_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[
|
||||||
{ id: "system", label: "System", icon: BrainCircuit },
|
{ id: "system", label: "System", icon: BrainCircuit },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Tabs for a chat session within a chat-only world
|
||||||
|
const CHAT_STORY_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
|
||||||
|
{ id: "menu", label: "Menu", icon: List },
|
||||||
|
{ id: "chat", label: "Chat", icon: MessageSquare },
|
||||||
|
{ id: "scratchpad", label: "Scratchpad", icon: FileText, right: true },
|
||||||
|
{ id: "prompt", label: "Prompt", icon: Code },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tabs for a chat-only world with no session selected
|
||||||
|
const CHAT_WORLD_TABS: { id: Tab; label: string; icon: LucideIcon; right?: boolean }[] = [
|
||||||
|
{ id: "menu", label: "Menu", icon: List },
|
||||||
|
{ id: "system", label: "System", icon: BrainCircuit },
|
||||||
|
];
|
||||||
|
|
||||||
export const Editor = () => {
|
export const Editor = () => {
|
||||||
const appState = useAppState();
|
const appState = useAppState();
|
||||||
const { currentWorld, currentStory, currentTab, chatOpen, dispatch } = appState;
|
const { currentWorld, currentStory, currentTab, chatOpen, dispatch } = appState;
|
||||||
|
|
@ -107,21 +122,30 @@ export const Editor = () => {
|
||||||
}, [currentStory?.id, currentWorld?.id, currentTab]);
|
}, [currentStory?.id, currentWorld?.id, currentTab]);
|
||||||
|
|
||||||
const hasSelection = currentWorld !== null;
|
const hasSelection = currentWorld !== null;
|
||||||
const tabs = currentStory ? STORY_TABS : currentWorld ? WORLD_TABS : [{ id: "menu" as Tab, label: "Menu", icon: List }];
|
const isChatOnly = currentWorld?.chatOnly ?? false;
|
||||||
|
|
||||||
// Title bar: show world > story or just world
|
const tabs = currentStory
|
||||||
|
? (isChatOnly ? CHAT_STORY_TABS : STORY_TABS)
|
||||||
|
: currentWorld
|
||||||
|
? (isChatOnly ? CHAT_WORLD_TABS : WORLD_TABS)
|
||||||
|
: [{ id: "menu" as Tab, label: "Menu", icon: List }];
|
||||||
|
|
||||||
|
// Title bar: use MessagesSquare icon for chat-only worlds
|
||||||
|
const WorldIcon = isChatOnly ? MessagesSquare : Globe;
|
||||||
const titleBar = currentStory
|
const titleBar = currentStory
|
||||||
? <div class={styles.title}>
|
? <div class={styles.title}>
|
||||||
<span class={styles.titleWorld}>{currentWorld?.title}</span>
|
<span class={styles.titleWorld}>{currentWorld?.title}</span>
|
||||||
<span class={styles.titleSep}>/</span>{currentStory.title}</div>
|
<span class={styles.titleSep}>/</span>{currentStory.title}</div>
|
||||||
: currentWorld
|
: currentWorld
|
||||||
? <div class={styles.title}><Globe size={24} />{currentWorld.title}</div>
|
? <div class={styles.title}><WorldIcon size={24} />{currentWorld.title}</div>
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const isChatTab = currentTab === 'chat';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.editor}>
|
<div class={styles.editor}>
|
||||||
{titleBar}
|
{titleBar}
|
||||||
<div class={clsx(styles.content, currentTab === 'menu' && styles.menuContent)} ref={contentRef}>
|
<div class={clsx(styles.content, currentTab === 'menu' && styles.menuContent, isChatTab && styles.chatContent)} ref={contentRef}>
|
||||||
{currentTab === "menu" && (
|
{currentTab === "menu" && (
|
||||||
<Menu />
|
<Menu />
|
||||||
)}
|
)}
|
||||||
|
|
@ -164,6 +188,9 @@ export const Editor = () => {
|
||||||
placeholder="Override the global system instruction for this world. Leave empty to use the global setting."
|
placeholder="Override the global system instruction for this world. Leave empty to use the global setting."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{currentTab === "chat" && currentStory && isChatOnly && (
|
||||||
|
<ChatPanel />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.tabs}>
|
<div class={styles.tabs}>
|
||||||
{tabs.filter(tab => hasSelection || tab.id === 'menu').map((tab) => (
|
{tabs.filter(tab => hasSelection || tab.id === 'menu').map((tab) => (
|
||||||
|
|
@ -177,7 +204,7 @@ export const Editor = () => {
|
||||||
<span class={styles.tabLabel}>{tab.label}</span>
|
<span class={styles.tabLabel}>{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{currentStory && (
|
{currentStory && !isChatOnly && (
|
||||||
<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 })}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useBool } from "@common/hooks/useBool";
|
||||||
import { useInputState } from "@common/hooks/useInputState";
|
import { useInputState } from "@common/hooks/useInputState";
|
||||||
import type { World, 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, ChevronRight, ChevronDown, Globe, Download, Upload } from "lucide-preact";
|
import { Pencil, X, Plus, Plug, Settings, Copy, ChevronRight, ChevronDown, Globe, Download, Upload, MessagesSquare, MessageSquarePlus } from "lucide-preact";
|
||||||
|
|
||||||
// ─── Inline Rename Input ──────────────────────────────────────────────────────
|
// ─── Inline Rename Input ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -144,12 +144,12 @@ const WorldItem = ({
|
||||||
onClick={onSelectWorld}
|
onClick={onSelectWorld}
|
||||||
onDblClick={isRenaming.setTrue}
|
onDblClick={isRenaming.setTrue}
|
||||||
>
|
>
|
||||||
<Globe size={13} />
|
{world.chatOnly ? <MessagesSquare size={13} /> : <Globe size={13} />}
|
||||||
{world.title}
|
{world.title}
|
||||||
</button>
|
</button>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<button class={styles.actionButton} onClick={onCreateStory} title="New Story">
|
<button class={styles.actionButton} onClick={onCreateStory} title={world.chatOnly ? "New Chat" : "New Story"}>
|
||||||
<Plus size={14} />
|
{world.chatOnly ? <MessageSquarePlus size={14} /> : <Plus size={14} />}
|
||||||
</button>
|
</button>
|
||||||
<button class={styles.actionButton} onClick={onExportWorld} title="Export World">
|
<button class={styles.actionButton} onClick={onExportWorld} title="Export World">
|
||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
|
|
@ -196,6 +196,10 @@ export const Menu = () => {
|
||||||
dispatch({ type: 'CREATE_WORLD', title: 'New World' });
|
dispatch({ type: 'CREATE_WORLD', title: 'New World' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateChatWorld = () => {
|
||||||
|
dispatch({ type: 'CREATE_WORLD', title: 'New Chat', chatOnly: true });
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectWorld = (worldId: string) => {
|
const handleSelectWorld = (worldId: string) => {
|
||||||
dispatch({ type: 'SELECT_WORLD', worldId });
|
dispatch({ type: 'SELECT_WORLD', worldId });
|
||||||
};
|
};
|
||||||
|
|
@ -213,7 +217,7 @@ export const Menu = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateStory = (worldId: string) => {
|
const handleCreateStory = (worldId: string) => {
|
||||||
dispatch({ type: 'CREATE_STORY', worldId, title: 'New Story' });
|
dispatch({ type: 'CREATE_STORY', worldId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectStory = (worldId: string, storyId: string) => {
|
const handleSelectStory = (worldId: string, storyId: string) => {
|
||||||
|
|
@ -276,6 +280,9 @@ export const Menu = () => {
|
||||||
<button class={styles.newButton} onClick={handleCreateWorld}>
|
<button class={styles.newButton} onClick={handleCreateWorld}>
|
||||||
<Plus size={16} /> New World
|
<Plus size={16} /> New World
|
||||||
</button>
|
</button>
|
||||||
|
<button class={styles.newButton} onClick={handleCreateChatWorld}>
|
||||||
|
<MessagesSquare size={16} /> New Chat World
|
||||||
|
</button>
|
||||||
<button class={styles.newButton} onClick={handleImportWorld}>
|
<button class={styles.newButton} onClick={handleImportWorld}>
|
||||||
<Upload size={16} /> Import World
|
<Upload size={16} /> Import World
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
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 { useStoredReducer } from "@common/hooks/useStored";
|
||||||
|
|
||||||
import LLM from "../utils/llm";
|
import LLM from "../utils/llm";
|
||||||
import Chapters from "../utils/chapters";
|
import Chapters from "../utils/chapters";
|
||||||
|
|
@ -11,7 +11,7 @@ export type ChatMessage = LLM.ChatMessage & {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Tab = "story" | "lore" | "characters" | "locations" | "chapters" | "scratchpad" | "prompt" | "menu" | "system";
|
export type Tab = "story" | "lore" | "characters" | "locations" | "chapters" | "scratchpad" | "prompt" | "menu" | "system" | "chat";
|
||||||
|
|
||||||
export enum CharacterRole {
|
export enum CharacterRole {
|
||||||
Protagonist = 'protagonist',
|
Protagonist = 'protagonist',
|
||||||
|
|
@ -80,6 +80,7 @@ export interface Story {
|
||||||
export interface World {
|
export interface World {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
chatOnly: boolean;
|
||||||
lore: LoreEntry[];
|
lore: LoreEntry[];
|
||||||
characters: Character[];
|
characters: Character[];
|
||||||
locations: Location[];
|
locations: Location[];
|
||||||
|
|
@ -106,12 +107,12 @@ interface IState {
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
// World actions
|
// World actions
|
||||||
| { type: 'CREATE_WORLD'; title: string }
|
| { type: 'CREATE_WORLD'; title: string; chatOnly?: boolean }
|
||||||
| { type: 'RENAME_WORLD'; worldId: string; title: string }
|
| { type: 'RENAME_WORLD'; worldId: string; title: string }
|
||||||
| { type: 'DELETE_WORLD'; worldId: string }
|
| { type: 'DELETE_WORLD'; worldId: string }
|
||||||
| { type: 'SELECT_WORLD'; worldId: string }
|
| { type: 'SELECT_WORLD'; worldId: string }
|
||||||
// Story actions
|
// Story actions
|
||||||
| { type: 'CREATE_STORY'; worldId: string; title: string }
|
| { type: 'CREATE_STORY'; worldId: string }
|
||||||
| { type: 'RENAME_STORY'; worldId: string; id: string; title: string }
|
| { type: 'RENAME_STORY'; worldId: string; id: string; title: string }
|
||||||
| { type: 'EDIT_STORY'; worldId: string; id: string; text: string; highlightText?: string }
|
| { type: 'EDIT_STORY'; worldId: string; id: string; text: string; highlightText?: string }
|
||||||
| { type: 'EDIT_SCRATCHPAD'; worldId: string; id: string; text: string }
|
| { type: 'EDIT_SCRATCHPAD'; worldId: string; id: string; text: string }
|
||||||
|
|
@ -226,6 +227,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
const world: World = {
|
const world: World = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: action.title,
|
title: action.title,
|
||||||
|
chatOnly: action.chatOnly ?? false,
|
||||||
lore: [],
|
lore: [],
|
||||||
characters: [],
|
characters: [],
|
||||||
locations: [],
|
locations: [],
|
||||||
|
|
@ -236,7 +238,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
worlds: [...state.worlds, world],
|
worlds: [...state.worlds, world],
|
||||||
currentWorldId: world.id,
|
currentWorldId: world.id,
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
currentTab: 'lore',
|
currentTab: action.chatOnly ? 'system' : 'lore',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'RENAME_WORLD': {
|
case 'RENAME_WORLD': {
|
||||||
|
|
@ -253,17 +255,18 @@ function reducer(state: IState, action: Action): IState {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'SELECT_WORLD': {
|
case 'SELECT_WORLD': {
|
||||||
|
const world = state.worlds.find(w => w.id === action.worldId);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentWorldId: action.worldId,
|
currentWorldId: action.worldId,
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
currentTab: 'lore',
|
currentTab: world?.chatOnly ? 'system' : 'lore',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'CREATE_STORY': {
|
case 'CREATE_STORY': {
|
||||||
const story: Story = {
|
const story: Story = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: action.title,
|
title: '',
|
||||||
text: '',
|
text: '',
|
||||||
scratchpad: '',
|
scratchpad: '',
|
||||||
lore: [],
|
lore: [],
|
||||||
|
|
@ -272,11 +275,13 @@ function reducer(state: IState, action: Action): IState {
|
||||||
chatMessages: [],
|
chatMessages: [],
|
||||||
chapters: [],
|
chapters: [],
|
||||||
};
|
};
|
||||||
|
const world = state.worlds.find(w => w.id === action.worldId);
|
||||||
|
story.title = world?.chatOnly ? 'New chat' : 'New Story';
|
||||||
return {
|
return {
|
||||||
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
|
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })),
|
||||||
currentWorldId: action.worldId,
|
currentWorldId: action.worldId,
|
||||||
currentStoryId: story.id,
|
currentStoryId: story.id,
|
||||||
currentTab: 'story',
|
currentTab: world?.chatOnly ? 'chat' : 'story',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'RENAME_STORY': {
|
case 'RENAME_STORY': {
|
||||||
|
|
@ -303,11 +308,12 @@ function reducer(state: IState, action: Action): IState {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'SELECT_STORY': {
|
case 'SELECT_STORY': {
|
||||||
|
const world = state.worlds.find(w => w.id === action.worldId);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
currentWorldId: action.worldId,
|
currentWorldId: action.worldId,
|
||||||
currentStoryId: action.id,
|
currentStoryId: action.id,
|
||||||
currentTab: 'story',
|
currentTab: world?.chatOnly ? 'chat' : 'story',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'DUPLICATE_STORY': {
|
case 'DUPLICATE_STORY': {
|
||||||
|
|
@ -328,7 +334,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
return {
|
return {
|
||||||
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })),
|
...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })),
|
||||||
currentStoryId: newStory.id,
|
currentStoryId: newStory.id,
|
||||||
currentTab: 'story',
|
currentTab: world?.chatOnly ? 'chat' : 'story',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'ADD_LORE_ENTRY': {
|
case 'ADD_LORE_ENTRY': {
|
||||||
|
|
@ -508,7 +514,7 @@ function reducer(state: IState, action: Action): IState {
|
||||||
worlds: [...state.worlds, world],
|
worlds: [...state.worlds, world],
|
||||||
currentWorldId: world.id,
|
currentWorldId: world.id,
|
||||||
currentStoryId: null,
|
currentStoryId: null,
|
||||||
currentTab: 'lore',
|
currentTab: world.chatOnly ? 'system' : 'lore',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -543,7 +549,7 @@ export const useAppState = () => useContext(StateContext);
|
||||||
// ─── Provider ────────────────────────────────────────────────────────────────
|
// ─── Provider ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const StateContextProvider = ({ children }: { children?: any }) => {
|
export const StateContextProvider = ({ children }: { children?: any }) => {
|
||||||
const [state, dispatch] = useRemoteReducer('storywriter.state', reducer, DEFAULT_STATE);
|
const [state, dispatch] = useStoredReducer('storywriter.state', reducer, DEFAULT_STATE);
|
||||||
|
|
||||||
const value = useMemo<AppState>(() => {
|
const value = useMemo<AppState>(() => {
|
||||||
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
|
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;
|
||||||
|
|
|
||||||
|
|
@ -275,11 +275,16 @@ namespace Prompt {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
|
export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
|
||||||
const { currentStory } = state;
|
const { currentStory, currentWorld } = state;
|
||||||
if (!currentStory) {
|
if (!currentStory) {
|
||||||
return state.effectiveSystemInstruction;
|
return state.effectiveSystemInstruction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chat-only worlds: just the system instruction, no story scaffolding
|
||||||
|
if (currentWorld?.chatOnly) {
|
||||||
|
return state.effectiveSystemInstruction;
|
||||||
|
}
|
||||||
|
|
||||||
const parts: string[] = [state.effectiveSystemInstruction];
|
const parts: string[] = [state.effectiveSystemInstruction];
|
||||||
|
|
||||||
parts.push(`# Story Title: ${currentStory.title}`);
|
parts.push(`# Story Title: ${currentStory.title}`);
|
||||||
|
|
@ -351,7 +356,7 @@ namespace Prompt {
|
||||||
return {
|
return {
|
||||||
model: model.id,
|
model: model.id,
|
||||||
messages,
|
messages,
|
||||||
tools: Tools.getTools(),
|
tools: state.currentWorld?.chatOnly ? undefined : Tools.getTools(),
|
||||||
banned_tokens: state.bannedTokens,
|
banned_tokens: state.bannedTokens,
|
||||||
enable_thinking: enableThinking,
|
enable_thinking: enableThinking,
|
||||||
max_tokens: model.max_length ? model.max_length : 2048,
|
max_tokens: model.max_length ? model.max_length : 2048,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue