diff --git a/src/games/storywriter/assets/settings-modal.module.css b/src/games/storywriter/assets/settings-modal.module.css index a4d3dae..97a5925 100644 --- a/src/games/storywriter/assets/settings-modal.module.css +++ b/src/games/storywriter/assets/settings-modal.module.css @@ -182,6 +182,24 @@ flex: 1; } +.iconButton { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + cursor: pointer; + font-size: 16px; + min-width: 40px; + + &:hover { + background: var(--bg-hover); + } +} + .divider { height: 1px; background: var(--border); diff --git a/src/games/storywriter/components/chat-sidebar.tsx b/src/games/storywriter/components/chat-sidebar.tsx index 6892950..6b044d0 100644 --- a/src/games/storywriter/components/chat-sidebar.tsx +++ b/src/games/storywriter/components/chat-sidebar.tsx @@ -481,7 +481,7 @@ export const ChatPanel = () => {
{message.role === 'assistant' && message.tool_calls && ( diff --git a/src/games/storywriter/components/settings-modal.tsx b/src/games/storywriter/components/settings-modal.tsx index 2c60e3c..8f4b6bd 100644 --- a/src/games/storywriter/components/settings-modal.tsx +++ b/src/games/storywriter/components/settings-modal.tsx @@ -5,12 +5,13 @@ import styles from "../assets/settings-modal.module.css"; import { BannedTokensSettings } from "./settings/banned-tokens"; import { ConnectionSettings } from "./settings/connection"; import { SystemInstructionSettings } from "./settings/system-instruction"; +import { UserSettings } from "./settings/user"; interface Props { onClose: () => void; } -type Tab = "banned-tokens" | "system-instruction" | "connection"; +type Tab = "banned-tokens" | "system-instruction" | "connection" | "user"; export const SettingsModal = ({ onClose }: Props) => { const [activeTab, setActiveTab] = useState("connection"); @@ -32,6 +33,12 @@ export const SettingsModal = ({ onClose }: Props) => { > Connection +
- {activeTab === "banned-tokens" && } + {activeTab === "user" && } + {activeTab === "banned-tokens" && } {activeTab === "system-instruction" && } {activeTab === "connection" && }
diff --git a/src/games/storywriter/components/settings/banned-tokens.tsx b/src/games/storywriter/components/settings/banned-tokens.tsx index da4ed30..bb3510c 100644 --- a/src/games/storywriter/components/settings/banned-tokens.tsx +++ b/src/games/storywriter/components/settings/banned-tokens.tsx @@ -4,11 +4,7 @@ import { X } from "lucide-preact"; import styles from "../../assets/settings-modal.module.css"; import { useAppState } from "../../contexts/state"; -interface Props { - onClose: () => void; -} - -export const BannedTokensSettings = ({ onClose }: Props) => { +export const BannedTokensSettings = () => { const { bannedTokens, dispatch } = useAppState(); const [inputValue, setInputValue] = useInputState(); @@ -30,7 +26,6 @@ export const BannedTokensSettings = ({ onClose }: Props) => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter") handleAdd(); - else if (e.key === "Escape") onClose(); }; return ( diff --git a/src/games/storywriter/components/settings/connection.tsx b/src/games/storywriter/components/settings/connection.tsx index f0a3ce5..6f50a01 100644 --- a/src/games/storywriter/components/settings/connection.tsx +++ b/src/games/storywriter/components/settings/connection.tsx @@ -1,3 +1,4 @@ +import { useBool } from "@common/hooks/useBool"; import { useQuery } from "@common/hooks/useAsyncState"; import { useInputState } from "@common/hooks/useInputState"; import { useUpdate } from "@common/hooks/useUpdate"; @@ -12,6 +13,7 @@ export const ConnectionSettings = () => { const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? ""); const [selectedModel, setSelectedModel] = useInputState(model?.id ?? ""); const [update, triggerFetch] = useUpdate(); + const showPassword = useBool(false); const urlRef = useRef(url); const apiKeyRef = useRef(apiKey); @@ -91,17 +93,27 @@ export const ConnectionSettings = () => {
- +
+ + +
diff --git a/src/games/storywriter/components/settings/user.tsx b/src/games/storywriter/components/settings/user.tsx new file mode 100644 index 0000000..ac62275 --- /dev/null +++ b/src/games/storywriter/components/settings/user.tsx @@ -0,0 +1,49 @@ +import { ContentEditable } from "@common/components/ContentEditable"; +import { highlight } from "@common/highlight"; +import { useInputCallback } from "@common/hooks/useInputCallback"; +import { useInputState } from "@common/hooks/useInputState"; +import clsx from "clsx"; +import styles from "../../assets/settings-modal.module.css"; +import { useAppState } from "../../contexts/state"; + +export const UserSettings = () => { + const { userName, userDescription, dispatch } = useAppState(); + + const [nameValue, setNameValue] = useInputState(userName); + + const handleNameBlur = () => { + const trimmed = nameValue.trim(); + if (trimmed !== userName) { + dispatch({ type: "SET_USER_NAME", userName: trimmed || "User" }); + } + }; + + const setDescription = useInputCallback((value) => { + dispatch({ type: "SET_USER_DESCRIPTION", userDescription: value }); + }, []); + + return ( +
+
+ + +
+
+ + +
+
+ ); +}; diff --git a/src/games/storywriter/contexts/state.tsx b/src/games/storywriter/contexts/state.tsx index 0c889d3..6169eb9 100644 --- a/src/games/storywriter/contexts/state.tsx +++ b/src/games/storywriter/contexts/state.tsx @@ -116,6 +116,8 @@ interface IState { enableThinking: boolean; bannedTokens: string[]; systemInstruction: string; + userName: string; + userDescription: string; } // ─── Actions ───────────────────────────────────────────────────────────────── @@ -140,6 +142,8 @@ type Action = | { 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_USER_NAME'; userName: string } + | { type: 'SET_USER_DESCRIPTION'; userDescription: string } | { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string } | { type: 'SET_WORLD_SYSTEM_INSTRUCTION_OVERRIDE'; worldId: string; systemInstructionOverride: string | undefined } | { type: 'SET_CURRENT_TAB'; tab: Tab } @@ -212,6 +216,8 @@ const DEFAULT_STATE: IState = { model: null, enableThinking: false, bannedTokens: [], + userName: 'User', + userDescription: '', systemInstruction: `You are a creative writing assistant. Help the user develop their story by writing engaging content, maintaining consistency with the established characters, settings, and plot. Follow the user's instructions while staying true to the story's tone and style. Write using markdown to highlight special parts. @@ -382,6 +388,12 @@ function reducer(state: IState, action: Action): IState { return { lore: reordered }; }); } + case 'SET_USER_NAME': { + return { ...state, userName: action.userName }; + } + case 'SET_USER_DESCRIPTION': { + return { ...state, userDescription: action.userDescription }; + } case 'SET_SYSTEM_INSTRUCTION': { return { ...state, systemInstruction: action.systemInstruction }; } @@ -570,6 +582,8 @@ export interface AppState { enableThinking: boolean; bannedTokens: string[]; systemInstruction: string; + userName: string; + userDescription: string; /** Effective system instruction: world override if set, otherwise global */ effectiveSystemInstruction: string; dispatch: (action: Action) => void; @@ -616,6 +630,8 @@ export const StateContextProvider = ({ children }: { children?: any }) => { enableThinking: state.enableThinking, bannedTokens: state.bannedTokens ?? [], systemInstruction: state.systemInstruction ?? '', + userName: state.userName ?? 'User', + userDescription: state.userDescription ?? '', effectiveSystemInstruction: currentWorld?.systemInstructionOverride ?? state.systemInstruction ?? '', dispatch, }; diff --git a/src/games/storywriter/utils/character-card.ts b/src/games/storywriter/utils/character-card.ts index 37ff1f3..d17a205 100644 --- a/src/games/storywriter/utils/character-card.ts +++ b/src/games/storywriter/utils/character-card.ts @@ -144,7 +144,7 @@ Key Guidelines: parts.push(`## {{char}}'s Example Response:\n${data.mes_example.trim()}`); } - return `# **Roleplay Context**\n${substituteVars(parts.join('\n\n'), data.name)}\n### **End of Roleplay Context**`; + return `# **Roleplay Context**\n${parts.join('\n\n')}\n### **End of Roleplay Context**`; } // ─── World Builder ──────────────────────────────────────────────────────── diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index 560c51c..f1a905a 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -250,6 +250,16 @@ namespace Prompt { return lines.join('\n'); } + export function formatUserSection(state: AppState): string { + if (!state.userName) { + return ''; + } + + return state.userDescription + ? `## ${state.userName}:\n${state.userDescription}` + : `## User name: ${state.userName}`; + } + export function formatLocationsMarkdown(state: AppState): string { const { mergedLocations } = state; if (!mergedLocations?.length) { @@ -274,44 +284,55 @@ namespace Prompt { return lines.join('\n'); } + export function substituteVars(state: AppState, text: string): string { + const charName = state.currentWorld?.title || 'Assistant'; + const userName = state.userName || 'User'; + return text + .replaceAll('{{char}}', charName) + .replaceAll('{{user}}', userName); + } + export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string { 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}`); + // Chat-only worlds: system instruction + user info, no story scaffolding + if (currentWorld?.chatOnly) { + const userSection = formatUserSection(state); + if (userSection) { + parts.push(userSection); + } + } else { + parts.push(`# Story Title: ${currentStory.title}`); - const loreSection = formatLoreMarkdown(state); - if (loreSection) { - parts.push(loreSection); - } + const loreSection = formatLoreMarkdown(state); + if (loreSection) { + parts.push(loreSection); + } - const charactersSection = formatCharactersMarkdown(state); - if (charactersSection) { - parts.push(charactersSection); - } + const charactersSection = formatCharactersMarkdown(state); + if (charactersSection) { + parts.push(charactersSection); + } - const locationsSection = formatLocationsMarkdown(state); - if (locationsSection) { - parts.push(locationsSection); - } + const locationsSection = formatLocationsMarkdown(state); + if (locationsSection) { + parts.push(locationsSection); + } - if (currentStory.scratchpad) { - parts.push(`## Scratchpad`, currentStory.scratchpad); - } + if (currentStory.scratchpad) { + parts.push(`## Scratchpad`, currentStory.scratchpad); + } - if (currentStory.text && storyTokenBudget > 0) { - const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget); - if (storyText) { - parts.push(`## Story Text:`, storyText); + if (currentStory.text && storyTokenBudget > 0) { + const storyText = formatStoryChunks(currentStory.text, currentStory.chapters ?? [], storyTokenBudget); + if (storyText) { + parts.push(`## Story Text:`, storyText); + } } } @@ -345,15 +366,16 @@ namespace Prompt { } } + const charName = currentWorld?.title || 'Assistant'; + const userName = state.userName || 'User'; + const applyVars = (msgs: ChatMessage[]) => + msgs.map(m => ({ ...m, content: substituteVars(state, m.content) })); + // Chat-only world: format messages with name prefixes if (currentWorld?.chatOnly) { - const charName = currentWorld.title ?? 'Assistant'; const formattedMessages: ChatMessage[] = messages.map(msg => { - const prefix = msg.role === 'user' ? 'User' : charName; - return { - ...msg, - content: `${prefix}: ${msg.content}`, - }; + const prefix = msg.role === 'user' ? userName : charName; + return { ...msg, content: `${prefix}: ${msg.content}` }; }); // Prepend system message @@ -365,7 +387,7 @@ namespace Prompt { return { model: model.id, - messages: formattedMessages, + messages: applyVars(formattedMessages), enable_thinking: false, max_tokens: model.max_length ? model.max_length : 2048, add_generation_prompt: true, @@ -391,7 +413,7 @@ namespace Prompt { return { model: model.id, - messages, + messages: applyVars(messages), tools: Tools.getTools(), banned_tokens: state.bannedTokens, enable_thinking: enableThinking,