settings.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. import { useState, useEffect, useMemo, HTMLProps, useRef } from "react";
  2. import EmojiPicker, { Theme as EmojiTheme } from "emoji-picker-react";
  3. import styles from "./settings.module.scss";
  4. import ResetIcon from "../icons/reload.svg";
  5. import CloseIcon from "../icons/close.svg";
  6. import CopyIcon from "../icons/copy.svg";
  7. import ClearIcon from "../icons/clear.svg";
  8. import EditIcon from "../icons/edit.svg";
  9. import { Input, List, ListItem, Modal, PasswordInput, Popover } from "./ui-lib";
  10. import { IconButton } from "./button";
  11. import {
  12. SubmitKey,
  13. useChatStore,
  14. Theme,
  15. ALL_MODELS,
  16. useUpdateStore,
  17. useAccessStore,
  18. ModalConfigValidator,
  19. useAppConfig,
  20. ChatConfig,
  21. ModelConfig,
  22. } from "../store";
  23. import { Avatar } from "./chat";
  24. import Locale, { AllLangs, changeLang, getLang } from "../locales";
  25. import { copyToClipboard, getEmojiUrl } from "../utils";
  26. import Link from "next/link";
  27. import { Path, UPDATE_URL } from "../constant";
  28. import { Prompt, SearchService, usePromptStore } from "../store/prompt";
  29. import { ErrorBoundary } from "./error";
  30. import { InputRange } from "./input-range";
  31. import { useNavigate } from "react-router-dom";
  32. function UserPromptModal(props: { onClose?: () => void }) {
  33. const promptStore = usePromptStore();
  34. const userPrompts = promptStore.getUserPrompts();
  35. const builtinPrompts = SearchService.builtinPrompts;
  36. const allPrompts = userPrompts.concat(builtinPrompts);
  37. const [searchInput, setSearchInput] = useState("");
  38. const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
  39. const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
  40. useEffect(() => {
  41. if (searchInput.length > 0) {
  42. const searchResult = SearchService.search(searchInput);
  43. setSearchPrompts(searchResult);
  44. } else {
  45. setSearchPrompts([]);
  46. }
  47. }, [searchInput]);
  48. return (
  49. <div className="modal-mask">
  50. <Modal
  51. title={Locale.Settings.Prompt.Modal.Title}
  52. onClose={() => props.onClose?.()}
  53. actions={[
  54. <IconButton
  55. key="add"
  56. onClick={() => promptStore.add({ title: "", content: "" })}
  57. icon={<ClearIcon />}
  58. bordered
  59. text={Locale.Settings.Prompt.Modal.Add}
  60. />,
  61. ]}
  62. >
  63. <div className={styles["user-prompt-modal"]}>
  64. <input
  65. type="text"
  66. className={styles["user-prompt-search"]}
  67. placeholder={Locale.Settings.Prompt.Modal.Search}
  68. value={searchInput}
  69. onInput={(e) => setSearchInput(e.currentTarget.value)}
  70. ></input>
  71. <div className={styles["user-prompt-list"]}>
  72. {prompts.map((v, _) => (
  73. <div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
  74. <div className={styles["user-prompt-header"]}>
  75. <input
  76. type="text"
  77. className={styles["user-prompt-title"]}
  78. value={v.title}
  79. readOnly={!v.isUser}
  80. onChange={(e) => {
  81. if (v.isUser) {
  82. promptStore.updateUserPrompts(
  83. v.id!,
  84. (prompt) => (prompt.title = e.currentTarget.value),
  85. );
  86. }
  87. }}
  88. ></input>
  89. <div className={styles["user-prompt-buttons"]}>
  90. {v.isUser && (
  91. <IconButton
  92. icon={<ClearIcon />}
  93. bordered
  94. className={styles["user-prompt-button"]}
  95. onClick={() => promptStore.remove(v.id!)}
  96. />
  97. )}
  98. <IconButton
  99. icon={<CopyIcon />}
  100. bordered
  101. className={styles["user-prompt-button"]}
  102. onClick={() => copyToClipboard(v.content)}
  103. />
  104. </div>
  105. </div>
  106. <Input
  107. rows={2}
  108. value={v.content}
  109. className={styles["user-prompt-content"]}
  110. readOnly={!v.isUser}
  111. onChange={(e) => {
  112. if (v.isUser) {
  113. promptStore.updateUserPrompts(
  114. v.id!,
  115. (prompt) => (prompt.content = e.currentTarget.value),
  116. );
  117. }
  118. }}
  119. />
  120. </div>
  121. ))}
  122. </div>
  123. </div>
  124. </Modal>
  125. </div>
  126. );
  127. }
  128. function SettingItem(props: {
  129. title: string;
  130. subTitle?: string;
  131. children: JSX.Element;
  132. }) {
  133. return (
  134. <ListItem>
  135. <div className={styles["settings-title"]}>
  136. <div>{props.title}</div>
  137. {props.subTitle && (
  138. <div className={styles["settings-sub-title"]}>{props.subTitle}</div>
  139. )}
  140. </div>
  141. {props.children}
  142. </ListItem>
  143. );
  144. }
  145. export function ModelConfigList(props: {
  146. modelConfig: ModelConfig;
  147. updateConfig: (updater: (config: ModelConfig) => void) => void;
  148. }) {
  149. return (
  150. <>
  151. <SettingItem title={Locale.Settings.Model}>
  152. <select
  153. value={props.modelConfig.model}
  154. onChange={(e) => {
  155. props.updateConfig(
  156. (config) =>
  157. (config.model = ModalConfigValidator.model(
  158. e.currentTarget.value,
  159. )),
  160. );
  161. }}
  162. >
  163. {ALL_MODELS.map((v) => (
  164. <option value={v.name} key={v.name} disabled={!v.available}>
  165. {v.name}
  166. </option>
  167. ))}
  168. </select>
  169. </SettingItem>
  170. <SettingItem
  171. title={Locale.Settings.Temperature.Title}
  172. subTitle={Locale.Settings.Temperature.SubTitle}
  173. >
  174. <InputRange
  175. value={props.modelConfig.temperature?.toFixed(1)}
  176. min="0"
  177. max="2"
  178. step="0.1"
  179. onChange={(e) => {
  180. props.updateConfig(
  181. (config) =>
  182. (config.temperature = ModalConfigValidator.temperature(
  183. e.currentTarget.valueAsNumber,
  184. )),
  185. );
  186. }}
  187. ></InputRange>
  188. </SettingItem>
  189. <SettingItem
  190. title={Locale.Settings.MaxTokens.Title}
  191. subTitle={Locale.Settings.MaxTokens.SubTitle}
  192. >
  193. <input
  194. type="number"
  195. min={100}
  196. max={32000}
  197. value={props.modelConfig.max_tokens}
  198. onChange={(e) =>
  199. props.updateConfig(
  200. (config) =>
  201. (config.max_tokens = ModalConfigValidator.max_tokens(
  202. e.currentTarget.valueAsNumber,
  203. )),
  204. )
  205. }
  206. ></input>
  207. </SettingItem>
  208. <SettingItem
  209. title={Locale.Settings.PresencePenlty.Title}
  210. subTitle={Locale.Settings.PresencePenlty.SubTitle}
  211. >
  212. <InputRange
  213. value={props.modelConfig.presence_penalty?.toFixed(1)}
  214. min="-2"
  215. max="2"
  216. step="0.1"
  217. onChange={(e) => {
  218. props.updateConfig(
  219. (config) =>
  220. (config.presence_penalty =
  221. ModalConfigValidator.presence_penalty(
  222. e.currentTarget.valueAsNumber,
  223. )),
  224. );
  225. }}
  226. ></InputRange>
  227. </SettingItem>
  228. <SettingItem
  229. title={Locale.Settings.HistoryCount.Title}
  230. subTitle={Locale.Settings.HistoryCount.SubTitle}
  231. >
  232. <InputRange
  233. title={props.modelConfig.historyMessageCount.toString()}
  234. value={props.modelConfig.historyMessageCount}
  235. min="0"
  236. max="25"
  237. step="1"
  238. onChange={(e) =>
  239. props.updateConfig(
  240. (config) => (config.historyMessageCount = e.target.valueAsNumber),
  241. )
  242. }
  243. ></InputRange>
  244. </SettingItem>
  245. <SettingItem
  246. title={Locale.Settings.CompressThreshold.Title}
  247. subTitle={Locale.Settings.CompressThreshold.SubTitle}
  248. >
  249. <input
  250. type="number"
  251. min={500}
  252. max={4000}
  253. value={props.modelConfig.compressMessageLengthThreshold}
  254. onChange={(e) =>
  255. props.updateConfig(
  256. (config) =>
  257. (config.compressMessageLengthThreshold =
  258. e.currentTarget.valueAsNumber),
  259. )
  260. }
  261. ></input>
  262. </SettingItem>
  263. </>
  264. );
  265. }
  266. export function Settings() {
  267. const navigate = useNavigate();
  268. const [showEmojiPicker, setShowEmojiPicker] = useState(false);
  269. const config = useAppConfig();
  270. const updateConfig = config.update;
  271. const resetConfig = config.reset;
  272. const [clearAllData, clearSessions] = useChatStore((state) => [
  273. state.clearAllData,
  274. state.clearSessions,
  275. ]);
  276. const updateStore = useUpdateStore();
  277. const [checkingUpdate, setCheckingUpdate] = useState(false);
  278. const currentVersion = updateStore.version;
  279. const remoteId = updateStore.remoteVersion;
  280. const hasNewVersion = currentVersion !== remoteId;
  281. function checkUpdate(force = false) {
  282. setCheckingUpdate(true);
  283. updateStore.getLatestVersion(force).then(() => {
  284. setCheckingUpdate(false);
  285. });
  286. }
  287. const usage = {
  288. used: updateStore.used,
  289. subscription: updateStore.subscription,
  290. };
  291. const [loadingUsage, setLoadingUsage] = useState(false);
  292. function checkUsage() {
  293. setLoadingUsage(true);
  294. updateStore.updateUsage().finally(() => {
  295. setLoadingUsage(false);
  296. });
  297. }
  298. const accessStore = useAccessStore();
  299. const enabledAccessControl = useMemo(
  300. () => accessStore.enabledAccessControl(),
  301. // eslint-disable-next-line react-hooks/exhaustive-deps
  302. [],
  303. );
  304. const promptStore = usePromptStore();
  305. const builtinCount = SearchService.count.builtin;
  306. const customCount = promptStore.getUserPrompts().length ?? 0;
  307. const [shouldShowPromptModal, setShowPromptModal] = useState(false);
  308. const showUsage = accessStore.isAuthorized();
  309. useEffect(() => {
  310. // checks per minutes
  311. checkUpdate();
  312. showUsage && checkUsage();
  313. // eslint-disable-next-line react-hooks/exhaustive-deps
  314. }, []);
  315. useEffect(() => {
  316. const keydownEvent = (e: KeyboardEvent) => {
  317. if (e.key === "Escape") {
  318. navigate(Path.Home);
  319. }
  320. };
  321. document.addEventListener("keydown", keydownEvent);
  322. return () => {
  323. document.removeEventListener("keydown", keydownEvent);
  324. };
  325. // eslint-disable-next-line react-hooks/exhaustive-deps
  326. }, []);
  327. return (
  328. <ErrorBoundary>
  329. <div className={styles["window-header"]}>
  330. <div className={styles["window-header-title"]}>
  331. <div className={styles["window-header-main-title"]}>
  332. {Locale.Settings.Title}
  333. </div>
  334. <div className={styles["window-header-sub-title"]}>
  335. {Locale.Settings.SubTitle}
  336. </div>
  337. </div>
  338. <div className={styles["window-actions"]}>
  339. <div className={styles["window-action-button"]}>
  340. <IconButton
  341. icon={<ClearIcon />}
  342. onClick={() => {
  343. const confirmed = window.confirm(
  344. `${Locale.Settings.Actions.ConfirmClearAll.Confirm}`,
  345. );
  346. if (confirmed) {
  347. clearSessions();
  348. }
  349. }}
  350. bordered
  351. title={Locale.Settings.Actions.ClearAll}
  352. />
  353. </div>
  354. <div className={styles["window-action-button"]}>
  355. <IconButton
  356. icon={<ResetIcon />}
  357. onClick={() => {
  358. const confirmed = window.confirm(
  359. `${Locale.Settings.Actions.ConfirmResetAll.Confirm}`,
  360. );
  361. if (confirmed) {
  362. resetConfig();
  363. }
  364. }}
  365. bordered
  366. title={Locale.Settings.Actions.ResetAll}
  367. />
  368. </div>
  369. <div className={styles["window-action-button"]}>
  370. <IconButton
  371. icon={<CloseIcon />}
  372. onClick={() => navigate(Path.Home)}
  373. bordered
  374. title={Locale.Settings.Actions.Close}
  375. />
  376. </div>
  377. </div>
  378. </div>
  379. <div className={styles["settings"]}>
  380. <List>
  381. <SettingItem title={Locale.Settings.Avatar}>
  382. <Popover
  383. onClose={() => setShowEmojiPicker(false)}
  384. content={
  385. <EmojiPicker
  386. lazyLoadEmojis
  387. theme={EmojiTheme.AUTO}
  388. getEmojiUrl={getEmojiUrl}
  389. onEmojiClick={(e) => {
  390. updateConfig((config) => (config.avatar = e.unified));
  391. setShowEmojiPicker(false);
  392. }}
  393. />
  394. }
  395. open={showEmojiPicker}
  396. >
  397. <div
  398. className={styles.avatar}
  399. onClick={() => setShowEmojiPicker(true)}
  400. >
  401. <Avatar role="user" />
  402. </div>
  403. </Popover>
  404. </SettingItem>
  405. <SettingItem
  406. title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
  407. subTitle={
  408. checkingUpdate
  409. ? Locale.Settings.Update.IsChecking
  410. : hasNewVersion
  411. ? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
  412. : Locale.Settings.Update.IsLatest
  413. }
  414. >
  415. {checkingUpdate ? (
  416. <div />
  417. ) : hasNewVersion ? (
  418. <Link href={UPDATE_URL} target="_blank" className="link">
  419. {Locale.Settings.Update.GoToUpdate}
  420. </Link>
  421. ) : (
  422. <IconButton
  423. icon={<ResetIcon></ResetIcon>}
  424. text={Locale.Settings.Update.CheckUpdate}
  425. onClick={() => checkUpdate(true)}
  426. />
  427. )}
  428. </SettingItem>
  429. <SettingItem title={Locale.Settings.SendKey}>
  430. <select
  431. value={config.submitKey}
  432. onChange={(e) => {
  433. updateConfig(
  434. (config) =>
  435. (config.submitKey = e.target.value as any as SubmitKey),
  436. );
  437. }}
  438. >
  439. {Object.values(SubmitKey).map((v) => (
  440. <option value={v} key={v}>
  441. {v}
  442. </option>
  443. ))}
  444. </select>
  445. </SettingItem>
  446. <ListItem>
  447. <div className={styles["settings-title"]}>
  448. {Locale.Settings.Theme}
  449. </div>
  450. <select
  451. value={config.theme}
  452. onChange={(e) => {
  453. updateConfig(
  454. (config) => (config.theme = e.target.value as any as Theme),
  455. );
  456. }}
  457. >
  458. {Object.values(Theme).map((v) => (
  459. <option value={v} key={v}>
  460. {v}
  461. </option>
  462. ))}
  463. </select>
  464. </ListItem>
  465. <SettingItem title={Locale.Settings.Lang.Name}>
  466. <select
  467. value={getLang()}
  468. onChange={(e) => {
  469. changeLang(e.target.value as any);
  470. }}
  471. >
  472. {AllLangs.map((lang) => (
  473. <option value={lang} key={lang}>
  474. {Locale.Settings.Lang.Options[lang]}
  475. </option>
  476. ))}
  477. </select>
  478. </SettingItem>
  479. <SettingItem
  480. title={Locale.Settings.FontSize.Title}
  481. subTitle={Locale.Settings.FontSize.SubTitle}
  482. >
  483. <InputRange
  484. title={`${config.fontSize ?? 14}px`}
  485. value={config.fontSize}
  486. min="12"
  487. max="18"
  488. step="1"
  489. onChange={(e) =>
  490. updateConfig(
  491. (config) =>
  492. (config.fontSize = Number.parseInt(e.currentTarget.value)),
  493. )
  494. }
  495. ></InputRange>
  496. </SettingItem>
  497. <SettingItem title={Locale.Settings.TightBorder}>
  498. <input
  499. type="checkbox"
  500. checked={config.tightBorder}
  501. onChange={(e) =>
  502. updateConfig(
  503. (config) => (config.tightBorder = e.currentTarget.checked),
  504. )
  505. }
  506. ></input>
  507. </SettingItem>
  508. <SettingItem title={Locale.Settings.SendPreviewBubble}>
  509. <input
  510. type="checkbox"
  511. checked={config.sendPreviewBubble}
  512. onChange={(e) =>
  513. updateConfig(
  514. (config) =>
  515. (config.sendPreviewBubble = e.currentTarget.checked),
  516. )
  517. }
  518. ></input>
  519. </SettingItem>
  520. </List>
  521. <List>
  522. {enabledAccessControl ? (
  523. <SettingItem
  524. title={Locale.Settings.AccessCode.Title}
  525. subTitle={Locale.Settings.AccessCode.SubTitle}
  526. >
  527. <PasswordInput
  528. value={accessStore.accessCode}
  529. type="text"
  530. placeholder={Locale.Settings.AccessCode.Placeholder}
  531. onChange={(e) => {
  532. accessStore.updateCode(e.currentTarget.value);
  533. }}
  534. />
  535. </SettingItem>
  536. ) : (
  537. <></>
  538. )}
  539. <SettingItem
  540. title={Locale.Settings.Token.Title}
  541. subTitle={Locale.Settings.Token.SubTitle}
  542. >
  543. <PasswordInput
  544. value={accessStore.token}
  545. type="text"
  546. placeholder={Locale.Settings.Token.Placeholder}
  547. onChange={(e) => {
  548. accessStore.updateToken(e.currentTarget.value);
  549. }}
  550. />
  551. </SettingItem>
  552. <SettingItem
  553. title={Locale.Settings.Usage.Title}
  554. subTitle={
  555. showUsage
  556. ? loadingUsage
  557. ? Locale.Settings.Usage.IsChecking
  558. : Locale.Settings.Usage.SubTitle(
  559. usage?.used ?? "[?]",
  560. usage?.subscription ?? "[?]",
  561. )
  562. : Locale.Settings.Usage.NoAccess
  563. }
  564. >
  565. {!showUsage || loadingUsage ? (
  566. <div />
  567. ) : (
  568. <IconButton
  569. icon={<ResetIcon></ResetIcon>}
  570. text={Locale.Settings.Usage.Check}
  571. onClick={checkUsage}
  572. />
  573. )}
  574. </SettingItem>
  575. </List>
  576. <List>
  577. <SettingItem
  578. title={Locale.Settings.Prompt.Disable.Title}
  579. subTitle={Locale.Settings.Prompt.Disable.SubTitle}
  580. >
  581. <input
  582. type="checkbox"
  583. checked={config.disablePromptHint}
  584. onChange={(e) =>
  585. updateConfig(
  586. (config) =>
  587. (config.disablePromptHint = e.currentTarget.checked),
  588. )
  589. }
  590. ></input>
  591. </SettingItem>
  592. <SettingItem
  593. title={Locale.Settings.Prompt.List}
  594. subTitle={Locale.Settings.Prompt.ListCount(
  595. builtinCount,
  596. customCount,
  597. )}
  598. >
  599. <IconButton
  600. icon={<EditIcon />}
  601. text={Locale.Settings.Prompt.Edit}
  602. onClick={() => setShowPromptModal(true)}
  603. />
  604. </SettingItem>
  605. </List>
  606. <List>
  607. <ModelConfigList
  608. modelConfig={config.modelConfig}
  609. updateConfig={(upater) => {
  610. const modelConfig = { ...config.modelConfig };
  611. upater(modelConfig);
  612. config.update((config) => (config.modelConfig = modelConfig));
  613. }}
  614. />
  615. </List>
  616. {shouldShowPromptModal && (
  617. <UserPromptModal onClose={() => setShowPromptModal(false)} />
  618. )}
  619. </div>
  620. </ErrorBoundary>
  621. );
  622. }