settings.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  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 EyeIcon from "../icons/eye.svg";
  10. import EyeOffIcon from "../icons/eye-off.svg";
  11. import { Input, List, ListItem, Modal, Popover } from "./ui-lib";
  12. import { IconButton } from "./button";
  13. import {
  14. SubmitKey,
  15. useChatStore,
  16. Theme,
  17. ALL_MODELS,
  18. useUpdateStore,
  19. useAccessStore,
  20. ModalConfigValidator,
  21. useAppConfig,
  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. function PasswordInput(props: HTMLProps<HTMLInputElement>) {
  146. const [visible, setVisible] = useState(false);
  147. function changeVisibility() {
  148. setVisible(!visible);
  149. }
  150. return (
  151. <div className={styles["password-input-container"]}>
  152. <IconButton
  153. icon={visible ? <EyeIcon /> : <EyeOffIcon />}
  154. onClick={changeVisibility}
  155. className={styles["password-eye"]}
  156. />
  157. <input
  158. {...props}
  159. type={visible ? "text" : "password"}
  160. className={styles["password-input"]}
  161. />
  162. </div>
  163. );
  164. }
  165. export function Settings() {
  166. const navigate = useNavigate();
  167. const [showEmojiPicker, setShowEmojiPicker] = useState(false);
  168. const config = useAppConfig();
  169. const updateConfig = config.update;
  170. const resetConfig = config.reset;
  171. const [clearAllData, clearSessions] = useChatStore((state) => [
  172. state.clearAllData,
  173. state.clearSessions,
  174. ]);
  175. const updateStore = useUpdateStore();
  176. const [checkingUpdate, setCheckingUpdate] = useState(false);
  177. const currentVersion = updateStore.version;
  178. const remoteId = updateStore.remoteVersion;
  179. const hasNewVersion = currentVersion !== remoteId;
  180. function checkUpdate(force = false) {
  181. setCheckingUpdate(true);
  182. updateStore.getLatestVersion(force).then(() => {
  183. setCheckingUpdate(false);
  184. });
  185. }
  186. const usage = {
  187. used: updateStore.used,
  188. subscription: updateStore.subscription,
  189. };
  190. const [loadingUsage, setLoadingUsage] = useState(false);
  191. function checkUsage() {
  192. setLoadingUsage(true);
  193. updateStore.updateUsage().finally(() => {
  194. setLoadingUsage(false);
  195. });
  196. }
  197. const accessStore = useAccessStore();
  198. const enabledAccessControl = useMemo(
  199. () => accessStore.enabledAccessControl(),
  200. // eslint-disable-next-line react-hooks/exhaustive-deps
  201. [],
  202. );
  203. const promptStore = usePromptStore();
  204. const builtinCount = SearchService.count.builtin;
  205. const customCount = promptStore.getUserPrompts().length ?? 0;
  206. const [shouldShowPromptModal, setShowPromptModal] = useState(false);
  207. const showUsage = accessStore.isAuthorized();
  208. useEffect(() => {
  209. // checks per minutes
  210. checkUpdate();
  211. showUsage && checkUsage();
  212. // eslint-disable-next-line react-hooks/exhaustive-deps
  213. }, []);
  214. useEffect(() => {
  215. const keydownEvent = (e: KeyboardEvent) => {
  216. if (e.key === "Escape") {
  217. navigate(Path.Home);
  218. }
  219. };
  220. document.addEventListener("keydown", keydownEvent);
  221. return () => {
  222. document.removeEventListener("keydown", keydownEvent);
  223. };
  224. // eslint-disable-next-line react-hooks/exhaustive-deps
  225. }, []);
  226. return (
  227. <ErrorBoundary>
  228. <div className={styles["window-header"]}>
  229. <div className={styles["window-header-title"]}>
  230. <div className={styles["window-header-main-title"]}>
  231. {Locale.Settings.Title}
  232. </div>
  233. <div className={styles["window-header-sub-title"]}>
  234. {Locale.Settings.SubTitle}
  235. </div>
  236. </div>
  237. <div className={styles["window-actions"]}>
  238. <div className={styles["window-action-button"]}>
  239. <IconButton
  240. icon={<ClearIcon />}
  241. onClick={() => {
  242. const confirmed = window.confirm(
  243. `${Locale.Settings.Actions.ConfirmClearAll.Confirm}`,
  244. );
  245. if (confirmed) {
  246. clearSessions();
  247. }
  248. }}
  249. bordered
  250. title={Locale.Settings.Actions.ClearAll}
  251. />
  252. </div>
  253. <div className={styles["window-action-button"]}>
  254. <IconButton
  255. icon={<ResetIcon />}
  256. onClick={() => {
  257. const confirmed = window.confirm(
  258. `${Locale.Settings.Actions.ConfirmResetAll.Confirm}`,
  259. );
  260. if (confirmed) {
  261. resetConfig();
  262. }
  263. }}
  264. bordered
  265. title={Locale.Settings.Actions.ResetAll}
  266. />
  267. </div>
  268. <div className={styles["window-action-button"]}>
  269. <IconButton
  270. icon={<CloseIcon />}
  271. onClick={() => navigate(Path.Home)}
  272. bordered
  273. title={Locale.Settings.Actions.Close}
  274. />
  275. </div>
  276. </div>
  277. </div>
  278. <div className={styles["settings"]}>
  279. <List>
  280. <SettingItem title={Locale.Settings.Avatar}>
  281. <Popover
  282. onClose={() => setShowEmojiPicker(false)}
  283. content={
  284. <EmojiPicker
  285. lazyLoadEmojis
  286. theme={EmojiTheme.AUTO}
  287. getEmojiUrl={getEmojiUrl}
  288. onEmojiClick={(e) => {
  289. updateConfig((config) => (config.avatar = e.unified));
  290. setShowEmojiPicker(false);
  291. }}
  292. />
  293. }
  294. open={showEmojiPicker}
  295. >
  296. <div
  297. className={styles.avatar}
  298. onClick={() => setShowEmojiPicker(true)}
  299. >
  300. <Avatar role="user" />
  301. </div>
  302. </Popover>
  303. </SettingItem>
  304. <SettingItem
  305. title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
  306. subTitle={
  307. checkingUpdate
  308. ? Locale.Settings.Update.IsChecking
  309. : hasNewVersion
  310. ? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
  311. : Locale.Settings.Update.IsLatest
  312. }
  313. >
  314. {checkingUpdate ? (
  315. <div />
  316. ) : hasNewVersion ? (
  317. <Link href={UPDATE_URL} target="_blank" className="link">
  318. {Locale.Settings.Update.GoToUpdate}
  319. </Link>
  320. ) : (
  321. <IconButton
  322. icon={<ResetIcon></ResetIcon>}
  323. text={Locale.Settings.Update.CheckUpdate}
  324. onClick={() => checkUpdate(true)}
  325. />
  326. )}
  327. </SettingItem>
  328. <SettingItem title={Locale.Settings.SendKey}>
  329. <select
  330. value={config.submitKey}
  331. onChange={(e) => {
  332. updateConfig(
  333. (config) =>
  334. (config.submitKey = e.target.value as any as SubmitKey),
  335. );
  336. }}
  337. >
  338. {Object.values(SubmitKey).map((v) => (
  339. <option value={v} key={v}>
  340. {v}
  341. </option>
  342. ))}
  343. </select>
  344. </SettingItem>
  345. <ListItem>
  346. <div className={styles["settings-title"]}>
  347. {Locale.Settings.Theme}
  348. </div>
  349. <select
  350. value={config.theme}
  351. onChange={(e) => {
  352. updateConfig(
  353. (config) => (config.theme = e.target.value as any as Theme),
  354. );
  355. }}
  356. >
  357. {Object.values(Theme).map((v) => (
  358. <option value={v} key={v}>
  359. {v}
  360. </option>
  361. ))}
  362. </select>
  363. </ListItem>
  364. <SettingItem title={Locale.Settings.Lang.Name}>
  365. <select
  366. value={getLang()}
  367. onChange={(e) => {
  368. changeLang(e.target.value as any);
  369. }}
  370. >
  371. {AllLangs.map((lang) => (
  372. <option value={lang} key={lang}>
  373. {Locale.Settings.Lang.Options[lang]}
  374. </option>
  375. ))}
  376. </select>
  377. </SettingItem>
  378. <SettingItem
  379. title={Locale.Settings.FontSize.Title}
  380. subTitle={Locale.Settings.FontSize.SubTitle}
  381. >
  382. <InputRange
  383. title={`${config.fontSize ?? 14}px`}
  384. value={config.fontSize}
  385. min="12"
  386. max="18"
  387. step="1"
  388. onChange={(e) =>
  389. updateConfig(
  390. (config) =>
  391. (config.fontSize = Number.parseInt(e.currentTarget.value)),
  392. )
  393. }
  394. ></InputRange>
  395. </SettingItem>
  396. <SettingItem title={Locale.Settings.TightBorder}>
  397. <input
  398. type="checkbox"
  399. checked={config.tightBorder}
  400. onChange={(e) =>
  401. updateConfig(
  402. (config) => (config.tightBorder = e.currentTarget.checked),
  403. )
  404. }
  405. ></input>
  406. </SettingItem>
  407. <SettingItem title={Locale.Settings.SendPreviewBubble}>
  408. <input
  409. type="checkbox"
  410. checked={config.sendPreviewBubble}
  411. onChange={(e) =>
  412. updateConfig(
  413. (config) =>
  414. (config.sendPreviewBubble = e.currentTarget.checked),
  415. )
  416. }
  417. ></input>
  418. </SettingItem>
  419. </List>
  420. <List>
  421. {enabledAccessControl ? (
  422. <SettingItem
  423. title={Locale.Settings.AccessCode.Title}
  424. subTitle={Locale.Settings.AccessCode.SubTitle}
  425. >
  426. <PasswordInput
  427. value={accessStore.accessCode}
  428. type="text"
  429. placeholder={Locale.Settings.AccessCode.Placeholder}
  430. onChange={(e) => {
  431. accessStore.updateCode(e.currentTarget.value);
  432. }}
  433. />
  434. </SettingItem>
  435. ) : (
  436. <></>
  437. )}
  438. <SettingItem
  439. title={Locale.Settings.Token.Title}
  440. subTitle={Locale.Settings.Token.SubTitle}
  441. >
  442. <PasswordInput
  443. value={accessStore.token}
  444. type="text"
  445. placeholder={Locale.Settings.Token.Placeholder}
  446. onChange={(e) => {
  447. accessStore.updateToken(e.currentTarget.value);
  448. }}
  449. />
  450. </SettingItem>
  451. <SettingItem
  452. title={Locale.Settings.Usage.Title}
  453. subTitle={
  454. showUsage
  455. ? loadingUsage
  456. ? Locale.Settings.Usage.IsChecking
  457. : Locale.Settings.Usage.SubTitle(
  458. usage?.used ?? "[?]",
  459. usage?.subscription ?? "[?]",
  460. )
  461. : Locale.Settings.Usage.NoAccess
  462. }
  463. >
  464. {!showUsage || loadingUsage ? (
  465. <div />
  466. ) : (
  467. <IconButton
  468. icon={<ResetIcon></ResetIcon>}
  469. text={Locale.Settings.Usage.Check}
  470. onClick={checkUsage}
  471. />
  472. )}
  473. </SettingItem>
  474. <SettingItem
  475. title={Locale.Settings.HistoryCount.Title}
  476. subTitle={Locale.Settings.HistoryCount.SubTitle}
  477. >
  478. <InputRange
  479. title={config.historyMessageCount.toString()}
  480. value={config.historyMessageCount}
  481. min="0"
  482. max="25"
  483. step="1"
  484. onChange={(e) =>
  485. updateConfig(
  486. (config) =>
  487. (config.historyMessageCount = e.target.valueAsNumber),
  488. )
  489. }
  490. ></InputRange>
  491. </SettingItem>
  492. <SettingItem
  493. title={Locale.Settings.CompressThreshold.Title}
  494. subTitle={Locale.Settings.CompressThreshold.SubTitle}
  495. >
  496. <input
  497. type="number"
  498. min={500}
  499. max={4000}
  500. value={config.compressMessageLengthThreshold}
  501. onChange={(e) =>
  502. updateConfig(
  503. (config) =>
  504. (config.compressMessageLengthThreshold =
  505. e.currentTarget.valueAsNumber),
  506. )
  507. }
  508. ></input>
  509. </SettingItem>
  510. </List>
  511. <List>
  512. <SettingItem
  513. title={Locale.Settings.Prompt.Disable.Title}
  514. subTitle={Locale.Settings.Prompt.Disable.SubTitle}
  515. >
  516. <input
  517. type="checkbox"
  518. checked={config.disablePromptHint}
  519. onChange={(e) =>
  520. updateConfig(
  521. (config) =>
  522. (config.disablePromptHint = e.currentTarget.checked),
  523. )
  524. }
  525. ></input>
  526. </SettingItem>
  527. <SettingItem
  528. title={Locale.Settings.Prompt.List}
  529. subTitle={Locale.Settings.Prompt.ListCount(
  530. builtinCount,
  531. customCount,
  532. )}
  533. >
  534. <IconButton
  535. icon={<EditIcon />}
  536. text={Locale.Settings.Prompt.Edit}
  537. onClick={() => setShowPromptModal(true)}
  538. />
  539. </SettingItem>
  540. </List>
  541. <List>
  542. <SettingItem title={Locale.Settings.Model}>
  543. <select
  544. value={config.modelConfig.model}
  545. onChange={(e) => {
  546. updateConfig(
  547. (config) =>
  548. (config.modelConfig.model = ModalConfigValidator.model(
  549. e.currentTarget.value,
  550. )),
  551. );
  552. }}
  553. >
  554. {ALL_MODELS.map((v) => (
  555. <option value={v.name} key={v.name} disabled={!v.available}>
  556. {v.name}
  557. </option>
  558. ))}
  559. </select>
  560. </SettingItem>
  561. <SettingItem
  562. title={Locale.Settings.Temperature.Title}
  563. subTitle={Locale.Settings.Temperature.SubTitle}
  564. >
  565. <InputRange
  566. value={config.modelConfig.temperature?.toFixed(1)}
  567. min="0"
  568. max="2"
  569. step="0.1"
  570. onChange={(e) => {
  571. updateConfig(
  572. (config) =>
  573. (config.modelConfig.temperature =
  574. ModalConfigValidator.temperature(
  575. e.currentTarget.valueAsNumber,
  576. )),
  577. );
  578. }}
  579. ></InputRange>
  580. </SettingItem>
  581. <SettingItem
  582. title={Locale.Settings.MaxTokens.Title}
  583. subTitle={Locale.Settings.MaxTokens.SubTitle}
  584. >
  585. <input
  586. type="number"
  587. min={100}
  588. max={32000}
  589. value={config.modelConfig.max_tokens}
  590. onChange={(e) =>
  591. updateConfig(
  592. (config) =>
  593. (config.modelConfig.max_tokens =
  594. ModalConfigValidator.max_tokens(
  595. e.currentTarget.valueAsNumber,
  596. )),
  597. )
  598. }
  599. ></input>
  600. </SettingItem>
  601. <SettingItem
  602. title={Locale.Settings.PresencePenlty.Title}
  603. subTitle={Locale.Settings.PresencePenlty.SubTitle}
  604. >
  605. <InputRange
  606. value={config.modelConfig.presence_penalty?.toFixed(1)}
  607. min="-2"
  608. max="2"
  609. step="0.5"
  610. onChange={(e) => {
  611. updateConfig(
  612. (config) =>
  613. (config.modelConfig.presence_penalty =
  614. ModalConfigValidator.presence_penalty(
  615. e.currentTarget.valueAsNumber,
  616. )),
  617. );
  618. }}
  619. ></InputRange>
  620. </SettingItem>
  621. </List>
  622. {shouldShowPromptModal && (
  623. <UserPromptModal onClose={() => setShowPromptModal(false)} />
  624. )}
  625. </div>
  626. </ErrorBoundary>
  627. );
  628. }