From 23ea3eb7dba17564cf15c7e90f4f1cc74c236314 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Tue, 7 Apr 2026 08:57:30 +0000 Subject: [PATCH] Chat-mode --- .../storywriter/assets/editor.module.css | 5 + .../storywriter/components/chat-sidebar.tsx | 239 ++++++++++-------- src/games/storywriter/components/editor.tsx | 43 +++- src/games/storywriter/components/menu.tsx | 17 +- src/games/storywriter/contexts/state.tsx | 30 ++- src/games/storywriter/utils/prompt.ts | 9 +- 6 files changed, 207 insertions(+), 136 deletions(-) diff --git a/src/games/storywriter/assets/editor.module.css b/src/games/storywriter/assets/editor.module.css index c26b3ae..180eb5e 100644 --- a/src/games/storywriter/assets/editor.module.css +++ b/src/games/storywriter/assets/editor.module.css @@ -43,6 +43,11 @@ padding-bottom: 10px; } +.content.chatContent { + padding: 0; + overflow: hidden; +} + .editable { width: 100%; min-height: 100%; diff --git a/src/games/storywriter/components/chat-sidebar.tsx b/src/games/storywriter/components/chat-sidebar.tsx index 7413e89..d7d2f52 100644 --- a/src/games/storywriter/components/chat-sidebar.tsx +++ b/src/games/storywriter/components/chat-sidebar.tsx @@ -42,7 +42,7 @@ const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => { ); }; -export const ChatSidebar = () => { +export const ChatPanel = () => { const appState = useAppState(); const { currentWorld, currentStory, dispatch, connection, model, enableThinking, chatOpen } = appState; const { summarizeAll, isSummarizing } = useChapterSummarization(); @@ -307,115 +307,115 @@ export const ChatSidebar = () => { const isDisabled = !currentStory || !connection || !model || isLoading; return ( - dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })} class={sidebarStyles.mobileOverlay}> -
- {!currentStory ? ( -
- Select a story to start chatting -
- ) : !connection || !model ? ( -
- {!connection ? 'Connect to an LLM server' : 'Select a model'} to start chatting -
- ) : currentStory.chatMessages.length === 0 ? ( -
- No messages yet -
- ) : ( -
- {currentStory.chatMessages.map((message) => ( -
- +
+ {!currentStory ? ( +
+ Select a chat to start +
+ ) : !connection || !model ? ( +
+ {!connection ? 'Connect to an LLM server' : 'Select a model'} to start chatting +
+ ) : currentStory.chatMessages.length === 0 ? ( +
+ No messages yet +
+ ) : ( +
+ {currentStory.chatMessages.map((message) => ( +
+ - {message.role === 'assistant' && message.reasoning_content && ( -
- {message.reasoning_content} -
- )} + {message.role === 'assistant' && message.reasoning_content && ( +
+ {message.reasoning_content} +
+ )} -
+
- {message.role === 'assistant' && message.tool_calls && ( -
- {message.tool_calls.map((tool) => ( - - {tool.function.name} - - ))} -
- )} -
- ))} - {error && ( -
-
error
-
{error}
-
- )} - -
- )} - {currentStory && ( -
-
- -
- {tokenCount && {tokenCount.taken} / {tokenCount.total} tokens} - -
+ {message.role === 'assistant' && message.tool_calls && ( +
+ {message.tool_calls.map((tool) => ( + + {tool.function.name} + + ))} +
+ )}
- - {isLoading ? ( + ))} + {error && ( +
+
error
+
{error}
+
+ )} + +
+ )} + {currentStory && ( +
+
+ +
+ {tokenCount && {tokenCount.taken} / {tokenCount.total} tokens} - ) : ( -
- +
+
+ + {isLoading ? ( + + ) : ( +
+ + {!currentWorld?.chatOnly && ( -
- )} -
- )} -
+ )} +
+ )} +
+ )} +
+ ); +}; + +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 ( + dispatch({ + type: 'SET_CHAT_OPEN', + open: !chatOpen, + })} + class={sidebarStyles.mobileOverlay} + > + ); }; diff --git a/src/games/storywriter/components/editor.tsx b/src/games/storywriter/components/editor.tsx index 760a5c4..0af3e99 100644 --- a/src/games/storywriter/components/editor.tsx +++ b/src/games/storywriter/components/editor.tsx @@ -9,11 +9,12 @@ import { LocationEditor } from "./location-editor"; import { ChaptersEditor } from "./chapters-editor"; import { LoreEditor } from "./lore-editor"; import { Menu } from "./menu"; +import { ChatPanel } from "./chat-sidebar"; import { useInputCallback } from "@common/hooks/useInputCallback"; 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 }[] = [ { id: "menu", label: "Menu", icon: List }, { 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 }, ]; -// 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 }[] = [ { id: "menu", label: "Menu", icon: List }, { 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 }, ]; +// 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 = () => { const appState = useAppState(); const { currentWorld, currentStory, currentTab, chatOpen, dispatch } = appState; @@ -107,21 +122,30 @@ export const Editor = () => { }, [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 }]; + 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 ?
{currentWorld?.title} /{currentStory.title}
: currentWorld - ?
{currentWorld.title}
+ ?
{currentWorld.title}
: null; + const isChatTab = currentTab === 'chat'; + return (
{titleBar} -
+
{currentTab === "menu" && ( )} @@ -164,6 +188,9 @@ export const Editor = () => { placeholder="Override the global system instruction for this world. Leave empty to use the global setting." /> )} + {currentTab === "chat" && currentStory && isChatOnly && ( + + )}
{tabs.filter(tab => hasSelection || tab.id === 'menu').map((tab) => ( @@ -177,7 +204,7 @@ export const Editor = () => { {tab.label} ))} - {currentStory && ( + {currentStory && !isChatOnly && (
- + diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index c9898e9..7201218 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -1,6 +1,6 @@ import { createContext } from "preact"; import { useContext, useMemo } from "preact/hooks"; -import { useRemoteReducer } from "@common/hooks/useRemote"; +import { useStoredReducer } from "@common/hooks/useStored"; import LLM from "../utils/llm"; import Chapters from "../utils/chapters"; @@ -11,7 +11,7 @@ export type ChatMessage = LLM.ChatMessage & { 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 { Protagonist = 'protagonist', @@ -80,6 +80,7 @@ export interface Story { export interface World { id: string; title: string; + chatOnly: boolean; lore: LoreEntry[]; characters: Character[]; locations: Location[]; @@ -106,12 +107,12 @@ interface IState { type Action = // World actions - | { type: 'CREATE_WORLD'; title: string } + | { type: 'CREATE_WORLD'; title: string; chatOnly?: boolean } | { type: 'RENAME_WORLD'; worldId: string; title: string } | { type: 'DELETE_WORLD'; worldId: string } | { type: 'SELECT_WORLD'; worldId: string } // Story actions - | { type: 'CREATE_STORY'; worldId: string; title: string } + | { type: 'CREATE_STORY'; worldId: 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 } @@ -226,6 +227,7 @@ function reducer(state: IState, action: Action): IState { const world: World = { id: crypto.randomUUID(), title: action.title, + chatOnly: action.chatOnly ?? false, lore: [], characters: [], locations: [], @@ -236,7 +238,7 @@ function reducer(state: IState, action: Action): IState { worlds: [...state.worlds, world], currentWorldId: world.id, currentStoryId: null, - currentTab: 'lore', + currentTab: action.chatOnly ? 'system' : 'lore', }; } case 'RENAME_WORLD': { @@ -253,17 +255,18 @@ function reducer(state: IState, action: Action): IState { }; } case 'SELECT_WORLD': { + const world = state.worlds.find(w => w.id === action.worldId); return { ...state, currentWorldId: action.worldId, currentStoryId: null, - currentTab: 'lore', + currentTab: world?.chatOnly ? 'system' : 'lore', }; } case 'CREATE_STORY': { const story: Story = { id: crypto.randomUUID(), - title: action.title, + title: '', text: '', scratchpad: '', lore: [], @@ -272,11 +275,13 @@ function reducer(state: IState, action: Action): IState { chatMessages: [], chapters: [], }; + const world = state.worlds.find(w => w.id === action.worldId); + story.title = world?.chatOnly ? 'New chat' : 'New Story'; return { ...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, story] })), currentWorldId: action.worldId, currentStoryId: story.id, - currentTab: 'story', + currentTab: world?.chatOnly ? 'chat' : 'story', }; } case 'RENAME_STORY': { @@ -303,11 +308,12 @@ function reducer(state: IState, action: Action): IState { }; } case 'SELECT_STORY': { + const world = state.worlds.find(w => w.id === action.worldId); return { ...state, currentWorldId: action.worldId, currentStoryId: action.id, - currentTab: 'story', + currentTab: world?.chatOnly ? 'chat' : 'story', }; } case 'DUPLICATE_STORY': { @@ -328,7 +334,7 @@ function reducer(state: IState, action: Action): IState { return { ...updateWorld(state, action.worldId, w => ({ ...w, stories: [...w.stories, newStory] })), currentStoryId: newStory.id, - currentTab: 'story', + currentTab: world?.chatOnly ? 'chat' : 'story', }; } case 'ADD_LORE_ENTRY': { @@ -508,7 +514,7 @@ function reducer(state: IState, action: Action): IState { worlds: [...state.worlds, world], currentWorldId: world.id, currentStoryId: null, - currentTab: 'lore', + currentTab: world.chatOnly ? 'system' : 'lore', }; } } @@ -543,7 +549,7 @@ export const useAppState = () => useContext(StateContext); // ─── Provider ──────────────────────────────────────────────────────────────── 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(() => { const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null; diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index 9c9de71..534646b 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -275,11 +275,16 @@ namespace Prompt { } export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string { - const { currentStory } = state; + const { currentStory, currentWorld } = state; if (!currentStory) { return state.effectiveSystemInstruction; } + // Chat-only worlds: just the system instruction, no story scaffolding + if (currentWorld?.chatOnly) { + return state.effectiveSystemInstruction; + } + const parts: string[] = [state.effectiveSystemInstruction]; parts.push(`# Story Title: ${currentStory.title}`); @@ -351,7 +356,7 @@ namespace Prompt { return { model: model.id, messages, - tools: Tools.getTools(), + tools: state.currentWorld?.chatOnly ? undefined : Tools.getTools(), banned_tokens: state.bannedTokens, enable_thinking: enableThinking, max_tokens: model.max_length ? model.max_length : 2048,