| 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> |
| ); |
| } |
|
|