1
0
Fork 0

User config

This commit is contained in:
Pabloader 2026-04-08 08:37:26 +00:00
parent 6f100cac97
commit 371f84571a
9 changed files with 174 additions and 54 deletions

View File

@ -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);

View File

@ -481,7 +481,7 @@ export const ChatPanel = () => {
<div
class={styles.content}
dangerouslySetInnerHTML={{ __html: highlight(message.content, false).trim() }}
dangerouslySetInnerHTML={{ __html: highlight(Prompt.substituteVars(appState, message.content), false).trim() }}
/>
{message.role === 'assistant' && message.tool_calls && (

View File

@ -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<Tab>("connection");
@ -32,6 +33,12 @@ export const SettingsModal = ({ onClose }: Props) => {
>
Connection
</button>
<button
class={clsx(styles.menuItem, activeTab === "user" && styles.active)}
onClick={() => setActiveTab("user")}
>
User
</button>
<button
class={clsx(styles.menuItem, activeTab === "banned-tokens" && styles.active)}
onClick={() => setActiveTab("banned-tokens")}
@ -46,7 +53,8 @@ export const SettingsModal = ({ onClose }: Props) => {
</button>
</nav>
<div class={styles.content}>
{activeTab === "banned-tokens" && <BannedTokensSettings onClose={onClose} />}
{activeTab === "user" && <UserSettings />}
{activeTab === "banned-tokens" && <BannedTokensSettings />}
{activeTab === "system-instruction" && <SystemInstructionSettings />}
{activeTab === "connection" && <ConnectionSettings />}
</div>

View File

@ -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 (

View File

@ -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,8 +93,9 @@ export const ConnectionSettings = () => {
</div>
<div class={styles.formGroup}>
<label class={styles.label}>API Key</label>
<div class={styles.inputRow}>
<input
type="password"
type={showPassword.value ? "text" : "password"}
value={apiKey}
onInput={setApiKey}
onBlur={handleBlur}
@ -102,6 +105,15 @@ export const ConnectionSettings = () => {
autocomplete="new-password"
name="api-key-random"
/>
<button
type="button"
onClick={showPassword.toggle}
class={styles.iconButton}
title={showPassword.value ? "Hide API key" : "Show API key"}
>
{showPassword.value ? "🙈" : "👁️"}
</button>
</div>
</div>
<div class={styles.formGroup}>
<label class={styles.label}>Model</label>

View File

@ -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 (
<div class={styles.form}>
<div class={styles.formGroup}>
<label class={styles.label}>Your Name</label>
<input
type="text"
value={nameValue}
onInput={setNameValue}
onBlur={handleNameBlur}
placeholder="User"
class={styles.input}
/>
</div>
<div class={clsx(styles.formGroup, styles.formGroupFill)}>
<label class={styles.label}>Your Description</label>
<ContentEditable
value={highlight(userDescription)}
onInput={setDescription}
placeholder="Describe yourself for the AI..."
class={clsx(styles.input, styles.textarea)}
/>
</div>
</div>
);
};

View File

@ -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,
};

View File

@ -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 ────────────────────────────────────────────────────────

View File

@ -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,19 +284,29 @@ 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];
// 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);
@ -314,6 +334,7 @@ namespace Prompt {
parts.push(`## Story Text:`, storyText);
}
}
}
return parts.join('\n\n');
}
@ -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,