Chat-mode
This commit is contained in:
parent
62c71a02c3
commit
23ea3eb7db
|
|
@ -43,6 +43,11 @@
|
|||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.content.chatContent {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editable {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
|
|
|
|||
|
|
@ -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,11 +307,10 @@ export const ChatSidebar = () => {
|
|||
const isDisabled = !currentStory || !connection || !model || isLoading;
|
||||
|
||||
return (
|
||||
<Sidebar side="right" open={chatOpen} onToggle={() => dispatch({ type: 'SET_CHAT_OPEN', open: !chatOpen })} class={sidebarStyles.mobileOverlay}>
|
||||
<div class={styles.chat}>
|
||||
{!currentStory ? (
|
||||
<div class={styles.placeholder}>
|
||||
Select a story to start chatting
|
||||
Select a chat to start
|
||||
</div>
|
||||
) : !connection || !model ? (
|
||||
<div class={styles.placeholder}>
|
||||
|
|
@ -416,6 +415,7 @@ export const ChatSidebar = () => {
|
|||
>
|
||||
Send
|
||||
</button>
|
||||
{!currentWorld?.chatOnly && (
|
||||
<button
|
||||
class={styles.continueButton}
|
||||
onClick={handleContinue}
|
||||
|
|
@ -424,11 +424,32 @@ export const ChatSidebar = () => {
|
|||
>
|
||||
<ChevronsRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? <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>
|
||||
? <div class={styles.title}><WorldIcon size={24} />{currentWorld.title}</div>
|
||||
: null;
|
||||
|
||||
const isChatTab = currentTab === 'chat';
|
||||
|
||||
return (
|
||||
<div class={styles.editor}>
|
||||
{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" && (
|
||||
<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 && (
|
||||
<ChatPanel />
|
||||
)}
|
||||
</div>
|
||||
<div class={styles.tabs}>
|
||||
{tabs.filter(tab => hasSelection || tab.id === 'menu').map((tab) => (
|
||||
|
|
@ -177,7 +204,7 @@ export const Editor = () => {
|
|||
<span class={styles.tabLabel}>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{currentStory && (
|
||||
{currentStory && !isChatOnly && (
|
||||
<button
|
||||
class={clsx(styles.tab, styles.tabRight, chatOpen && styles.active)}
|
||||
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 type { World, Story } from "../contexts/state";
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -144,12 +144,12 @@ const WorldItem = ({
|
|||
onClick={onSelectWorld}
|
||||
onDblClick={isRenaming.setTrue}
|
||||
>
|
||||
<Globe size={13} />
|
||||
{world.chatOnly ? <MessagesSquare size={13} /> : <Globe size={13} />}
|
||||
{world.title}
|
||||
</button>
|
||||
<div class={styles.actions}>
|
||||
<button class={styles.actionButton} onClick={onCreateStory} title="New Story">
|
||||
<Plus size={14} />
|
||||
<button class={styles.actionButton} onClick={onCreateStory} title={world.chatOnly ? "New Chat" : "New Story"}>
|
||||
{world.chatOnly ? <MessageSquarePlus size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
<button class={styles.actionButton} onClick={onExportWorld} title="Export World">
|
||||
<Download size={14} />
|
||||
|
|
@ -196,6 +196,10 @@ export const Menu = () => {
|
|||
dispatch({ type: 'CREATE_WORLD', title: 'New World' });
|
||||
};
|
||||
|
||||
const handleCreateChatWorld = () => {
|
||||
dispatch({ type: 'CREATE_WORLD', title: 'New Chat', chatOnly: true });
|
||||
};
|
||||
|
||||
const handleSelectWorld = (worldId: string) => {
|
||||
dispatch({ type: 'SELECT_WORLD', worldId });
|
||||
};
|
||||
|
|
@ -213,7 +217,7 @@ export const Menu = () => {
|
|||
};
|
||||
|
||||
const handleCreateStory = (worldId: string) => {
|
||||
dispatch({ type: 'CREATE_STORY', worldId, title: 'New Story' });
|
||||
dispatch({ type: 'CREATE_STORY', worldId });
|
||||
};
|
||||
|
||||
const handleSelectStory = (worldId: string, storyId: string) => {
|
||||
|
|
@ -276,6 +280,9 @@ export const Menu = () => {
|
|||
<button class={styles.newButton} onClick={handleCreateWorld}>
|
||||
<Plus size={16} /> New World
|
||||
</button>
|
||||
<button class={styles.newButton} onClick={handleCreateChatWorld}>
|
||||
<MessagesSquare size={16} /> New Chat World
|
||||
</button>
|
||||
<button class={styles.newButton} onClick={handleImportWorld}>
|
||||
<Upload size={16} /> Import World
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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<AppState>(() => {
|
||||
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 {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue