settings.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  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. const clientConfig = useMemo(() => getClientConfig(), []);
  265. const showAccessCode = enabledAccessControl && !clientConfig?.isApp;
  266. return (
  267. <ErrorBoundary>
  268. <div className="window-header" data-tauri-drag-region>
  269. <div className="window-header-title">
  270. <div className="window-header-main-title">
  271. {Locale.Settings.Title}
  272. </div>
  273. <div className="window-header-sub-title">
  274. {Locale.Settings.SubTitle}
  275. </div>
  276. </div>
  277. <div className="window-actions">
  278. <div className="window-action-button">
  279. <IconButton
  280. icon={<ClearIcon />}
  281. onClick={() => {
  282. if (confirm(Locale.Settings.Actions.ConfirmClearAll)) {
  283. chatStore.clearAllData();
  284. }
  285. }}
  286. bordered
  287. title={Locale.Settings.Actions.ClearAll}
  288. />
  289. </div>
  290. <div className="window-action-button">
  291. <IconButton
  292. icon={<ResetIcon />}
  293. onClick={() => {
  294. if (confirm(Locale.Settings.Actions.ConfirmResetAll)) {
  295. resetConfig();
  296. }
  297. }}
  298. bordered
  299. title={Locale.Settings.Actions.ResetAll}
  300. />
  301. </div>
  302. <div className="window-action-button">
  303. <IconButton
  304. icon={<CloseIcon />}
  305. onClick={() => navigate(Path.Home)}
  306. bordered
  307. title={Locale.Settings.Actions.Close}
  308. />
  309. </div>
  310. </div>
  311. </div>
  312. <div className={styles["settings"]}>
  313. <List>
  314. <ListItem title={Locale.Settings.Avatar}>
  315. <Popover
  316. onClose={() => setShowEmojiPicker(false)}
  317. content={
  318. <AvatarPicker
  319. onEmojiClick={(avatar: string) => {
  320. updateConfig((config) => (config.avatar = avatar));
  321. setShowEmojiPicker(false);
  322. }}
  323. />
  324. }
  325. open={showEmojiPicker}
  326. >
  327. <div
  328. className={styles.avatar}
  329. onClick={() => setShowEmojiPicker(true)}
  330. >
  331. <Avatar avatar={config.avatar} />
  332. </div>
  333. </Popover>
  334. </ListItem>
  335. <ListItem
  336. title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
  337. subTitle={
  338. checkingUpdate
  339. ? Locale.Settings.Update.IsChecking
  340. : hasNewVersion
  341. ? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
  342. : Locale.Settings.Update.IsLatest
  343. }
  344. >
  345. {checkingUpdate ? (
  346. <LoadingIcon />
  347. ) : hasNewVersion ? (
  348. <Link href={UPDATE_URL} target="_blank" className="link">
  349. {Locale.Settings.Update.GoToUpdate}
  350. </Link>
  351. ) : (
  352. <IconButton
  353. icon={<ResetIcon></ResetIcon>}
  354. text={Locale.Settings.Update.CheckUpdate}
  355. onClick={() => checkUpdate(true)}
  356. />
  357. )}
  358. </ListItem>
  359. <ListItem title={Locale.Settings.SendKey}>
  360. <Select
  361. value={config.submitKey}
  362. onChange={(e) => {
  363. updateConfig(
  364. (config) =>
  365. (config.submitKey = e.target.value as any as SubmitKey),
  366. );
  367. }}
  368. >
  369. {Object.values(SubmitKey).map((v) => (
  370. <option value={v} key={v}>
  371. {v}
  372. </option>
  373. ))}
  374. </Select>
  375. </ListItem>
  376. <ListItem title={Locale.Settings.Theme}>
  377. <Select
  378. value={config.theme}
  379. onChange={(e) => {
  380. updateConfig(
  381. (config) => (config.theme = e.target.value as any as Theme),
  382. );
  383. }}
  384. >
  385. {Object.values(Theme).map((v) => (
  386. <option value={v} key={v}>
  387. {v}
  388. </option>
  389. ))}
  390. </Select>
  391. </ListItem>
  392. <ListItem title={Locale.Settings.Lang.Name}>
  393. <Select
  394. value={getLang()}
  395. onChange={(e) => {
  396. changeLang(e.target.value as any);
  397. }}
  398. >
  399. {AllLangs.map((lang) => (
  400. <option value={lang} key={lang}>
  401. {ALL_LANG_OPTIONS[lang]}
  402. </option>
  403. ))}
  404. </Select>
  405. </ListItem>
  406. <ListItem
  407. title={Locale.Settings.FontSize.Title}
  408. subTitle={Locale.Settings.FontSize.SubTitle}
  409. >
  410. <InputRange
  411. title={`${config.fontSize ?? 14}px`}
  412. value={config.fontSize}
  413. min="12"
  414. max="18"
  415. step="1"
  416. onChange={(e) =>
  417. updateConfig(
  418. (config) =>
  419. (config.fontSize = Number.parseInt(e.currentTarget.value)),
  420. )
  421. }
  422. ></InputRange>
  423. </ListItem>
  424. <ListItem
  425. title={Locale.Settings.SendPreviewBubble.Title}
  426. subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
  427. >
  428. <input
  429. type="checkbox"
  430. checked={config.sendPreviewBubble}
  431. onChange={(e) =>
  432. updateConfig(
  433. (config) =>
  434. (config.sendPreviewBubble = e.currentTarget.checked),
  435. )
  436. }
  437. ></input>
  438. </ListItem>
  439. <ListItem
  440. title={Locale.Settings.Mask.Title}
  441. subTitle={Locale.Settings.Mask.SubTitle}
  442. >
  443. <input
  444. type="checkbox"
  445. checked={!config.dontShowMaskSplashScreen}
  446. onChange={(e) =>
  447. updateConfig(
  448. (config) =>
  449. (config.dontShowMaskSplashScreen =
  450. !e.currentTarget.checked),
  451. )
  452. }
  453. ></input>
  454. </ListItem>
  455. </List>
  456. <List>
  457. {showAccessCode ? (
  458. <ListItem
  459. title={Locale.Settings.AccessCode.Title}
  460. subTitle={Locale.Settings.AccessCode.SubTitle}
  461. >
  462. <PasswordInput
  463. value={accessStore.accessCode}
  464. type="text"
  465. placeholder={Locale.Settings.AccessCode.Placeholder}
  466. onChange={(e) => {
  467. accessStore.updateCode(e.currentTarget.value);
  468. }}
  469. />
  470. </ListItem>
  471. ) : (
  472. <></>
  473. )}
  474. {!accessStore.hideUserApiKey ? (
  475. <ListItem
  476. title={Locale.Settings.Token.Title}
  477. subTitle={Locale.Settings.Token.SubTitle}
  478. >
  479. <PasswordInput
  480. value={accessStore.token}
  481. type="text"
  482. placeholder={Locale.Settings.Token.Placeholder}
  483. onChange={(e) => {
  484. accessStore.updateToken(e.currentTarget.value);
  485. }}
  486. />
  487. </ListItem>
  488. ) : null}
  489. <ListItem
  490. title={Locale.Settings.Usage.Title}
  491. subTitle={
  492. showUsage
  493. ? loadingUsage
  494. ? Locale.Settings.Usage.IsChecking
  495. : Locale.Settings.Usage.SubTitle(
  496. usage?.used ?? "[?]",
  497. usage?.subscription ?? "[?]",
  498. )
  499. : Locale.Settings.Usage.NoAccess
  500. }
  501. >
  502. {!showUsage || loadingUsage ? (
  503. <div />
  504. ) : (
  505. <IconButton
  506. icon={<ResetIcon></ResetIcon>}
  507. text={Locale.Settings.Usage.Check}
  508. onClick={() => checkUsage(true)}
  509. />
  510. )}
  511. </ListItem>
  512. {!accessStore.hideUserApiKey ? (
  513. <ListItem
  514. title={Locale.Settings.Endpoint.Title}
  515. subTitle={Locale.Settings.Endpoint.SubTitle}
  516. >
  517. <input
  518. type="text"
  519. value={accessStore.openaiUrl}
  520. onChange={(e) =>
  521. accessStore.updateOpenAiUrl(e.currentTarget.value)
  522. }
  523. ></input>
  524. </ListItem>
  525. ) : null}
  526. </List>
  527. <List>
  528. <ListItem
  529. title={Locale.Settings.Prompt.Disable.Title}
  530. subTitle={Locale.Settings.Prompt.Disable.SubTitle}
  531. >
  532. <input
  533. type="checkbox"
  534. checked={config.disablePromptHint}
  535. onChange={(e) =>
  536. updateConfig(
  537. (config) =>
  538. (config.disablePromptHint = e.currentTarget.checked),
  539. )
  540. }
  541. ></input>
  542. </ListItem>
  543. <ListItem
  544. title={Locale.Settings.Prompt.List}
  545. subTitle={Locale.Settings.Prompt.ListCount(
  546. builtinCount,
  547. customCount,
  548. )}
  549. >
  550. <IconButton
  551. icon={<EditIcon />}
  552. text={Locale.Settings.Prompt.Edit}
  553. onClick={() => setShowPromptModal(true)}
  554. />
  555. </ListItem>
  556. </List>
  557. <List>
  558. <ModelConfigList
  559. modelConfig={config.modelConfig}
  560. updateConfig={(updater) => {
  561. const modelConfig = { ...config.modelConfig };
  562. updater(modelConfig);
  563. config.update((config) => (config.modelConfig = modelConfig));
  564. }}
  565. />
  566. </List>
  567. {shouldShowPromptModal && (
  568. <UserPromptModal onClose={() => setShowPromptModal(false)} />
  569. )}
  570. </div>
  571. </ErrorBoundary>
  572. );
  573. }