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,