| | import { useState } from 'react'; |
| | import { useAppContext } from '../utils/app.context'; |
| | import { CONFIG_DEFAULT, CONFIG_INFO } from '../Config'; |
| | import { isDev } from '../Config'; |
| | import StorageUtils from '../utils/storage'; |
| | 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 BASIC_KEYS: SettKey[] = [ |
| | 'temperature', |
| | 'top_k', |
| | 'top_p', |
| | 'min_p', |
| | 'max_tokens', |
| | ]; |
| | const SAMPLER_KEYS: SettKey[] = [ |
| | 'dynatemp_range', |
| | 'dynatemp_exponent', |
| | 'typical_p', |
| | 'xtc_probability', |
| | 'xtc_threshold', |
| | ]; |
| | const PENALTY_KEYS: SettKey[] = [ |
| | 'repeat_last_n', |
| | 'repeat_penalty', |
| | 'presence_penalty', |
| | 'frequency_penalty', |
| | 'dry_multiplier', |
| | 'dry_base', |
| | 'dry_allowed_length', |
| | '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 thought process by default when generating messages', |
| | key: 'showThoughtInProgress', |
| | }, |
| | { |
| | type: SettingInputType.CHECKBOX, |
| | label: |
| | 'Exclude thought process when sending requests to API (Recommended for DeepSeek-R1)', |
| | key: 'excludeThoughtOnReq', |
| | }, |
| | ], |
| | }, |
| | { |
| | title: ( |
| | <> |
| | <SquaresPlusIcon className={ICON_CLASSNAME} /> |
| | Advanced |
| | </> |
| | ), |
| | fields: [ |
| | { |
| | type: SettingInputType.CUSTOM, |
| | key: 'custom', |
| | 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', |
| | 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, |
| | }: { |
| | show: boolean; |
| | onClose: () => void; |
| | }) { |
| | const { config, saveConfig } = useAppContext(); |
| | const [sectionIdx, setSectionIdx] = useState(0); |
| |
|
| | |
| | const [localConfig, setLocalConfig] = useState<typeof CONFIG_DEFAULT>( |
| | JSON.parse(JSON.stringify(config)) |
| | ); |
| |
|
| | const resetConfig = () => { |
| | if (window.confirm('Are you sure you want to reset all settings?')) { |
| | setLocalConfig(CONFIG_DEFAULT); |
| | } |
| | }; |
| |
|
| | const handleSave = () => { |
| | |
| | const newConfig: typeof CONFIG_DEFAULT = JSON.parse( |
| | JSON.stringify(localConfig) |
| | ); |
| | |
| | for (const key in newConfig) { |
| | 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; |
| | } |
| | |
| | |
| | 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}`); |
| | } |
| | } |
| | if (isDev) console.log('Saving config', newConfig); |
| | saveConfig(newConfig); |
| | onClose(); |
| | }; |
| |
|
| | const onChange = (key: SettKey) => (value: string | boolean) => { |
| | |
| | setLocalConfig({ ...localConfig, [key]: value }); |
| | }; |
| |
|
| | 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.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"> |
| | <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" |
| | > |
| | {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"> |
| | <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">{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"> |
| | {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> |
| | ); |
| | } |
| |
|