SettingDialog.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import { useState } from 'react';
  2. import { useAppContext } from '../utils/app.context';
  3. import { CONFIG_DEFAULT, CONFIG_INFO } from '../Config';
  4. import { isDev } from '../Config';
  5. import StorageUtils from '../utils/storage';
  6. import { classNames, isBoolean, isNumeric, isString } from '../utils/misc';
  7. import {
  8. BeakerIcon,
  9. ChatBubbleOvalLeftEllipsisIcon,
  10. Cog6ToothIcon,
  11. FunnelIcon,
  12. HandRaisedIcon,
  13. SquaresPlusIcon,
  14. } from '@heroicons/react/24/outline';
  15. import { OpenInNewTab } from '../utils/common';
  16. type SettKey = keyof typeof CONFIG_DEFAULT;
  17. const BASIC_KEYS: SettKey[] = [
  18. 'temperature',
  19. 'top_k',
  20. 'top_p',
  21. 'min_p',
  22. 'max_tokens',
  23. ];
  24. const SAMPLER_KEYS: SettKey[] = [
  25. 'dynatemp_range',
  26. 'dynatemp_exponent',
  27. 'typical_p',
  28. 'xtc_probability',
  29. 'xtc_threshold',
  30. ];
  31. const PENALTY_KEYS: SettKey[] = [
  32. 'repeat_last_n',
  33. 'repeat_penalty',
  34. 'presence_penalty',
  35. 'frequency_penalty',
  36. 'dry_multiplier',
  37. 'dry_base',
  38. 'dry_allowed_length',
  39. 'dry_penalty_last_n',
  40. ];
  41. enum SettingInputType {
  42. SHORT_INPUT,
  43. LONG_INPUT,
  44. CHECKBOX,
  45. CUSTOM,
  46. }
  47. interface SettingFieldInput {
  48. type: Exclude<SettingInputType, SettingInputType.CUSTOM>;
  49. label: string | React.ReactElement;
  50. help?: string | React.ReactElement;
  51. key: SettKey;
  52. }
  53. interface SettingFieldCustom {
  54. type: SettingInputType.CUSTOM;
  55. key: SettKey;
  56. component:
  57. | string
  58. | React.FC<{
  59. value: string | boolean | number;
  60. onChange: (value: string) => void;
  61. }>;
  62. }
  63. interface SettingSection {
  64. title: React.ReactElement;
  65. fields: (SettingFieldInput | SettingFieldCustom)[];
  66. }
  67. const ICON_CLASSNAME = 'w-4 h-4 mr-1 inline';
  68. const SETTING_SECTIONS: SettingSection[] = [
  69. {
  70. title: (
  71. <>
  72. <Cog6ToothIcon className={ICON_CLASSNAME} />
  73. General
  74. </>
  75. ),
  76. fields: [
  77. {
  78. type: SettingInputType.SHORT_INPUT,
  79. label: 'API Key',
  80. key: 'apiKey',
  81. },
  82. {
  83. type: SettingInputType.LONG_INPUT,
  84. label: 'System Message (will be disabled if left empty)',
  85. key: 'systemMessage',
  86. },
  87. ...BASIC_KEYS.map(
  88. (key) =>
  89. ({
  90. type: SettingInputType.SHORT_INPUT,
  91. label: key,
  92. key,
  93. }) as SettingFieldInput
  94. ),
  95. ],
  96. },
  97. {
  98. title: (
  99. <>
  100. <FunnelIcon className={ICON_CLASSNAME} />
  101. Samplers
  102. </>
  103. ),
  104. fields: [
  105. {
  106. type: SettingInputType.SHORT_INPUT,
  107. label: 'Samplers queue',
  108. key: 'samplers',
  109. },
  110. ...SAMPLER_KEYS.map(
  111. (key) =>
  112. ({
  113. type: SettingInputType.SHORT_INPUT,
  114. label: key,
  115. key,
  116. }) as SettingFieldInput
  117. ),
  118. ],
  119. },
  120. {
  121. title: (
  122. <>
  123. <HandRaisedIcon className={ICON_CLASSNAME} />
  124. Penalties
  125. </>
  126. ),
  127. fields: PENALTY_KEYS.map((key) => ({
  128. type: SettingInputType.SHORT_INPUT,
  129. label: key,
  130. key,
  131. })),
  132. },
  133. {
  134. title: (
  135. <>
  136. <ChatBubbleOvalLeftEllipsisIcon className={ICON_CLASSNAME} />
  137. Reasoning
  138. </>
  139. ),
  140. fields: [
  141. {
  142. type: SettingInputType.CHECKBOX,
  143. label: 'Expand thought process by default when generating messages',
  144. key: 'showThoughtInProgress',
  145. },
  146. {
  147. type: SettingInputType.CHECKBOX,
  148. label:
  149. 'Exclude thought process when sending requests to API (Recommended for DeepSeek-R1)',
  150. key: 'excludeThoughtOnReq',
  151. },
  152. ],
  153. },
  154. {
  155. title: (
  156. <>
  157. <SquaresPlusIcon className={ICON_CLASSNAME} />
  158. Advanced
  159. </>
  160. ),
  161. fields: [
  162. {
  163. type: SettingInputType.CUSTOM,
  164. key: 'custom', // dummy key, won't be used
  165. component: () => {
  166. const debugImportDemoConv = async () => {
  167. const res = await fetch('/demo-conversation.json');
  168. const demoConv = await res.json();
  169. StorageUtils.remove(demoConv.id);
  170. for (const msg of demoConv.messages) {
  171. StorageUtils.appendMsg(demoConv.id, msg);
  172. }
  173. };
  174. return (
  175. <button className="btn" onClick={debugImportDemoConv}>
  176. (debug) Import demo conversation
  177. </button>
  178. );
  179. },
  180. },
  181. {
  182. type: SettingInputType.CHECKBOX,
  183. label: 'Show tokens per second',
  184. key: 'showTokensPerSecond',
  185. },
  186. {
  187. type: SettingInputType.LONG_INPUT,
  188. label: (
  189. <>
  190. Custom JSON config (For more info, refer to{' '}
  191. <OpenInNewTab href="https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md">
  192. server documentation
  193. </OpenInNewTab>
  194. )
  195. </>
  196. ),
  197. key: 'custom',
  198. },
  199. ],
  200. },
  201. {
  202. title: (
  203. <>
  204. <BeakerIcon className={ICON_CLASSNAME} />
  205. Experimental
  206. </>
  207. ),
  208. fields: [
  209. {
  210. type: SettingInputType.CUSTOM,
  211. key: 'custom', // dummy key, won't be used
  212. component: () => (
  213. <>
  214. <p className="mb-8">
  215. Experimental features are not guaranteed to work correctly.
  216. <br />
  217. <br />
  218. If you encounter any problems, create a{' '}
  219. <OpenInNewTab href="https://github.com/ggerganov/llama.cpp/issues/new?template=019-bug-misc.yml">
  220. Bug (misc.)
  221. </OpenInNewTab>{' '}
  222. report on Github. Please also specify <b>webui/experimental</b> on
  223. the report title and include screenshots.
  224. <br />
  225. <br />
  226. Some features may require packages downloaded from CDN, so they
  227. need internet connection.
  228. </p>
  229. </>
  230. ),
  231. },
  232. {
  233. type: SettingInputType.CHECKBOX,
  234. label: (
  235. <>
  236. <b>Enable Python interpreter</b>
  237. <br />
  238. <small className="text-xs">
  239. This feature uses{' '}
  240. <OpenInNewTab href="https://pyodide.org">pyodide</OpenInNewTab>,
  241. downloaded from CDN. To use this feature, ask the LLM to generate
  242. Python code inside a Markdown code block. You will see a "Run"
  243. button on the code block, near the "Copy" button.
  244. </small>
  245. </>
  246. ),
  247. key: 'pyIntepreterEnabled',
  248. },
  249. ],
  250. },
  251. ];
  252. export default function SettingDialog({
  253. show,
  254. onClose,
  255. }: {
  256. show: boolean;
  257. onClose: () => void;
  258. }) {
  259. const { config, saveConfig } = useAppContext();
  260. const [sectionIdx, setSectionIdx] = useState(0);
  261. // clone the config object to prevent direct mutation
  262. const [localConfig, setLocalConfig] = useState<typeof CONFIG_DEFAULT>(
  263. JSON.parse(JSON.stringify(config))
  264. );
  265. const resetConfig = () => {
  266. if (window.confirm('Are you sure you want to reset all settings?')) {
  267. setLocalConfig(CONFIG_DEFAULT);
  268. }
  269. };
  270. const handleSave = () => {
  271. // copy the local config to prevent direct mutation
  272. const newConfig: typeof CONFIG_DEFAULT = JSON.parse(
  273. JSON.stringify(localConfig)
  274. );
  275. // validate the config
  276. for (const key in newConfig) {
  277. const value = newConfig[key as SettKey];
  278. const mustBeBoolean = isBoolean(CONFIG_DEFAULT[key as SettKey]);
  279. const mustBeString = isString(CONFIG_DEFAULT[key as SettKey]);
  280. const mustBeNumeric = isNumeric(CONFIG_DEFAULT[key as SettKey]);
  281. if (mustBeString) {
  282. if (!isString(value)) {
  283. alert(`Value for ${key} must be string`);
  284. return;
  285. }
  286. } else if (mustBeNumeric) {
  287. const trimmedValue = value.toString().trim();
  288. const numVal = Number(trimmedValue);
  289. if (isNaN(numVal) || !isNumeric(numVal) || trimmedValue.length === 0) {
  290. alert(`Value for ${key} must be numeric`);
  291. return;
  292. }
  293. // force conversion to number
  294. // @ts-expect-error this is safe
  295. newConfig[key] = numVal;
  296. } else if (mustBeBoolean) {
  297. if (!isBoolean(value)) {
  298. alert(`Value for ${key} must be boolean`);
  299. return;
  300. }
  301. } else {
  302. console.error(`Unknown default type for key ${key}`);
  303. }
  304. }
  305. if (isDev) console.log('Saving config', newConfig);
  306. saveConfig(newConfig);
  307. onClose();
  308. };
  309. const onChange = (key: SettKey) => (value: string | boolean) => {
  310. // note: we do not perform validation here, because we may get incomplete value as user is still typing it
  311. setLocalConfig({ ...localConfig, [key]: value });
  312. };
  313. return (
  314. <dialog className={classNames({ modal: true, 'modal-open': show })}>
  315. <div className="modal-box w-11/12 max-w-3xl">
  316. <h3 className="text-lg font-bold mb-6">Settings</h3>
  317. <div className="flex flex-col md:flex-row h-[calc(90vh-12rem)]">
  318. {/* Left panel, showing sections - Desktop version */}
  319. <div className="hidden md:flex flex-col items-stretch pr-4 mr-4 border-r-2 border-base-200">
  320. {SETTING_SECTIONS.map((section, idx) => (
  321. <div
  322. key={idx}
  323. className={classNames({
  324. 'btn btn-ghost justify-start font-normal w-44 mb-1': true,
  325. 'btn-active': sectionIdx === idx,
  326. })}
  327. onClick={() => setSectionIdx(idx)}
  328. dir="auto"
  329. >
  330. {section.title}
  331. </div>
  332. ))}
  333. </div>
  334. {/* Left panel, showing sections - Mobile version */}
  335. <div className="md:hidden flex flex-row gap-2 mb-4">
  336. <details className="dropdown">
  337. <summary className="btn bt-sm w-full m-1">
  338. {SETTING_SECTIONS[sectionIdx].title}
  339. </summary>
  340. <ul className="menu dropdown-content bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
  341. {SETTING_SECTIONS.map((section, idx) => (
  342. <div
  343. key={idx}
  344. className={classNames({
  345. 'btn btn-ghost justify-start font-normal': true,
  346. 'btn-active': sectionIdx === idx,
  347. })}
  348. onClick={() => setSectionIdx(idx)}
  349. dir="auto"
  350. >
  351. {section.title}
  352. </div>
  353. ))}
  354. </ul>
  355. </details>
  356. </div>
  357. {/* Right panel, showing setting fields */}
  358. <div className="grow overflow-y-auto px-4">
  359. {SETTING_SECTIONS[sectionIdx].fields.map((field, idx) => {
  360. const key = `${sectionIdx}-${idx}`;
  361. if (field.type === SettingInputType.SHORT_INPUT) {
  362. return (
  363. <SettingsModalShortInput
  364. key={key}
  365. configKey={field.key}
  366. value={localConfig[field.key]}
  367. onChange={onChange(field.key)}
  368. label={field.label as string}
  369. />
  370. );
  371. } else if (field.type === SettingInputType.LONG_INPUT) {
  372. return (
  373. <SettingsModalLongInput
  374. key={key}
  375. configKey={field.key}
  376. value={localConfig[field.key].toString()}
  377. onChange={onChange(field.key)}
  378. label={field.label as string}
  379. />
  380. );
  381. } else if (field.type === SettingInputType.CHECKBOX) {
  382. return (
  383. <SettingsModalCheckbox
  384. key={key}
  385. configKey={field.key}
  386. value={!!localConfig[field.key]}
  387. onChange={onChange(field.key)}
  388. label={field.label as string}
  389. />
  390. );
  391. } else if (field.type === SettingInputType.CUSTOM) {
  392. return (
  393. <div key={key} className="mb-2">
  394. {typeof field.component === 'string'
  395. ? field.component
  396. : field.component({
  397. value: localConfig[field.key],
  398. onChange: onChange(field.key),
  399. })}
  400. </div>
  401. );
  402. }
  403. })}
  404. <p className="opacity-40 mb-6 text-sm mt-8">
  405. Settings are saved in browser's localStorage
  406. </p>
  407. </div>
  408. </div>
  409. <div className="modal-action">
  410. <button className="btn" onClick={resetConfig}>
  411. Reset to default
  412. </button>
  413. <button className="btn" onClick={onClose}>
  414. Close
  415. </button>
  416. <button className="btn btn-primary" onClick={handleSave}>
  417. Save
  418. </button>
  419. </div>
  420. </div>
  421. </dialog>
  422. );
  423. }
  424. function SettingsModalLongInput({
  425. configKey,
  426. value,
  427. onChange,
  428. label,
  429. }: {
  430. configKey: SettKey;
  431. value: string;
  432. onChange: (value: string) => void;
  433. label?: string;
  434. }) {
  435. return (
  436. <label className="form-control mb-2">
  437. <div className="label inline">{label || configKey}</div>
  438. <textarea
  439. className="textarea textarea-bordered h-24"
  440. placeholder={`Default: ${CONFIG_DEFAULT[configKey] || 'none'}`}
  441. value={value}
  442. onChange={(e) => onChange(e.target.value)}
  443. />
  444. </label>
  445. );
  446. }
  447. function SettingsModalShortInput({
  448. configKey,
  449. value,
  450. onChange,
  451. label,
  452. }: {
  453. configKey: SettKey;
  454. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  455. value: any;
  456. onChange: (value: string) => void;
  457. label?: string;
  458. }) {
  459. const helpMsg = CONFIG_INFO[configKey];
  460. return (
  461. <>
  462. {/* on mobile, we simply show the help message here */}
  463. {helpMsg && (
  464. <div className="block md:hidden mb-1">
  465. <b>{label || configKey}</b>
  466. <br />
  467. <p className="text-xs">{helpMsg}</p>
  468. </div>
  469. )}
  470. <label className="input input-bordered join-item grow flex items-center gap-2 mb-2">
  471. <div className="dropdown dropdown-hover">
  472. <div tabIndex={0} role="button" className="font-bold hidden md:block">
  473. {label || configKey}
  474. </div>
  475. {helpMsg && (
  476. <div className="dropdown-content menu bg-base-100 rounded-box z-10 w-64 p-2 shadow mt-4">
  477. {helpMsg}
  478. </div>
  479. )}
  480. </div>
  481. <input
  482. type="text"
  483. className="grow"
  484. placeholder={`Default: ${CONFIG_DEFAULT[configKey] || 'none'}`}
  485. value={value}
  486. onChange={(e) => onChange(e.target.value)}
  487. />
  488. </label>
  489. </>
  490. );
  491. }
  492. function SettingsModalCheckbox({
  493. configKey,
  494. value,
  495. onChange,
  496. label,
  497. }: {
  498. configKey: SettKey;
  499. value: boolean;
  500. onChange: (value: boolean) => void;
  501. label: string;
  502. }) {
  503. return (
  504. <div className="flex flex-row items-center mb-2">
  505. <input
  506. type="checkbox"
  507. className="toggle"
  508. checked={value}
  509. onChange={(e) => onChange(e.target.checked)}
  510. />
  511. <span className="ml-4">{label || configKey}</span>
  512. </div>
  513. );
  514. }