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; 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 { .divider {
height: 1px; height: 1px;
background: var(--border); background: var(--border);

View File

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

View File

@ -4,11 +4,7 @@ import { X } from "lucide-preact";
import styles from "../../assets/settings-modal.module.css"; import styles from "../../assets/settings-modal.module.css";
import { useAppState } from "../../contexts/state"; import { useAppState } from "../../contexts/state";
interface Props { export const BannedTokensSettings = () => {
onClose: () => void;
}
export const BannedTokensSettings = ({ onClose }: Props) => {
const { bannedTokens, dispatch } = useAppState(); const { bannedTokens, dispatch } = useAppState();
const [inputValue, setInputValue] = useInputState(); const [inputValue, setInputValue] = useInputState();
@ -30,7 +26,6 @@ export const BannedTokensSettings = ({ onClose }: Props) => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") handleAdd(); if (e.key === "Enter") handleAdd();
else if (e.key === "Escape") onClose();
}; };
return ( return (

View File

@ -1,3 +1,4 @@
import { useBool } from "@common/hooks/useBool";
import { useQuery } from "@common/hooks/useAsyncState"; import { useQuery } from "@common/hooks/useAsyncState";
import { useInputState } from "@common/hooks/useInputState"; import { useInputState } from "@common/hooks/useInputState";
import { useUpdate } from "@common/hooks/useUpdate"; import { useUpdate } from "@common/hooks/useUpdate";
@ -12,6 +13,7 @@ export const ConnectionSettings = () => {
const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? ""); const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? "");
const [selectedModel, setSelectedModel] = useInputState(model?.id ?? ""); const [selectedModel, setSelectedModel] = useInputState(model?.id ?? "");
const [update, triggerFetch] = useUpdate(); const [update, triggerFetch] = useUpdate();
const showPassword = useBool(false);
const urlRef = useRef(url); const urlRef = useRef(url);
const apiKeyRef = useRef(apiKey); const apiKeyRef = useRef(apiKey);
@ -91,8 +93,9 @@ export const ConnectionSettings = () => {
</div> </div>
<div class={styles.formGroup}> <div class={styles.formGroup}>
<label class={styles.label}>API Key</label> <label class={styles.label}>API Key</label>
<div class={styles.inputRow}>
<input <input
type="password" type={showPassword.value ? "text" : "password"}
value={apiKey} value={apiKey}
onInput={setApiKey} onInput={setApiKey}
onBlur={handleBlur} onBlur={handleBlur}
@ -102,6 +105,15 @@ export const ConnectionSettings = () => {
autocomplete="new-password" autocomplete="new-password"
name="api-key-random" 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>
<div class={styles.formGroup}> <div class={styles.formGroup}>
<label class={styles.label}>Model</label> <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; enableThinking: boolean;
bannedTokens: string[]; bannedTokens: string[];
systemInstruction: string; systemInstruction: string;
userName: string;
userDescription: string;
} }
// ─── Actions ───────────────────────────────────────────────────────────────── // ─── Actions ─────────────────────────────────────────────────────────────────
@ -140,6 +142,8 @@ type Action =
| { type: 'DELETE_LORE_ENTRY'; worldId: string; storyId: string | null; entryId: string } | { type: 'DELETE_LORE_ENTRY'; worldId: string; storyId: string | null; entryId: string }
| { type: 'REORDER_LORE_ENTRIES'; worldId: string; storyId: string | null; entryIds: string[] } | { type: 'REORDER_LORE_ENTRIES'; worldId: string; storyId: string | null; entryIds: string[] }
// Settings // Settings
| { type: 'SET_USER_NAME'; userName: string }
| { type: 'SET_USER_DESCRIPTION'; userDescription: string }
| { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string } | { type: 'SET_SYSTEM_INSTRUCTION'; systemInstruction: string }
| { type: 'SET_WORLD_SYSTEM_INSTRUCTION_OVERRIDE'; worldId: string; systemInstructionOverride: string | undefined } | { type: 'SET_WORLD_SYSTEM_INSTRUCTION_OVERRIDE'; worldId: string; systemInstructionOverride: string | undefined }
| { type: 'SET_CURRENT_TAB'; tab: Tab } | { type: 'SET_CURRENT_TAB'; tab: Tab }
@ -212,6 +216,8 @@ const DEFAULT_STATE: IState = {
model: null, model: null,
enableThinking: false, enableThinking: false,
bannedTokens: [], 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. 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. Write using markdown to highlight special parts.
@ -382,6 +388,12 @@ function reducer(state: IState, action: Action): IState {
return { lore: reordered }; 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': { case 'SET_SYSTEM_INSTRUCTION': {
return { ...state, systemInstruction: action.systemInstruction }; return { ...state, systemInstruction: action.systemInstruction };
} }
@ -570,6 +582,8 @@ export interface AppState {
enableThinking: boolean; enableThinking: boolean;
bannedTokens: string[]; bannedTokens: string[];
systemInstruction: string; systemInstruction: string;
userName: string;
userDescription: string;
/** Effective system instruction: world override if set, otherwise global */ /** Effective system instruction: world override if set, otherwise global */
effectiveSystemInstruction: string; effectiveSystemInstruction: string;
dispatch: (action: Action) => void; dispatch: (action: Action) => void;
@ -616,6 +630,8 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
enableThinking: state.enableThinking, enableThinking: state.enableThinking,
bannedTokens: state.bannedTokens ?? [], bannedTokens: state.bannedTokens ?? [],
systemInstruction: state.systemInstruction ?? '', systemInstruction: state.systemInstruction ?? '',
userName: state.userName ?? 'User',
userDescription: state.userDescription ?? '',
effectiveSystemInstruction: currentWorld?.systemInstructionOverride ?? state.systemInstruction ?? '', effectiveSystemInstruction: currentWorld?.systemInstructionOverride ?? state.systemInstruction ?? '',
dispatch, dispatch,
}; };

View File

@ -144,7 +144,7 @@ Key Guidelines:
parts.push(`## {{char}}'s Example Response:\n${data.mes_example.trim()}`); 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 ──────────────────────────────────────────────────────── // ─── World Builder ────────────────────────────────────────────────────────

View File

@ -250,6 +250,16 @@ namespace Prompt {
return lines.join('\n'); 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 { export function formatLocationsMarkdown(state: AppState): string {
const { mergedLocations } = state; const { mergedLocations } = state;
if (!mergedLocations?.length) { if (!mergedLocations?.length) {
@ -274,19 +284,29 @@ namespace Prompt {
return lines.join('\n'); 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 { export function formatSystemPrompt(state: AppState, storyTokenBudget: number = 0): string {
const { currentStory, currentWorld } = state; const { currentStory, currentWorld } = state;
if (!currentStory) { if (!currentStory) {
return state.effectiveSystemInstruction; return state.effectiveSystemInstruction;
} }
// Chat-only worlds: just the system instruction, no story scaffolding
if (currentWorld?.chatOnly) {
return state.effectiveSystemInstruction;
}
const parts: string[] = [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}`); parts.push(`# Story Title: ${currentStory.title}`);
const loreSection = formatLoreMarkdown(state); const loreSection = formatLoreMarkdown(state);
@ -314,6 +334,7 @@ namespace Prompt {
parts.push(`## Story Text:`, storyText); parts.push(`## Story Text:`, storyText);
} }
} }
}
return parts.join('\n\n'); 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 // Chat-only world: format messages with name prefixes
if (currentWorld?.chatOnly) { if (currentWorld?.chatOnly) {
const charName = currentWorld.title ?? 'Assistant';
const formattedMessages: ChatMessage[] = messages.map(msg => { const formattedMessages: ChatMessage[] = messages.map(msg => {
const prefix = msg.role === 'user' ? 'User' : charName; const prefix = msg.role === 'user' ? userName : charName;
return { return { ...msg, content: `${prefix}: ${msg.content}` };
...msg,
content: `${prefix}: ${msg.content}`,
};
}); });
// Prepend system message // Prepend system message
@ -365,7 +387,7 @@ namespace Prompt {
return { return {
model: model.id, model: model.id,
messages: formattedMessages, messages: applyVars(formattedMessages),
enable_thinking: false, enable_thinking: false,
max_tokens: model.max_length ? model.max_length : 2048, max_tokens: model.max_length ? model.max_length : 2048,
add_generation_prompt: true, add_generation_prompt: true,
@ -391,7 +413,7 @@ namespace Prompt {
return { return {
model: model.id, model: model.id,
messages, messages: applyVars(messages),
tools: Tools.getTools(), tools: Tools.getTools(),
banned_tokens: state.bannedTokens, banned_tokens: state.bannedTokens,
enable_thinking: enableThinking, enable_thinking: enableThinking,