settings.tsx 18 KB

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