settings.tsx 20 KB

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