1
0
Fork 0

System prompt

This commit is contained in:
Pabloader 2026-03-23 21:58:21 +00:00
parent c2c55eb820
commit d1325b33b7
6 changed files with 308 additions and 250 deletions

View File

@ -111,7 +111,8 @@
}
.input,
.select {
.select,
.textarea {
width: 100%;
padding: 8px;
border-radius: 4px;
@ -120,6 +121,13 @@
color: var(--text);
}
.textarea {
resize: vertical;
font-family: inherit;
font-size: inherit;
line-height: 1.5;
}
.footer {
padding: 16px 20px;
border-top: 1px solid var(--border);

View File

@ -1,98 +0,0 @@
import clsx from "clsx";
import { useInputState } from "@common/hooks/useInputState";
import { useAppState } from "../contexts/state";
import styles from "../assets/settings-modal.module.css";
import { X } from "lucide-preact";
interface Props {
onClose: () => void;
}
export const BannedTokensModal = ({ onClose }: Props) => {
const { bannedTokens, dispatch } = useAppState();
const [inputValue, setInputValue] = useInputState();
const handleAdd = () => {
const trimmed = inputValue.trim();
if (trimmed && !bannedTokens.includes(trimmed)) {
dispatch({
type: "SET_BANNED_TOKENS",
tokens: [...bannedTokens, trimmed],
});
setInputValue("");
}
};
const handleRemove = (token: string) => {
dispatch({
type: "SET_BANNED_TOKENS",
tokens: bannedTokens.filter((t) => t !== token),
});
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
handleAdd();
} else if (e.key === "Escape") {
onClose();
}
};
const sortedTokens = [...bannedTokens].sort((a, b) =>
a.trim().toLowerCase().localeCompare(b.trim().toLowerCase())
);
return (
<div class={styles.overlay} onClick={onClose}>
<div class={styles.modal} onClick={(e) => e.stopPropagation()}>
<div class={styles.header}>
<h2 class={styles.title}>Banned Tokens</h2>
<button class={styles.closeButton} onClick={onClose}>
<X size={20} />
</button>
</div>
<div class={styles.content}>
<div class={styles.inputRow}>
<input
type="text"
value={inputValue}
onInput={setInputValue}
onKeyDown={handleKeyDown}
placeholder="Token to ban"
class={styles.input}
autoFocus
/>
<button onClick={handleAdd} class={clsx(styles.button, styles.buttonPrimary)}>
Add
</button>
</div>
<div class={styles.divider} />
<div class={styles.tokenList}>
{sortedTokens.length === 0 ? (
<p class={styles.emptyText}>No banned tokens</p>
) : (
sortedTokens.map((token) => (
<div key={token} class={styles.tokenItem}>
<span>{token}</span>
<button
onClick={() => handleRemove(token)}
class={styles.tokenRemoveButton}
>
<X size={12} />
</button>
</div>
))
)}
</div>
</div>
<div class={styles.footer}>
<button onClick={onClose} class={clsx(styles.button, styles.buttonSecondary)}>
Done
</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,185 @@
import { useMemo, useRef } from "preact/hooks";
import { useQuery } from "@common/hooks/useAsyncState";
import { useInputState } from "@common/hooks/useInputState";
import { useUpdate } from "@common/hooks/useUpdate";
import { useAppState } from "../contexts/state";
import LLM from "../utils/llm";
import styles from "../assets/settings-modal.module.css";
import { X } from "lucide-preact";
interface Props {
onClose: () => void;
}
export const ConnectionSettingsModal = ({ onClose }: Props) => {
const { connection, model, dispatch } = useAppState();
const [url, setUrl] = useInputState(connection?.url ?? "");
const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? "");
const [selectedModel, setSelectedModel] = useInputState(model?.id ?? "");
const [update, triggerFetch] = useUpdate();
const urlRef = useRef(url);
const apiKeyRef = useRef(apiKey);
urlRef.current = url;
apiKeyRef.current = apiKey;
const connectionToFetch = useMemo<LLM.Connection | null>(() => {
const currentUrl = urlRef.current;
const currentApiKey = apiKeyRef.current;
if (!currentUrl || !currentApiKey) return null;
return { url: currentUrl, apiKey: currentApiKey };
}, [update]);
const fetchModels = useMemo(() => async (conn: LLM.Connection | null) => {
if (!conn) return [];
const r = await LLM.getModels(conn);
return r.data;
}, []);
const modelsData = useQuery(fetchModels, connectionToFetch);
const isLoadingModels = connectionToFetch != null && modelsData == undefined;
const groupedModels = useMemo(() => {
const sorted = (modelsData ?? []).sort((a, b) => {
const aWeight = Number(a.support_tools) * 2 + Number(a.support_thinking);
const bWeight = Number(b.support_tools) * 2 + Number(b.support_thinking);
if (aWeight !== bWeight) {
return bWeight - aWeight;
}
const aContext = a.max_context ?? 0;
const bContext = b.max_context ?? 0;
if (aContext !== bContext) {
return bContext - aContext;
}
return a.id.localeCompare(b.id);
});
// Group by context size
const groups = Map.groupBy(sorted, m => m.max_context ?? 0);
// Convert to array sorted by context size (bigger first)
return Array.from(groups.entries())
.sort((a, b) => b[0] - a[0])
.map(([context, models]) => ({ context, models }));
}, [modelsData]);
const handleBlur = () => {
if (url && apiKey) {
triggerFetch();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && url && apiKey) {
triggerFetch();
}
};
const handleConfirm = () => {
dispatch({
type: 'SET_CONNECTION',
connection: connectionToFetch,
});
const selectedModelInfo = modelsData?.find(m => m.id === selectedModel) ?? null;
dispatch({
type: 'SET_MODEL',
model: selectedModelInfo,
});
onClose();
};
const connectionToTest = url && apiKey ? { url, apiKey } : null;
return (
<div class={styles.overlay} onClick={onClose}>
<div class={styles.modal} onClick={(e) => e.stopPropagation()}>
<div class={styles.header}>
<h2 class={styles.title}>Connection Settings</h2>
<button class={styles.closeButton} onClick={onClose}>
<X size={20} />
</button>
</div>
<div class={styles.content}>
<div class={styles.form} autocomplete="off">
<div class={styles.formGroup}>
<label class={styles.label}>
API URL
</label>
<input
type="text"
value={url}
onInput={setUrl}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder="http://localhost:1234"
class={styles.input}
autocomplete="off"
name="api-url-random"
/>
</div>
<div class={styles.formGroup}>
<label class={styles.label}>
API Key
</label>
<input
type="password"
value={apiKey}
onInput={setApiKey}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder="your-api-key"
class={styles.input}
autocomplete="new-password"
name="api-key-random"
/>
</div>
<div class={styles.formGroup}>
<label class={styles.label}>
Model
</label>
{connectionToTest ? (
isLoadingModels ? (
<p>Loading models...</p>
) : groupedModels.length > 0 ? (
<select
value={selectedModel}
onChange={setSelectedModel}
class={styles.select}
>
<option value="">Select a model</option>
{groupedModels.map(({ context, models }) => (
<optgroup key={context} label={`${context} context`}>
{models.map(m => (
<option key={m.id} value={m.id}>
{m.support_tools ? '🔨' : ''}{m.support_thinking ? '🧠' : ''}{m.id} {m.max_length ? `(len: ${m.max_length})` : ''}
</option>
))}
</optgroup>
))}
</select>
) : (
<p>No models available</p>
)
) : (
<p>Enter connection details to load models</p>
)}
</div>
</div>
</div>
<div class={styles.footer}>
<button onClick={onClose} class={`${styles.button} ${styles.buttonSecondary}`}>
Cancel
</button>
<button onClick={handleConfirm} class={`${styles.button} ${styles.buttonPrimary}`}>
Confirm
</button>
</div>
</div>
</div>
);
};

View File

@ -1,13 +1,13 @@
import clsx from "clsx";
import { Sidebar } from "./sidebar";
import { ConnectionSettingsModal } from "./connection-settings-modal";
import { SettingsModal } from "./settings-modal";
import { BannedTokensModal } from "./banned-tokens-modal";
import { useAppState } from "../contexts/state";
import { useBool } from "@common/hooks/useBool";
import type { Story } from "../contexts/state";
import styles from '../assets/menu-sidebar.module.css';
import { useState } from "preact/hooks";
import { Pencil, X, Plus, Settings, Ban } from "lucide-preact";
import { Pencil, X, Plus, Plug, Settings } from "lucide-preact";
// ─── Story Item ───────────────────────────────────────────────────────────────
@ -82,8 +82,8 @@ const StoryItem = ({ story, active, onSelect, onRename, onDelete }: StoryItemPro
export const MenuSidebar = () => {
const { stories, currentStory, dispatch } = useAppState();
const isConnectionSettingsOpen = useBool(false);
const isSettingsOpen = useBool(false);
const isBannedTokensOpen = useBool(false);
const handleCreate = () => {
dispatch({ type: 'CREATE_STORY', title: 'New Story' });
@ -124,20 +124,20 @@ export const MenuSidebar = () => {
))}
</div>
<div class={styles.bottomButtons}>
<button class={styles.settingsButton} onClick={isBannedTokensOpen.toggle}>
<Ban size={16} /> Banned Tokens
</button>
<button class={styles.settingsButton} onClick={isSettingsOpen.toggle}>
<Settings size={16} /> Settings
</button>
<button class={styles.settingsButton} onClick={isConnectionSettingsOpen.toggle}>
<Plug size={16} /> Connection Settings
</button>
</div>
</div>
{isBannedTokensOpen.value && (
<BannedTokensModal onClose={isBannedTokensOpen.toggle} />
)}
{isSettingsOpen.value && (
<SettingsModal onClose={isSettingsOpen.toggle} />
)}
{isConnectionSettingsOpen.value && (
<ConnectionSettingsModal onClose={isConnectionSettingsOpen.toggle} />
)}
</Sidebar>
);
};

View File

@ -1,99 +1,61 @@
import { useMemo, useRef } from "preact/hooks";
import clsx from "clsx";
import { useMemo, useState } from "preact/hooks";
import { useQuery } from "@common/hooks/useAsyncState";
import { useInputState } from "@common/hooks/useInputState";
import { useUpdate } from "@common/hooks/useUpdate";
import { useAppState } from "../contexts/state";
import LLM from "../utils/llm";
import styles from "../assets/settings-modal.module.css";
import { X } from "lucide-preact";
import { useInputCallback } from "@common/hooks/useInputCallback";
interface Props {
onClose: () => void;
}
type Tab = "banned-tokens" | "system-instruction";
export const SettingsModal = ({ onClose }: Props) => {
const { connection, model, dispatch } = useAppState();
const [url, setUrl] = useInputState(connection?.url ?? "");
const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? "");
const [selectedModel, setSelectedModel] = useInputState(model?.id ?? "");
const [update, triggerFetch] = useUpdate();
const { bannedTokens, systemInstruction, dispatch } = useAppState();
const [inputValue, setInputValue] = useInputState();
const [activeTab, setActiveTab] = useState<Tab>("banned-tokens");
const urlRef = useRef(url);
const apiKeyRef = useRef(apiKey);
urlRef.current = url;
apiKeyRef.current = apiKey;
const connectionToFetch = useMemo<LLM.Connection | null>(() => {
const currentUrl = urlRef.current;
const currentApiKey = apiKeyRef.current;
if (!currentUrl || !currentApiKey) return null;
return { url: currentUrl, apiKey: currentApiKey };
}, [update]);
const fetchModels = useMemo(() => async (conn: LLM.Connection | null) => {
if (!conn) return [];
const r = await LLM.getModels(conn);
return r.data;
// Save system instruction on every change
const setInstructionValue = useInputCallback((instructionValue) => {
dispatch({
type: "SET_SYSTEM_INSTRUCTION",
systemInstruction: instructionValue,
});
}, []);
const modelsData = useQuery(fetchModels, connectionToFetch);
const isLoadingModels = connectionToFetch != null && modelsData == undefined;
const groupedModels = useMemo(() => {
const sorted = (modelsData ?? []).sort((a, b) => {
const aWeight = Number(a.support_tools) * 2 + Number(a.support_thinking);
const bWeight = Number(b.support_tools) * 2 + Number(b.support_thinking);
if (aWeight !== bWeight) {
return bWeight - aWeight;
}
const aContext = a.max_context ?? 0;
const bContext = b.max_context ?? 0;
if (aContext !== bContext) {
return bContext - aContext;
}
return a.id.localeCompare(b.id);
});
// Group by context size
const groups = Map.groupBy(sorted, m => m.max_context ?? 0);
// Convert to array sorted by context size (bigger first)
return Array.from(groups.entries())
.sort((a, b) => b[0] - a[0])
.map(([context, models]) => ({ context, models }));
}, [modelsData]);
const handleBlur = () => {
if (url && apiKey) {
triggerFetch();
const handleAdd = () => {
const trimmed = inputValue.trim();
if (trimmed && !bannedTokens.includes(trimmed)) {
dispatch({
type: "SET_BANNED_TOKENS",
tokens: [...bannedTokens, trimmed],
});
setInputValue("");
}
};
const handleRemove = (token: string) => {
dispatch({
type: "SET_BANNED_TOKENS",
tokens: bannedTokens.filter((t) => t !== token),
});
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && url && apiKey) {
triggerFetch();
if (e.key === "Enter") {
handleAdd();
} else if (e.key === "Escape") {
onClose();
}
};
const handleConfirm = () => {
dispatch({
type: 'SET_CONNECTION',
connection: connectionToFetch,
});
const selectedModelInfo = modelsData?.find(m => m.id === selectedModel) ?? null;
dispatch({
type: 'SET_MODEL',
model: selectedModelInfo,
});
onClose();
};
const connectionToTest = url && apiKey ? { url, apiKey } : null;
const sortedTokens = [...bannedTokens].sort((a, b) =>
a.trim().toLowerCase().localeCompare(b.trim().toLowerCase())
);
return (
<div class={styles.overlay} onClick={onClose}>
@ -104,79 +66,76 @@ export const SettingsModal = ({ onClose }: Props) => {
<X size={20} />
</button>
</div>
<div class={styles.tabs}>
<button
class={clsx(styles.tab, activeTab === "banned-tokens" && styles.active)}
onClick={() => setActiveTab("banned-tokens")}
>
Banned Tokens
</button>
<button
class={clsx(styles.tab, activeTab === "system-instruction" && styles.active)}
onClick={() => setActiveTab("system-instruction")}
>
System Instruction
</button>
</div>
<div class={styles.content}>
<div class={styles.form} autocomplete="off">
<div class={styles.formGroup}>
<label class={styles.label}>
API URL
</label>
<input
type="text"
value={url}
onInput={setUrl}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder="http://localhost:1234"
class={styles.input}
autocomplete="off"
name="api-url-random"
/>
</div>
<div class={styles.formGroup}>
<label class={styles.label}>
API Key
</label>
<input
type="password"
value={apiKey}
onInput={setApiKey}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder="your-api-key"
class={styles.input}
autocomplete="new-password"
name="api-key-random"
/>
</div>
<div class={styles.formGroup}>
<label class={styles.label}>
Model
</label>
{connectionToTest ? (
isLoadingModels ? (
<p>Loading models...</p>
) : groupedModels.length > 0 ? (
<select
value={selectedModel}
onChange={setSelectedModel}
class={styles.select}
>
<option value="">Select a model</option>
{groupedModels.map(({ context, models }) => (
<optgroup key={context} label={`${context} context`}>
{models.map(m => (
<option key={m.id} value={m.id}>
{m.support_tools ? '🔨' : ''}{m.support_thinking ? '🧠' : ''}{m.id} {m.max_length ? `(len: ${m.max_length})` : ''}
</option>
))}
</optgroup>
))}
</select>
{activeTab === "banned-tokens" ? (
<>
<div class={styles.inputRow}>
<input
type="text"
value={inputValue}
onInput={setInputValue}
onKeyDown={handleKeyDown}
placeholder="Token to ban"
class={styles.input}
autoFocus
/>
<button onClick={handleAdd} class={clsx(styles.button, styles.buttonPrimary)}>
Add
</button>
</div>
<div class={styles.divider} />
<div class={styles.tokenList}>
{sortedTokens.length === 0 ? (
<p class={styles.emptyText}>No banned tokens</p>
) : (
<p>No models available</p>
)
) : (
<p>Enter connection details to load models</p>
)}
sortedTokens.map((token) => (
<div key={token} class={styles.tokenItem}>
<span>{token}</span>
<button
onClick={() => handleRemove(token)}
class={styles.tokenRemoveButton}
>
<X size={12} />
</button>
</div>
))
)}
</div>
</>
) : (
<div class={styles.form}>
<div class={styles.formGroup}>
<label class={styles.label}>
System Instruction
</label>
<textarea
value={systemInstruction}
onInput={setInstructionValue}
placeholder="Enter system instruction for the AI assistant..."
class={clsx(styles.input, styles.textarea)}
rows={10}
/>
</div>
</div>
</div>
)}
</div>
<div class={styles.footer}>
<button onClick={onClose} class={`${styles.button} ${styles.buttonSecondary}`}>
Cancel
</button>
<button onClick={handleConfirm} class={`${styles.button} ${styles.buttonPrimary}`}>
Confirm
<button onClick={onClose} class={clsx(styles.button, styles.buttonSecondary)}>
Done
</button>
</div>
</div>

View File

@ -67,6 +67,10 @@ namespace Prompt {
parts.push(`# ${currentStory.title}`);
if (currentStory.lore) {
parts.push('## Lore\n' + currentStory.lore);
}
const charactersSection = formatCharactersMarkdown(state);
if (charactersSection) {
parts.push(charactersSection);