Story details tabs
This commit is contained in:
parent
268e5cf5ea
commit
9726472a38
|
|
@ -54,6 +54,10 @@
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reasoningContent {
|
.reasoningContent {
|
||||||
|
|
@ -83,11 +87,16 @@
|
||||||
|
|
||||||
.toolBadge {
|
.toolBadge {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 8px;
|
padding: 2px 6px;
|
||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
|
||||||
|
.role & {
|
||||||
|
padding: 0 3px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
padding: 36px 0;
|
padding: 36px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
|
|
@ -17,11 +17,16 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editable {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
overflow-y: auto;
|
||||||
resize: none;
|
|
||||||
padding: 0 72px;
|
padding: 0 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
resize: none;
|
||||||
font-family: 'Georgia', serif;
|
font-family: 'Georgia', serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
line-height: 1.9;
|
line-height: 1.9;
|
||||||
|
|
@ -44,3 +49,43 @@
|
||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 200px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-top: -1px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.settingsButton {
|
.settingsButton {
|
||||||
margin-top: auto;
|
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
@ -118,3 +117,12 @@
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottomButtons {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,35 @@ import Prompt from "../utils/prompt";
|
||||||
import { Tools } from "../utils/tools";
|
import { Tools } from "../utils/tools";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
// ─── Role Header ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RoleHeaderProps {
|
||||||
|
message: ChatMessage;
|
||||||
|
chatMessages: ChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RoleHeader = ({ message, chatMessages }: RoleHeaderProps) => {
|
||||||
|
const toolName = useMemo(() => {
|
||||||
|
if (message.role !== 'tool') return;
|
||||||
|
for (const m of chatMessages.toReversed()) {
|
||||||
|
if (m.role !== 'assistant') continue;
|
||||||
|
const toolCall = m.tool_calls?.find(tc => tc.id === message.tool_call_id);
|
||||||
|
if (toolCall) return toolCall.function.name;
|
||||||
|
}
|
||||||
|
}, [message, chatMessages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={styles.role}>
|
||||||
|
{message.role}
|
||||||
|
{toolName && (
|
||||||
|
<span class={styles.toolBadge}>
|
||||||
|
{toolName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ChatSidebar = () => {
|
export const ChatSidebar = () => {
|
||||||
const appState = useAppState();
|
const appState = useAppState();
|
||||||
const { currentStory, dispatch, connection, model, enableThinking } = appState;
|
const { currentStory, dispatch, connection, model, enableThinking } = appState;
|
||||||
|
|
@ -208,7 +237,7 @@ export const ChatSidebar = () => {
|
||||||
<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}>
|
||||||
<div class={styles.role}>{message.role}</div>
|
<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}>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import { ContentEditable } from "@common/components/ContentEditable";
|
import { ContentEditable } from "@common/components/ContentEditable";
|
||||||
import { useAppState } from "../contexts/state";
|
import { useAppState, type Tab } from "../contexts/state";
|
||||||
import styles from '../assets/editor.module.css';
|
import styles from '../assets/editor.module.css';
|
||||||
import { highlight } from "../utils/highlight";
|
import { highlight } from "../utils/highlight";
|
||||||
import { useMemo } from "preact/hooks";
|
import { useMemo } from "preact/hooks";
|
||||||
|
|
||||||
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
|
{ id: "story", label: "Story" },
|
||||||
|
{ id: "lore", label: "Lore" },
|
||||||
|
{ id: "characters", label: "Characters" },
|
||||||
|
{ id: "locations", label: "Locations" },
|
||||||
|
];
|
||||||
|
|
||||||
export const Editor = () => {
|
export const Editor = () => {
|
||||||
const { currentStory, dispatch } = useAppState();
|
const { currentStory, dispatch } = useAppState();
|
||||||
|
|
||||||
|
|
@ -20,16 +27,68 @@ export const Editor = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = useMemo(() => highlight(currentStory.text), [currentStory.text]);
|
const handleLoreInput = (e: Event) => {
|
||||||
|
const lore = (e.target as HTMLElement).textContent || '';
|
||||||
|
dispatch({
|
||||||
|
type: 'EDIT_LORE',
|
||||||
|
id: currentStory.id,
|
||||||
|
lore,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabChange = (tab: Tab) => {
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_CURRENT_TAB',
|
||||||
|
id: currentStory.id,
|
||||||
|
tab,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const storyValue = useMemo(() => highlight(currentStory.text), [currentStory.text]);
|
||||||
|
const loreValue = useMemo(() => highlight(currentStory.lore), [currentStory.lore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.editor}>
|
<div class={styles.editor}>
|
||||||
<div class={styles.title}>{currentStory.title}</div>
|
<div class={styles.title}>{currentStory.title}</div>
|
||||||
<ContentEditable
|
<div class={styles.content}>
|
||||||
class={styles.editable}
|
{currentStory.currentTab === "story" && (
|
||||||
value={value}
|
<ContentEditable
|
||||||
onInput={handleInput}
|
class={styles.editable}
|
||||||
/>
|
value={storyValue}
|
||||||
|
onInput={handleInput}
|
||||||
|
placeholder="Start writing your story..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStory.currentTab === "lore" && (
|
||||||
|
<ContentEditable
|
||||||
|
class={styles.editable}
|
||||||
|
value={loreValue}
|
||||||
|
onInput={handleLoreInput}
|
||||||
|
placeholder="Add lore, world-building details, and background information..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStory.currentTab === "characters" && (
|
||||||
|
<div class={styles.placeholder}>
|
||||||
|
<p>Characters content placeholder</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentStory.currentTab === "locations" && (
|
||||||
|
<div class={styles.placeholder}>
|
||||||
|
<p>Locations content placeholder</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class={styles.tabs}>
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
class={`${styles.tab} ${currentStory.currentTab === tab.id ? styles.active : ""}`}
|
||||||
|
onClick={() => handleTabChange(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -120,9 +120,11 @@ export const MenuSidebar = () => {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button class={styles.settingsButton} onClick={isSettingsOpen.toggle}>
|
<div class={styles.bottomButtons}>
|
||||||
⚙ Settings
|
<button class={styles.settingsButton} onClick={isSettingsOpen.toggle}>
|
||||||
</button>
|
⚙ Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSettingsOpen.value && (
|
{isSettingsOpen.value && (
|
||||||
<SettingsModal onClose={isSettingsOpen.toggle} />
|
<SettingsModal onClose={isSettingsOpen.toggle} />
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,14 @@ export type ChatMessage = LLM.ChatMessage & {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Tab = "story" | "lore" | "characters" | "locations"
|
||||||
|
|
||||||
export interface Story {
|
export interface Story {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
lore: string;
|
||||||
|
currentTab: Tab;
|
||||||
chatMessages: ChatMessage[];
|
chatMessages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,7 +37,8 @@ type Action =
|
||||||
| { type: 'CREATE_STORY'; title: string }
|
| { type: 'CREATE_STORY'; title: string }
|
||||||
| { type: 'RENAME_STORY'; id: string; title: string }
|
| { type: 'RENAME_STORY'; id: string; title: string }
|
||||||
| { type: 'EDIT_STORY'; id: string; text: string }
|
| { type: 'EDIT_STORY'; id: string; text: string }
|
||||||
| { type: 'APPEND_TO_STORY'; id: string; text: string }
|
| { type: 'EDIT_LORE'; id: string; lore: string }
|
||||||
|
| { type: 'SET_CURRENT_TAB'; id: string; tab: Tab }
|
||||||
| { type: 'DELETE_STORY'; id: string }
|
| { type: 'DELETE_STORY'; id: string }
|
||||||
| { type: 'SELECT_STORY'; id: string }
|
| { type: 'SELECT_STORY'; id: string }
|
||||||
| { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage }
|
| { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage }
|
||||||
|
|
@ -61,6 +66,8 @@ function reducer(state: IState, action: Action): IState {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: action.title,
|
title: action.title,
|
||||||
text: '',
|
text: '',
|
||||||
|
lore: '',
|
||||||
|
currentTab: 'story',
|
||||||
chatMessages: [],
|
chatMessages: [],
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
|
@ -85,6 +92,22 @@ function reducer(state: IState, action: Action): IState {
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'EDIT_LORE': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stories: state.stories.map(s =>
|
||||||
|
s.id === action.id ? { ...s, lore: action.lore } : s
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'SET_CURRENT_TAB': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stories: state.stories.map(s =>
|
||||||
|
s.id === action.id ? { ...s, currentTab: action.tab } : s
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'DELETE_STORY': {
|
case 'DELETE_STORY': {
|
||||||
const remaining = state.stories.filter(s => s.id !== action.id);
|
const remaining = state.stories.filter(s => s.id !== action.id);
|
||||||
const deletingCurrent = state.currentStoryId === action.id;
|
const deletingCurrent = state.currentStoryId === action.id;
|
||||||
|
|
@ -125,14 +148,6 @@ function reducer(state: IState, action: Action): IState {
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'APPEND_TO_STORY': {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stories: state.stories.map(s =>
|
|
||||||
s.id === action.id ? { ...s, text: s.text + action.text } : s
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'SET_CONNECTION': {
|
case 'SET_CONNECTION': {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,14 @@ export namespace Tools {
|
||||||
return 'Error: No story selected';
|
return 'Error: No story selected';
|
||||||
}
|
}
|
||||||
appState.dispatch({
|
appState.dispatch({
|
||||||
type: 'APPEND_TO_STORY',
|
type: 'EDIT_STORY',
|
||||||
id: appState.currentStory.id,
|
id: appState.currentStory.id,
|
||||||
text
|
text: appState.currentStory.text + text
|
||||||
|
});
|
||||||
|
appState.dispatch({
|
||||||
|
type: 'SET_CURRENT_TAB',
|
||||||
|
id: appState.currentStory.id,
|
||||||
|
tab: 'story'
|
||||||
});
|
});
|
||||||
return 'Text appended successfully';
|
return 'Text appended successfully';
|
||||||
},
|
},
|
||||||
|
|
@ -40,6 +45,42 @@ export namespace Tools {
|
||||||
},
|
},
|
||||||
required: ['text'],
|
required: ['text'],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
'append_to_lore': {
|
||||||
|
handler: async (args, appState) => {
|
||||||
|
if (!args || typeof args !== 'object' || !('text' in args)) {
|
||||||
|
return 'Error: Missing required argument "text"';
|
||||||
|
}
|
||||||
|
const { text } = args as { text: string };
|
||||||
|
if (typeof text !== 'string') {
|
||||||
|
return 'Error: Argument "text" must be a string';
|
||||||
|
}
|
||||||
|
if (!appState.currentStory) {
|
||||||
|
return 'Error: No story selected';
|
||||||
|
}
|
||||||
|
appState.dispatch({
|
||||||
|
type: 'EDIT_LORE',
|
||||||
|
id: appState.currentStory.id,
|
||||||
|
lore: appState.currentStory.lore + text
|
||||||
|
});
|
||||||
|
appState.dispatch({
|
||||||
|
type: 'SET_CURRENT_TAB',
|
||||||
|
id: appState.currentStory.id,
|
||||||
|
tab: 'lore'
|
||||||
|
});
|
||||||
|
return 'Text appended to lore successfully';
|
||||||
|
},
|
||||||
|
description: 'Append text to the story lore',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
text: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The text to append to the lore',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['text'],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue