889 lines
26 KiB
TypeScript
889 lines
26 KiB
TypeScript
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>
|
|
);
|
|
} |