|
|
@@ -3,18 +3,27 @@ import { useAppContext } from '../utils/app.context';
|
|
|
import { CONFIG_DEFAULT, CONFIG_INFO } from '../Config';
|
|
|
import { isDev } from '../Config';
|
|
|
import StorageUtils from '../utils/storage';
|
|
|
-import { isBoolean, isNumeric, isString } from '../utils/misc';
|
|
|
+import { classNames, isBoolean, isNumeric, isString } from '../utils/misc';
|
|
|
+import {
|
|
|
+ BeakerIcon,
|
|
|
+ ChatBubbleOvalLeftEllipsisIcon,
|
|
|
+ Cog6ToothIcon,
|
|
|
+ FunnelIcon,
|
|
|
+ HandRaisedIcon,
|
|
|
+ SquaresPlusIcon,
|
|
|
+} from '@heroicons/react/24/outline';
|
|
|
+import { OpenInNewTab } from '../utils/common';
|
|
|
|
|
|
type SettKey = keyof typeof CONFIG_DEFAULT;
|
|
|
|
|
|
-const COMMON_SAMPLER_KEYS: SettKey[] = [
|
|
|
+const BASIC_KEYS: SettKey[] = [
|
|
|
'temperature',
|
|
|
'top_k',
|
|
|
'top_p',
|
|
|
'min_p',
|
|
|
'max_tokens',
|
|
|
];
|
|
|
-const OTHER_SAMPLER_KEYS: SettKey[] = [
|
|
|
+const SAMPLER_KEYS: SettKey[] = [
|
|
|
'dynatemp_range',
|
|
|
'dynatemp_exponent',
|
|
|
'typical_p',
|
|
|
@@ -32,6 +41,223 @@ const PENALTY_KEYS: SettKey[] = [
|
|
|
'dry_penalty_last_n',
|
|
|
];
|
|
|
|
|
|
+enum SettingInputType {
|
|
|
+ SHORT_INPUT,
|
|
|
+ LONG_INPUT,
|
|
|
+ CHECKBOX,
|
|
|
+ CUSTOM,
|
|
|
+}
|
|
|
+
|
|
|
+interface SettingFieldInput {
|
|
|
+ type: Exclude<SettingInputType, SettingInputType.CUSTOM>;
|
|
|
+ label: string | React.ReactElement;
|
|
|
+ help?: string | React.ReactElement;
|
|
|
+ key: SettKey;
|
|
|
+}
|
|
|
+
|
|
|
+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';
|
|
|
+
|
|
|
+const SETTING_SECTIONS: SettingSection[] = [
|
|
|
+ {
|
|
|
+ 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',
|
|
|
+ },
|
|
|
+ ...BASIC_KEYS.map(
|
|
|
+ (key) =>
|
|
|
+ ({
|
|
|
+ type: SettingInputType.SHORT_INPUT,
|
|
|
+ label: key,
|
|
|
+ key,
|
|
|
+ }) as SettingFieldInput
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 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 though process by default for generating message',
|
|
|
+ key: 'showThoughtInProgress',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: SettingInputType.CHECKBOX,
|
|
|
+ label:
|
|
|
+ 'Exclude thought process when sending request to API (Recommended for 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);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ return (
|
|
|
+ <button className="btn" onClick={debugImportDemoConv}>
|
|
|
+ (debug) Import demo conversation
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: SettingInputType.CHECKBOX,
|
|
|
+ label: 'Show tokens per second',
|
|
|
+ key: 'showTokensPerSecond',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: SettingInputType.LONG_INPUT,
|
|
|
+ label: (
|
|
|
+ <>
|
|
|
+ Custom JSON config (For more info, refer to{' '}
|
|
|
+ <OpenInNewTab href="https://github.com/ggerganov/llama.cpp/blob/master/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/ggerganov/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,
|
|
|
@@ -40,6 +266,7 @@ export default function SettingDialog({
|
|
|
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>(
|
|
|
@@ -92,181 +319,109 @@ export default function SettingDialog({
|
|
|
onClose();
|
|
|
};
|
|
|
|
|
|
- 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);
|
|
|
- }
|
|
|
- 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 });
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
- <dialog className={`modal ${show ? 'modal-open' : ''}`}>
|
|
|
- <div className="modal-box">
|
|
|
+ <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="h-[calc(90vh-12rem)] overflow-y-auto">
|
|
|
- <p className="opacity-40 mb-6">
|
|
|
- Settings below are saved in browser's localStorage
|
|
|
- </p>
|
|
|
-
|
|
|
- <SettingsModalShortInput
|
|
|
- configKey="apiKey"
|
|
|
- configDefault={CONFIG_DEFAULT}
|
|
|
- value={localConfig.apiKey}
|
|
|
- onChange={onChange('apiKey')}
|
|
|
- />
|
|
|
-
|
|
|
- <label className="form-control mb-2">
|
|
|
- <div className="label">
|
|
|
- System Message (will be disabled if left empty)
|
|
|
- </div>
|
|
|
- <textarea
|
|
|
- className="textarea textarea-bordered h-24"
|
|
|
- placeholder={`Default: ${CONFIG_DEFAULT.systemMessage}`}
|
|
|
- value={localConfig.systemMessage}
|
|
|
- onChange={(e) => onChange('systemMessage')(e.target.value)}
|
|
|
- />
|
|
|
- </label>
|
|
|
-
|
|
|
- {COMMON_SAMPLER_KEYS.map((key) => (
|
|
|
- <SettingsModalShortInput
|
|
|
- key={key}
|
|
|
- configKey={key}
|
|
|
- configDefault={CONFIG_DEFAULT}
|
|
|
- value={localConfig[key]}
|
|
|
- onChange={onChange(key)}
|
|
|
- />
|
|
|
- ))}
|
|
|
-
|
|
|
- <details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
|
|
- <summary className="collapse-title font-bold">
|
|
|
- Other sampler settings
|
|
|
- </summary>
|
|
|
- <div className="collapse-content">
|
|
|
- <SettingsModalShortInput
|
|
|
- label="Samplers queue"
|
|
|
- configKey="samplers"
|
|
|
- configDefault={CONFIG_DEFAULT}
|
|
|
- value={localConfig.samplers}
|
|
|
- onChange={onChange('samplers')}
|
|
|
- />
|
|
|
- {OTHER_SAMPLER_KEYS.map((key) => (
|
|
|
- <SettingsModalShortInput
|
|
|
- key={key}
|
|
|
- configKey={key}
|
|
|
- configDefault={CONFIG_DEFAULT}
|
|
|
- value={localConfig[key]}
|
|
|
- onChange={onChange(key)}
|
|
|
- />
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </details>
|
|
|
-
|
|
|
- <details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
|
|
- <summary className="collapse-title font-bold">
|
|
|
- Penalties settings
|
|
|
- </summary>
|
|
|
- <div className="collapse-content">
|
|
|
- {PENALTY_KEYS.map((key) => (
|
|
|
- <SettingsModalShortInput
|
|
|
- key={key}
|
|
|
- configKey={key}
|
|
|
- configDefault={CONFIG_DEFAULT}
|
|
|
- value={localConfig[key]}
|
|
|
- onChange={onChange(key)}
|
|
|
- />
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </details>
|
|
|
-
|
|
|
- <details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
|
|
- <summary className="collapse-title font-bold">
|
|
|
- Reasoning models
|
|
|
- </summary>
|
|
|
- <div className="collapse-content">
|
|
|
- <div className="flex flex-row items-center mb-2">
|
|
|
- <input
|
|
|
- type="checkbox"
|
|
|
- className="checkbox"
|
|
|
- checked={localConfig.showThoughtInProgress}
|
|
|
- onChange={(e) =>
|
|
|
- onChange('showThoughtInProgress')(e.target.checked)
|
|
|
- }
|
|
|
- />
|
|
|
- <span className="ml-4">
|
|
|
- Expand though process by default for generating message
|
|
|
- </span>
|
|
|
+ <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.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 className="flex flex-row items-center mb-2">
|
|
|
- <input
|
|
|
- type="checkbox"
|
|
|
- className="checkbox"
|
|
|
- checked={localConfig.excludeThoughtOnReq}
|
|
|
- onChange={(e) =>
|
|
|
- onChange('excludeThoughtOnReq')(e.target.checked)
|
|
|
- }
|
|
|
- />
|
|
|
- <span className="ml-4">
|
|
|
- Exclude thought process when sending request to API
|
|
|
- (Recommended for DeepSeek-R1)
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </details>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
|
|
|
- <details className="collapse collapse-arrow bg-base-200 mb-2 overflow-visible">
|
|
|
- <summary className="collapse-title font-bold">
|
|
|
- Advanced config
|
|
|
- </summary>
|
|
|
- <div className="collapse-content">
|
|
|
- {/* this button only shows in dev mode, used to import a demo conversation to test message rendering */}
|
|
|
- {isDev && (
|
|
|
- <div className="flex flex-row items-center mb-2">
|
|
|
- <button className="btn" onClick={debugImportDemoConv}>
|
|
|
- (debug) Import demo conversation
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- <div className="flex flex-row items-center mb-2">
|
|
|
- <input
|
|
|
- type="checkbox"
|
|
|
- className="checkbox"
|
|
|
- checked={localConfig.showTokensPerSecond}
|
|
|
- onChange={(e) =>
|
|
|
- onChange('showTokensPerSecond')(e.target.checked)
|
|
|
- }
|
|
|
- />
|
|
|
- <span className="ml-4">Show tokens per second</span>
|
|
|
- </div>
|
|
|
- <label className="form-control mb-2">
|
|
|
- <div className="label inline">
|
|
|
- Custom JSON config (For more info, refer to{' '}
|
|
|
- <a
|
|
|
- className="underline"
|
|
|
- href="https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md"
|
|
|
- target="_blank"
|
|
|
- rel="noopener noreferrer"
|
|
|
+ {/* Left panel, showing sections - Mobile version */}
|
|
|
+ <div className="md:hidden flex flex-row gap-2 mb-4">
|
|
|
+ <details className="dropdown">
|
|
|
+ <summary className="btn bt-sm w-full m-1">
|
|
|
+ {SETTING_SECTIONS[sectionIdx].title}
|
|
|
+ </summary>
|
|
|
+ <ul className="menu dropdown-content bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
|
|
|
+ {SETTING_SECTIONS.map((section, idx) => (
|
|
|
+ <div
|
|
|
+ key={idx}
|
|
|
+ className={classNames({
|
|
|
+ 'btn btn-ghost justify-start font-normal': true,
|
|
|
+ 'btn-active': sectionIdx === idx,
|
|
|
+ })}
|
|
|
+ onClick={() => setSectionIdx(idx)}
|
|
|
+ dir="auto"
|
|
|
>
|
|
|
- server documentation
|
|
|
- </a>
|
|
|
- )
|
|
|
- </div>
|
|
|
- <textarea
|
|
|
- className="textarea textarea-bordered h-24"
|
|
|
- placeholder='Example: { "mirostat": 1, "min_p": 0.1 }'
|
|
|
- value={localConfig.custom}
|
|
|
- onChange={(e) => onChange('custom')(e.target.value)}
|
|
|
- />
|
|
|
- </label>
|
|
|
- </div>
|
|
|
- </details>
|
|
|
+ {section.title}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </ul>
|
|
|
+ </details>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Right panel, showing setting fields */}
|
|
|
+ <div className="grow overflow-y-auto px-4">
|
|
|
+ {SETTING_SECTIONS[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.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">
|
|
|
@@ -285,37 +440,97 @@ export default function SettingDialog({
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+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,
|
|
|
- configDefault,
|
|
|
value,
|
|
|
onChange,
|
|
|
label,
|
|
|
}: {
|
|
|
configKey: SettKey;
|
|
|
- configDefault: typeof CONFIG_DEFAULT;
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
value: any;
|
|
|
onChange: (value: string) => void;
|
|
|
label?: string;
|
|
|
}) {
|
|
|
+ const helpMsg = CONFIG_INFO[configKey];
|
|
|
+
|
|
|
return (
|
|
|
- <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">
|
|
|
- {label || configKey}
|
|
|
+ <>
|
|
|
+ {/* 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">{helpMsg}</p>
|
|
|
</div>
|
|
|
- <div className="dropdown-content menu bg-base-100 rounded-box z-10 w-64 p-2 shadow mt-4">
|
|
|
- {CONFIG_INFO[configKey] ?? '(no help message available)'}
|
|
|
+ )}
|
|
|
+ <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">
|
|
|
+ {helpMsg}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</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="text"
|
|
|
- className="grow"
|
|
|
- placeholder={`Default: ${configDefault[configKey] || 'none'}`}
|
|
|
- value={value}
|
|
|
- onChange={(e) => onChange(e.target.value)}
|
|
|
+ type="checkbox"
|
|
|
+ className="toggle"
|
|
|
+ checked={value}
|
|
|
+ onChange={(e) => onChange(e.target.checked)}
|
|
|
/>
|
|
|
- </label>
|
|
|
+ <span className="ml-4">{label || configKey}</span>
|
|
|
+ </div>
|
|
|
);
|
|
|
}
|