import { useState, useRef } from 'react';
import { useAppContext } from '../utils/app.context';
import { CONFIG_DEFAULT, CONFIG_INFO } from '../Config';
import StorageUtils from '../utils/storage';
import { useModals } from './ModalProvider';
import { classNames, isBoolean, isNumeric, isString } from '../utils/misc';
import {
BeakerIcon,
BookmarkIcon,
ChatBubbleOvalLeftEllipsisIcon,
Cog6ToothIcon,
FunnelIcon,
HandRaisedIcon,
SquaresPlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { OpenInNewTab } from '../utils/common';
import { SettingsPreset } from '../utils/types';
import toast from 'react-hot-toast'
type SettKey = keyof typeof CONFIG_DEFAULT;
const BASIC_KEYS: SettKey[] = [
'prefix_role',
'stop_string',
'reasoning_format',
'temperature',
'top_k',
'top_p',
'min_p',
'max_tokens',
];
const SAMPLER_KEYS: SettKey[] = [
'top_n_sigma',
'adaptive_target',
'adaptive_decay',
'xtc_probability',
'xtc_threshold',
'dynatemp_range',
'dynatemp_exponent',
'typical_p'
];
const PENALTY_KEYS: SettKey[] = [
'dry_multiplier',
'dry_base',
'dry_allowed_length',
'dry_penalty_last_n',
'repeat_last_n',
'repeat_penalty',
'presence_penalty',
'frequency_penalty',
];
enum SettingInputType {
SHORT_INPUT,
LONG_INPUT,
CHECKBOX,
DROPDOWN,
CUSTOM,
}
interface SettingFieldInput {
type: Exclude<SettingInputType, SettingInputType.CUSTOM>;
label: string | React.ReactElement;
help?: string | React.ReactElement;
key: SettKey;
options?:string;
}
interface SettingFieldCustom {
type: SettingInputType.CUSTOM;
key: SettKey;
component:
| string
| React.FC<{
value: string | boolean | number;
onChange: (value: string) => void;
}>;
}
interface SettingSection {
title: React.ReactElement;
fields: (SettingFieldInput | SettingFieldCustom)[];
}
const ICON_CLASSNAME = 'w-4 h-4 mr-1 inline';
// Presets Component
function PresetsManager({
currentConfig,
onLoadPreset,
}: {
currentConfig: typeof CONFIG_DEFAULT;
onLoadPreset: (config: typeof CONFIG_DEFAULT) => void;
}) {
const [presets, setPresets] = useState<SettingsPreset[]>(() =>
StorageUtils.getPresets()
);
const [presetName, setPresetName] = useState('');
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
const { showConfirm, showAlert } = useModals();
const handleSavePreset = async () => {
if (!presetName.trim()) {
await showAlert('Please enter a preset name');
return;
}
// Check if preset name already exists
const existingPreset = presets.find((p) => p.name === presetName.trim());
if (existingPreset) {
if (
await showConfirm(
`Preset "${presetName}" already exists. Do you want to overwrite it?`
)
) {
StorageUtils.updatePreset(existingPreset.id, currentConfig);
setPresets(StorageUtils.getPresets());
setPresetName('');
await showAlert('Preset updated successfully');
}
} else {
const newPreset = StorageUtils.savePreset(
presetName.trim(),
currentConfig
);
setPresets([...presets, newPreset]);
setPresetName('');
await showAlert('Preset saved successfully');
}
};
const handleLoadPreset = async (preset: SettingsPreset) => {
if (
await showConfirm(
`Load preset "${preset.name}"? Current settings will be replaced.`
)
) {
onLoadPreset(preset.config as typeof CONFIG_DEFAULT);
setSelectedPresetId(preset.id);
}
};
const handleDeletePreset = async (preset: SettingsPreset) => {
if (await showConfirm(`Delete preset "${preset.name}"?`)) {
StorageUtils.deletePreset(preset.id);
setPresets(presets.filter((p) => p.id !== preset.id));
if (selectedPresetId === preset.id) {
setSelectedPresetId(null);
}
}
};
return (
<div className="space-y-4">
{/* Save current settings as preset */}
<div className="form-control">
<label className="label">
<span className="label-text">Save current settings as preset</span>
</label>
<div className="join">
<input
type="text"
placeholder="Enter preset name"
className="input input-bordered join-item flex-1"
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSavePreset();
}
}}
/>
<button
className="btn btn-primary join-item"
onClick={handleSavePreset}
>
Save Preset
</button>
</div>
</div>
{/* List of saved presets */}
<div className="form-control">
<label className="label">
<span className="label-text">Saved presets</span>
</label>
{presets.length === 0 ? (
<div className="alert">
<span>No presets saved yet</span>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{presets.map((preset) => (
<div
key={preset.id}
className={classNames({
'card bg-base-200 p-3': true,
'ring-2 ring-primary': selectedPresetId === preset.id,
})}
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{preset.name}</h4>
<p className="text-sm opacity-70">
Created: {new Date(preset.createdAt).toLocaleString()}
</p>
</div>
<div className="flex gap-2">
<button
className="btn btn-sm btn-primary"
onClick={() => handleLoadPreset(preset)}
>
Load
</button>
<button
className="btn btn-sm btn-error"
onClick={() => handleDeletePreset(preset)}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
const SETTING_SECTIONS = (
localConfig: typeof CONFIG_DEFAULT,
setLocalConfig: (config: typeof CONFIG_DEFAULT) => void
): SettingSection[] => [
{
title: (
<>
<BookmarkIcon className={ICON_CLASSNAME} />
Presets
</>
),
fields: [
{
type: SettingInputType.CUSTOM,
key: 'custom', // dummy key for presets
component: () => (
<PresetsManager
currentConfig={localConfig}
onLoadPreset={setLocalConfig}
/>
),
},
],
},
{
title: (
<>
<Cog6ToothIcon className={ICON_CLASSNAME} />
General
</>
),
fields: [
{
type: SettingInputType.SHORT_INPUT,
label: 'API Key',
key: 'apiKey',
},
{
type: SettingInputType.LONG_INPUT,
label: 'System Message (will be disabled if left empty)',
key: 'systemMessage',
},
{
type: SettingInputType.DROPDOWN,
label: 'Completion Type',
key: 'completionType',
options: 'Chat|Text'
},
...BASIC_KEYS.map(
(key) =>
({
type: SettingInputType.SHORT_INPUT,
label: key,
key,
}) as SettingFieldInput
),
{
type: SettingInputType.SHORT_INPUT,
label: 'Paste length to file',
key: 'pasteLongTextToFileLen',
},
{
type: SettingInputType.CHECKBOX,
label: 'Parse PDF as image instead of text',
key: 'pdfAsImage',
},
],
},
{
title: (
<>
<FunnelIcon className={ICON_CLASSNAME} />
Samplers
</>
),
fields: [
{
type: SettingInputType.SHORT_INPUT,
label: 'Samplers queue',
key: 'samplers',
},
...SAMPLER_KEYS.map(
(key) =>
({
type: SettingInputType.SHORT_INPUT,
label: key,
key,
}) as SettingFieldInput
),
],
},
{
title: (
<>
<HandRaisedIcon className={ICON_CLASSNAME} />
Penalties
</>
),
fields: PENALTY_KEYS.map((key) => ({
type: SettingInputType.SHORT_INPUT,
label: key,
key,
})),
},
{
title: (
<>
<ChatBubbleOvalLeftEllipsisIcon className={ICON_CLASSNAME} />
Reasoning
</>
),
fields: [
{
type: SettingInputType.CHECKBOX,
label: 'Expand thought process by default when generating messages',
key: 'showThoughtInProgress',
},
{
type: SettingInputType.CHECKBOX,
label:
'Exclude thought process when sending requests to API (Recommended for Reasoning Models like Deepseek R1)',
key: 'excludeThoughtOnReq',
},
],
},
{
title: (
<>
<SquaresPlusIcon className={ICON_CLASSNAME} />
Advanced
</>
),
fields: [
{
type: SettingInputType.CUSTOM,
key: 'custom', // dummy key, won't be used
component: () => {
const debugImportDemoConv = async () => {
const res = await fetch('/demo-conversation.json');
const demoConv = await res.json();
StorageUtils.remove(demoConv.id);
for (const msg of demoConv.messages) {
StorageUtils.appendMsg(demoConv.id, msg, msg.model_name);
}
};
return (
<button className="btn" onClick={debugImportDemoConv}>
(debug) Import demo conversation
</button>
);
},
},
{
type: SettingInputType.CUSTOM,
key: 'custom', // dummy key, won't be used
component: () => {
const exportDB = async () => {
const blob = await StorageUtils.exportDB();
const a = document.createElement('a');
document.body.appendChild(a);
a.href = URL.createObjectURL(blob);
document.body.appendChild(a);
a.download = `llamawebui_dump.json`;
a.click();
document.body.removeChild(a);
};
return (
<button className="btn" onClick={exportDB}>
Export conversation database
</button>
);
},
},
{
type: SettingInputType.CUSTOM,
key: 'custom', // dummy key, won't be used
component: () => {
const importDB = async (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e);
if (!e.target.files) {
toast.error('Target.files cant be null');
throw new Error('e.target.files cant be null');
}
if (e.target.files.length != 1)
{
toast.error(
'Number of selected files for DB import must be 1 but was ' +
e.target.files.length +
'.');
throw new Error(
'Number of selected files for DB import must be 1 but was ' +
e.target.files.length +
'.'
);
}
const file = e.target.files[0];
try {
if (!file) throw new Error('No DB found to import.');
console.log('Importing DB ' + file.name);
await StorageUtils.importDB(file);
toast.success('Import complete')
window.location.reload();
} catch (error) {
//console.error('' + error);
toast.error('' + error);
}
};
return (
<div>
<label
htmlFor="db-import"
className="btn"
role="button"
tabIndex={0}
>
{' '}
Reset and import conversation database{' '}
</label>
<input
id="db-import"
type="file"
accept=".json"
className="file-upload"
onInput={importDB}
hidden
/>
</div>
);
},
},
{
type: SettingInputType.CHECKBOX,
label: 'Show generation stats (model name, context size, prompt and token per second)',
key: 'showTokensPerSecond',
},
{
type: SettingInputType.CHECKBOX,
label: 'Use server defaults for parameters (skip sending temp, top_k, top_p, min_p, typical p from WebUI)',
key: 'useServerDefaults',
},
{
type: SettingInputType.LONG_INPUT,
label: (
<>
Custom JSON config (For more info, refer to{' '}
<OpenInNewTab href="https://github.com/ikawrakow/ik_llama.cpp/tree/main/examples/server/README.md">
server documentation
</OpenInNewTab>
)
</>
),
key: 'custom',
},
],
},
{
title: (
<>
<BeakerIcon className={ICON_CLASSNAME} />
Experimental
</>
),
fields: [
{
type: SettingInputType.CUSTOM,
key: 'custom', // dummy key, won't be used
component: () => (
<>
<p className="mb-8">
Experimental features are not guaranteed to work correctly.
<br />
<br />
If you encounter any problems, create a{' '}
<OpenInNewTab href="https://github.com/ikawrakow/ik_llama.cpp/issues/new?template=019-bug-misc.yml">
Bug (misc.)
</OpenInNewTab>{' '}
report on Github. Please also specify <b>webui/experimental</b> on
the report title and include screenshots.
<br />
<br />
Some features may require packages downloaded from CDN, so they
need internet connection.
</p>
</>
),
},
{
type: SettingInputType.CHECKBOX,
label: (
<>
<b>Enable Python interpreter</b>
<br />
<small className="text-xs">
This feature uses{' '}
<OpenInNewTab href="https://pyodide.org">pyodide</OpenInNewTab>,
downloaded from CDN. To use this feature, ask the LLM to generate
Python code inside a Markdown code block. You will see a "Run"
button on the code block, near the "Copy" button.
</small>
</>
),
key: 'pyIntepreterEnabled',
},
],
},
];
export default function SettingDialog({
show,
onClose,
}: {
show: boolean;
onClose: () => void;
}) {
const { config, saveConfig } = useAppContext();
const [sectionIdx, setSectionIdx] = useState(0);
// clone the config object to prevent direct mutation
const [localConfig, setLocalConfig] = useState<typeof CONFIG_DEFAULT>(
JSON.parse(JSON.stringify(config))
);
// Generate sections with access to local state
const SETTING_SECTIONS_GENERATED = SETTING_SECTIONS(
localConfig,
setLocalConfig
);
const resetConfig = () => {
if (window.confirm('Are you sure you want to reset all settings?')) {
setLocalConfig(CONFIG_DEFAULT);
}
};
function isValidKey(key: string): key is SettKey {
return key in CONFIG_DEFAULT;
}
const handleSave = () => {
// copy the local config to prevent direct mutation
const newConfig: typeof CONFIG_DEFAULT = {
...CONFIG_DEFAULT,
...JSON.parse(
JSON.stringify(localConfig)
)};
// validate the config
for (const key in newConfig) {
if (!isValidKey(key)) {
console.log(`Unknown default type for key ${key}`);
continue;
}
const value = newConfig[key as SettKey];
const mustBeBoolean = isBoolean(CONFIG_DEFAULT[key as SettKey]);
const mustBeString = isString(CONFIG_DEFAULT[key as SettKey]);
const mustBeNumeric = isNumeric(CONFIG_DEFAULT[key as SettKey]);
if (mustBeString) {
if (!isString(value)) {
alert(`Value for ${key} must be string`);
return;
}
} else if (mustBeNumeric) {
const trimmedValue = value.toString().trim();
const numVal = Number(trimmedValue);
if (isNaN(numVal) || !isNumeric(numVal) || trimmedValue.length === 0) {
alert(`Value for ${key} must be numeric`);
return;
}
// force conversion to number
// @ts-expect-error this is safe
newConfig[key] = numVal;
} else if (mustBeBoolean) {
if (!isBoolean(value)) {
alert(`Value for ${key} must be boolean`);
return;
}
} else {
//console.error(`Unknown default type for key ${key}`);
toast.error(`Unknown default type for key ${key}`);
}
}
saveConfig(newConfig);
onClose();
};
const onChange = (key: SettKey) => (value: string | boolean) => {
// note: we do not perform validation here, because we may get incomplete value as user is still typing it
setLocalConfig({ ...localConfig, [key]: value });
};
const detailsRef = useRef<HTMLDetailsElement>(null); // <-- Add this line
return (
<dialog className={classNames({ modal: true, 'modal-open': show })}>
<div className="modal-box w-11/12 max-w-3xl">
<h3 className="text-lg font-bold mb-6">Settings</h3>
<div className="flex flex-col md:flex-row h-[calc(90vh-12rem)]">
{/* Left panel, showing sections - Desktop version */}
<div className="hidden md:flex flex-col items-stretch pr-4 mr-4 border-r-2 border-base-200">
{SETTING_SECTIONS_GENERATED.map((section, idx) => (
<div
key={idx}
className={classNames({
'btn btn-ghost justify-start font-normal w-44 mb-1': true,
'btn-active': sectionIdx === idx,
})}
onClick={() => setSectionIdx(idx)}
dir="auto"
>
{section.title}
</div>
))}
</div>
{/* Left panel, showing sections - Mobile version */}
<div className="md:hidden flex flex-row gap-2 mb-4">
<details className="dropdown" ref={detailsRef}>
<summary className="btn bt-sm w-full m-1">
{SETTING_SECTIONS_GENERATED[sectionIdx].title}
</summary>
<ul className="menu dropdown-content bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
{SETTING_SECTIONS_GENERATED.map((section, idx) => (
<div
key={idx}
className={classNames({
'btn btn-ghost justify-start font-normal': true,
'btn-active': sectionIdx === idx,
})}
onClick={() => {setSectionIdx(idx);
detailsRef.current?.removeAttribute('open');
}}
dir="auto"
>
{section.title}
</div>
))}
</ul>
</details>
</div>
{/* Right panel, showing setting fields */}
<div className="grow overflow-y-auto px-4">
{SETTING_SECTIONS_GENERATED[sectionIdx].fields.map((field, idx) => {
const key = `${sectionIdx}-${idx}`;
if (field.type === SettingInputType.SHORT_INPUT) {
return (
<SettingsModalShortInput
key={key}
configKey={field.key}
value={localConfig[field.key]}
onChange={onChange(field.key)}
label={field.label as string}
/>
);
} else if (field.type === SettingInputType.LONG_INPUT) {
return (
<SettingsModalLongInput
key={key}
configKey={field.key}
value={localConfig[field.key].toString()}
onChange={onChange(field.key)}
label={field.label as string}
/>
);
} else if (field.type === SettingInputType.CHECKBOX) {
return (
<SettingsModalCheckbox
key={key}
configKey={field.key}
value={!!localConfig[field.key]}
onChange={onChange(field.key)}
label={field.label as string}
/>
);
} else if (field.type === SettingInputType.DROPDOWN) {
return (
<SettingsModalDropdown
key={key}
configKey={field.key}
value={localConfig[field.key].toString()}
onChange={onChange(field.key)}
label={field.label as string}
options={field.options?field.options:''}
/>
);
}
else if (field.type === SettingInputType.CUSTOM) {
return (
<div key={key} className="mb-2">
{typeof field.component === 'string'
? field.component
: field.component({
value: localConfig[field.key],
onChange: onChange(field.key),
})}
</div>
);
}
})}
<p className="opacity-40 mb-6 text-sm mt-8">
Settings are saved in browser's localStorage
</p>
</div>
</div>
<div className="modal-action">
<button className="btn" onClick={resetConfig}>
Reset to default
</button>
<button className="btn" onClick={onClose}>
Close
</button>
<button className="btn btn-primary" onClick={handleSave}>
Save
</button>
</div>
</div>
</dialog>
);
}
function SettingsModalLongInput({
configKey,
value,
onChange,
label,
}: {
configKey: SettKey;
value: string;
onChange: (value: string) => void;
label?: string;
}) {
return (
<label className="form-control mb-2">
<div className="label inline">{label || configKey}</div>
<textarea
className="textarea textarea-bordered h-24"
placeholder={`Default: ${CONFIG_DEFAULT[configKey] || 'none'}`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
function SettingsModalShortInput({
configKey,
value,
onChange,
label,
}: {
configKey: SettKey;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
onChange: (value: string) => void;
label?: string;
}) {
const helpMsg = CONFIG_INFO[configKey];
return (
<>
{/* on mobile, we simply show the help message here */}
{helpMsg && (
<div className="block md:hidden mb-1">
<b>{label || configKey}</b>
<br />
<p className="text-xs whitespace-normal">{helpMsg}</p>
</div>
)}
<label className="input input-bordered join-item grow flex items-center gap-2 mb-2">
<div className="dropdown dropdown-hover">
<div tabIndex={0} role="button" className="font-bold hidden md:block">
{label || configKey}
</div>
{helpMsg && (
<div className="dropdown-content menu bg-base-100 rounded-box z-10 w-64 p-2 shadow mt-4 whitespace-normal break-words">
{helpMsg}
</div>
)}
</div>
<input
type="text"
className="grow"
placeholder={`Default: ${CONFIG_DEFAULT[configKey] || 'none'}`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</label>
</>
);
}
function SettingsModalCheckbox({
configKey,
value,
onChange,
label,
}: {
configKey: SettKey;
value: boolean;
onChange: (value: boolean) => void;
label: string;
}) {
return (
<div className="flex flex-row items-center mb-2">
<input
type="checkbox"
className="toggle"
checked={value}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="ml-4">{label || configKey}</span>
</div>
);
}
function SettingsModalDropdown({
configKey,
value,
onChange,
label,
options,
}: {
configKey: SettKey;
value: string; // This should be one of the option values like "Text" or "Chat"
onChange: (value: string) => void;
label: string;
options:string; // Format: "Display Label|Text|Chat"
}) {
const parts = options.split('|');
const selectedValue = options.includes(value) ? value : (options[0] || "");
return (
<div className="flex flex-row items-center mb-2">
<div className="flex flex-col w-full">
<label className="mb-1 text-sm font-medium text-gray-700">
{label || configKey}
</label>
<select
className="select select-bordered w-full max-w-xs"
value={selectedValue}
onChange={(e) => onChange(e.target.value)}
>
{parts .map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
</div>
);
}