settings.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739
  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 DownloadIcon from "../icons/download.svg";
  12. import UploadIcon from "../icons/upload.svg";
  13. import {
  14. Input,
  15. List,
  16. ListItem,
  17. Modal,
  18. PasswordInput,
  19. Popover,
  20. Select,
  21. showConfirm,
  22. showToast,
  23. } from "./ui-lib";
  24. import { ModelConfigList } from "./model-config";
  25. import { IconButton } from "./button";
  26. import {
  27. SubmitKey,
  28. useChatStore,
  29. Theme,
  30. useUpdateStore,
  31. useAccessStore,
  32. useAppConfig,
  33. } from "../store";
  34. import Locale, {
  35. AllLangs,
  36. ALL_LANG_OPTIONS,
  37. changeLang,
  38. getLang,
  39. } from "../locales";
  40. import { copyToClipboard } from "../utils";
  41. import Link from "next/link";
  42. import { Path, RELEASE_URL, UPDATE_URL } from "../constant";
  43. import { Prompt, SearchService, usePromptStore } from "../store/prompt";
  44. import { ErrorBoundary } from "./error";
  45. import { InputRange } from "./input-range";
  46. import { useNavigate } from "react-router-dom";
  47. import { Avatar, AvatarPicker } from "./emoji";
  48. import { getClientConfig } from "../config/client";
  49. import { useSyncStore } from "../store/sync";
  50. import { nanoid } from "nanoid";
  51. import { useMaskStore } from "../store/mask";
  52. function EditPromptModal(props: { id: string; onClose: () => void }) {
  53. const promptStore = usePromptStore();
  54. const prompt = promptStore.get(props.id);
  55. return prompt ? (
  56. <div className="modal-mask">
  57. <Modal
  58. title={Locale.Settings.Prompt.EditModal.Title}
  59. onClose={props.onClose}
  60. actions={[
  61. <IconButton
  62. key=""
  63. onClick={props.onClose}
  64. text={Locale.UI.Confirm}
  65. bordered
  66. />,
  67. ]}
  68. >
  69. <div className={styles["edit-prompt-modal"]}>
  70. <input
  71. type="text"
  72. value={prompt.title}
  73. readOnly={!prompt.isUser}
  74. className={styles["edit-prompt-title"]}
  75. onInput={(e) =>
  76. promptStore.updatePrompt(
  77. props.id,
  78. (prompt) => (prompt.title = e.currentTarget.value),
  79. )
  80. }
  81. ></input>
  82. <Input
  83. value={prompt.content}
  84. readOnly={!prompt.isUser}
  85. className={styles["edit-prompt-content"]}
  86. rows={10}
  87. onInput={(e) =>
  88. promptStore.updatePrompt(
  89. props.id,
  90. (prompt) => (prompt.content = e.currentTarget.value),
  91. )
  92. }
  93. ></Input>
  94. </div>
  95. </Modal>
  96. </div>
  97. ) : null;
  98. }
  99. function UserPromptModal(props: { onClose?: () => void }) {
  100. const promptStore = usePromptStore();
  101. const userPrompts = promptStore.getUserPrompts();
  102. const builtinPrompts = SearchService.builtinPrompts;
  103. const allPrompts = userPrompts.concat(builtinPrompts);
  104. const [searchInput, setSearchInput] = useState("");
  105. const [searchPrompts, setSearchPrompts] = useState<Prompt[]>([]);
  106. const prompts = searchInput.length > 0 ? searchPrompts : allPrompts;
  107. const [editingPromptId, setEditingPromptId] = useState<string>();
  108. useEffect(() => {
  109. if (searchInput.length > 0) {
  110. const searchResult = SearchService.search(searchInput);
  111. setSearchPrompts(searchResult);
  112. } else {
  113. setSearchPrompts([]);
  114. }
  115. }, [searchInput]);
  116. return (
  117. <div className="modal-mask">
  118. <Modal
  119. title={Locale.Settings.Prompt.Modal.Title}
  120. onClose={() => props.onClose?.()}
  121. actions={[
  122. <IconButton
  123. key="add"
  124. onClick={() => {
  125. const promptId = promptStore.add({
  126. id: nanoid(),
  127. createdAt: Date.now(),
  128. title: "Empty Prompt",
  129. content: "Empty Prompt Content",
  130. });
  131. setEditingPromptId(promptId);
  132. }}
  133. icon={<AddIcon />}
  134. bordered
  135. text={Locale.Settings.Prompt.Modal.Add}
  136. />,
  137. ]}
  138. >
  139. <div className={styles["user-prompt-modal"]}>
  140. <input
  141. type="text"
  142. className={styles["user-prompt-search"]}
  143. placeholder={Locale.Settings.Prompt.Modal.Search}
  144. value={searchInput}
  145. onInput={(e) => setSearchInput(e.currentTarget.value)}
  146. ></input>
  147. <div className={styles["user-prompt-list"]}>
  148. {prompts.map((v, _) => (
  149. <div className={styles["user-prompt-item"]} key={v.id ?? v.title}>
  150. <div className={styles["user-prompt-header"]}>
  151. <div className={styles["user-prompt-title"]}>{v.title}</div>
  152. <div className={styles["user-prompt-content"] + " one-line"}>
  153. {v.content}
  154. </div>
  155. </div>
  156. <div className={styles["user-prompt-buttons"]}>
  157. {v.isUser && (
  158. <IconButton
  159. icon={<ClearIcon />}
  160. className={styles["user-prompt-button"]}
  161. onClick={() => promptStore.remove(v.id!)}
  162. />
  163. )}
  164. {v.isUser ? (
  165. <IconButton
  166. icon={<EditIcon />}
  167. className={styles["user-prompt-button"]}
  168. onClick={() => setEditingPromptId(v.id)}
  169. />
  170. ) : (
  171. <IconButton
  172. icon={<EyeIcon />}
  173. className={styles["user-prompt-button"]}
  174. onClick={() => setEditingPromptId(v.id)}
  175. />
  176. )}
  177. <IconButton
  178. icon={<CopyIcon />}
  179. className={styles["user-prompt-button"]}
  180. onClick={() => copyToClipboard(v.content)}
  181. />
  182. </div>
  183. </div>
  184. ))}
  185. </div>
  186. </div>
  187. </Modal>
  188. {editingPromptId !== undefined && (
  189. <EditPromptModal
  190. id={editingPromptId!}
  191. onClose={() => setEditingPromptId(undefined)}
  192. />
  193. )}
  194. </div>
  195. );
  196. }
  197. function DangerItems() {
  198. const chatStore = useChatStore();
  199. const appConfig = useAppConfig();
  200. return (
  201. <List>
  202. <ListItem
  203. title={Locale.Settings.Danger.Reset.Title}
  204. subTitle={Locale.Settings.Danger.Reset.SubTitle}
  205. >
  206. <IconButton
  207. text={Locale.Settings.Danger.Reset.Action}
  208. onClick={async () => {
  209. if (await showConfirm(Locale.Settings.Danger.Reset.Confirm)) {
  210. appConfig.reset();
  211. }
  212. }}
  213. type="danger"
  214. />
  215. </ListItem>
  216. <ListItem
  217. title={Locale.Settings.Danger.Clear.Title}
  218. subTitle={Locale.Settings.Danger.Clear.SubTitle}
  219. >
  220. <IconButton
  221. text={Locale.Settings.Danger.Clear.Action}
  222. onClick={async () => {
  223. if (await showConfirm(Locale.Settings.Danger.Clear.Confirm)) {
  224. chatStore.clearAllData();
  225. }
  226. }}
  227. type="danger"
  228. />
  229. </ListItem>
  230. </List>
  231. );
  232. }
  233. function SyncItems() {
  234. const syncStore = useSyncStore();
  235. const webdav = syncStore.webDavConfig;
  236. const chatStore = useChatStore();
  237. const promptStore = usePromptStore();
  238. const maskStore = useMaskStore();
  239. const stateOverview = useMemo(() => {
  240. const sessions = chatStore.sessions;
  241. const messageCount = sessions.reduce((p, c) => p + c.messages.length, 0);
  242. return {
  243. chat: sessions.length,
  244. message: messageCount,
  245. prompt: Object.keys(promptStore.prompts).length,
  246. mask: Object.keys(maskStore.masks).length,
  247. };
  248. }, [chatStore.sessions, maskStore.masks, promptStore.prompts]);
  249. return (
  250. <List>
  251. <ListItem
  252. title={Locale.Settings.Sync.LastUpdate}
  253. subTitle={new Date(syncStore.lastSyncTime).toLocaleString()}
  254. >
  255. <IconButton
  256. icon={<ResetIcon />}
  257. text={Locale.UI.Sync}
  258. onClick={() => {
  259. showToast(Locale.WIP);
  260. }}
  261. />
  262. </ListItem>
  263. <ListItem
  264. title={Locale.Settings.Sync.LocalState}
  265. subTitle={Locale.Settings.Sync.Overview(stateOverview)}
  266. >
  267. <div style={{ display: "flex" }}>
  268. <IconButton
  269. icon={<UploadIcon />}
  270. text={Locale.UI.Export}
  271. onClick={() => {
  272. syncStore.export();
  273. }}
  274. />
  275. <IconButton
  276. icon={<DownloadIcon />}
  277. text={Locale.UI.Import}
  278. onClick={() => {
  279. syncStore.import();
  280. }}
  281. />
  282. </div>
  283. </ListItem>
  284. </List>
  285. );
  286. }
  287. export function Settings() {
  288. const navigate = useNavigate();
  289. const [showEmojiPicker, setShowEmojiPicker] = useState(false);
  290. const config = useAppConfig();
  291. const updateConfig = config.update;
  292. const updateStore = useUpdateStore();
  293. const [checkingUpdate, setCheckingUpdate] = useState(false);
  294. const currentVersion = updateStore.formatVersion(updateStore.version);
  295. const remoteId = updateStore.formatVersion(updateStore.remoteVersion);
  296. const hasNewVersion = currentVersion !== remoteId;
  297. const updateUrl = getClientConfig()?.isApp ? RELEASE_URL : UPDATE_URL;
  298. function checkUpdate(force = false) {
  299. setCheckingUpdate(true);
  300. updateStore.getLatestVersion(force).then(() => {
  301. setCheckingUpdate(false);
  302. });
  303. console.log("[Update] local version ", updateStore.version);
  304. console.log("[Update] remote version ", updateStore.remoteVersion);
  305. }
  306. const usage = {
  307. used: updateStore.used,
  308. subscription: updateStore.subscription,
  309. };
  310. const [loadingUsage, setLoadingUsage] = useState(false);
  311. function checkUsage(force = false) {
  312. if (accessStore.hideBalanceQuery) {
  313. return;
  314. }
  315. setLoadingUsage(true);
  316. updateStore.updateUsage(force).finally(() => {
  317. setLoadingUsage(false);
  318. });
  319. }
  320. const accessStore = useAccessStore();
  321. const enabledAccessControl = useMemo(
  322. () => accessStore.enabledAccessControl(),
  323. // eslint-disable-next-line react-hooks/exhaustive-deps
  324. [],
  325. );
  326. const promptStore = usePromptStore();
  327. const builtinCount = SearchService.count.builtin;
  328. const customCount = promptStore.getUserPrompts().length ?? 0;
  329. const [shouldShowPromptModal, setShowPromptModal] = useState(false);
  330. const showUsage = accessStore.isAuthorized();
  331. useEffect(() => {
  332. // checks per minutes
  333. checkUpdate();
  334. showUsage && checkUsage();
  335. // eslint-disable-next-line react-hooks/exhaustive-deps
  336. }, []);
  337. useEffect(() => {
  338. const keydownEvent = (e: KeyboardEvent) => {
  339. if (e.key === "Escape") {
  340. navigate(Path.Home);
  341. }
  342. };
  343. document.addEventListener("keydown", keydownEvent);
  344. return () => {
  345. document.removeEventListener("keydown", keydownEvent);
  346. };
  347. // eslint-disable-next-line react-hooks/exhaustive-deps
  348. }, []);
  349. const clientConfig = useMemo(() => getClientConfig(), []);
  350. const showAccessCode = enabledAccessControl && !clientConfig?.isApp;
  351. return (
  352. <ErrorBoundary>
  353. <div className="window-header" data-tauri-drag-region>
  354. <div className="window-header-title">
  355. <div className="window-header-main-title">
  356. {Locale.Settings.Title}
  357. </div>
  358. <div className="window-header-sub-title">
  359. {Locale.Settings.SubTitle}
  360. </div>
  361. </div>
  362. <div className="window-actions">
  363. <div className="window-action-button"></div>
  364. <div className="window-action-button"></div>
  365. <div className="window-action-button">
  366. <IconButton
  367. icon={<CloseIcon />}
  368. onClick={() => navigate(Path.Home)}
  369. bordered
  370. />
  371. </div>
  372. </div>
  373. </div>
  374. <div className={styles["settings"]}>
  375. <List>
  376. <ListItem title={Locale.Settings.Avatar}>
  377. <Popover
  378. onClose={() => setShowEmojiPicker(false)}
  379. content={
  380. <AvatarPicker
  381. onEmojiClick={(avatar: string) => {
  382. updateConfig((config) => (config.avatar = avatar));
  383. setShowEmojiPicker(false);
  384. }}
  385. />
  386. }
  387. open={showEmojiPicker}
  388. >
  389. <div
  390. className={styles.avatar}
  391. onClick={() => setShowEmojiPicker(true)}
  392. >
  393. <Avatar avatar={config.avatar} />
  394. </div>
  395. </Popover>
  396. </ListItem>
  397. <ListItem
  398. title={Locale.Settings.Update.Version(currentVersion ?? "unknown")}
  399. subTitle={
  400. checkingUpdate
  401. ? Locale.Settings.Update.IsChecking
  402. : hasNewVersion
  403. ? Locale.Settings.Update.FoundUpdate(remoteId ?? "ERROR")
  404. : Locale.Settings.Update.IsLatest
  405. }
  406. >
  407. {checkingUpdate ? (
  408. <LoadingIcon />
  409. ) : hasNewVersion ? (
  410. <Link href={updateUrl} target="_blank" className="link">
  411. {Locale.Settings.Update.GoToUpdate}
  412. </Link>
  413. ) : (
  414. <IconButton
  415. icon={<ResetIcon></ResetIcon>}
  416. text={Locale.Settings.Update.CheckUpdate}
  417. onClick={() => checkUpdate(true)}
  418. />
  419. )}
  420. </ListItem>
  421. <ListItem title={Locale.Settings.SendKey}>
  422. <Select
  423. value={config.submitKey}
  424. onChange={(e) => {
  425. updateConfig(
  426. (config) =>
  427. (config.submitKey = e.target.value as any as SubmitKey),
  428. );
  429. }}
  430. >
  431. {Object.values(SubmitKey).map((v) => (
  432. <option value={v} key={v}>
  433. {v}
  434. </option>
  435. ))}
  436. </Select>
  437. </ListItem>
  438. <ListItem title={Locale.Settings.Theme}>
  439. <Select
  440. value={config.theme}
  441. onChange={(e) => {
  442. updateConfig(
  443. (config) => (config.theme = e.target.value as any as Theme),
  444. );
  445. }}
  446. >
  447. {Object.values(Theme).map((v) => (
  448. <option value={v} key={v}>
  449. {v}
  450. </option>
  451. ))}
  452. </Select>
  453. </ListItem>
  454. <ListItem title={Locale.Settings.Lang.Name}>
  455. <Select
  456. value={getLang()}
  457. onChange={(e) => {
  458. changeLang(e.target.value as any);
  459. }}
  460. >
  461. {AllLangs.map((lang) => (
  462. <option value={lang} key={lang}>
  463. {ALL_LANG_OPTIONS[lang]}
  464. </option>
  465. ))}
  466. </Select>
  467. </ListItem>
  468. <ListItem
  469. title={Locale.Settings.FontSize.Title}
  470. subTitle={Locale.Settings.FontSize.SubTitle}
  471. >
  472. <InputRange
  473. title={`${config.fontSize ?? 14}px`}
  474. value={config.fontSize}
  475. min="12"
  476. max="18"
  477. step="1"
  478. onChange={(e) =>
  479. updateConfig(
  480. (config) =>
  481. (config.fontSize = Number.parseInt(e.currentTarget.value)),
  482. )
  483. }
  484. ></InputRange>
  485. </ListItem>
  486. <ListItem
  487. title={Locale.Settings.AutoGenerateTitle.Title}
  488. subTitle={Locale.Settings.AutoGenerateTitle.SubTitle}
  489. >
  490. <input
  491. type="checkbox"
  492. checked={config.enableAutoGenerateTitle}
  493. onChange={(e) =>
  494. updateConfig(
  495. (config) =>
  496. (config.enableAutoGenerateTitle = e.currentTarget.checked),
  497. )
  498. }
  499. ></input>
  500. </ListItem>
  501. <ListItem
  502. title={Locale.Settings.SendPreviewBubble.Title}
  503. subTitle={Locale.Settings.SendPreviewBubble.SubTitle}
  504. >
  505. <input
  506. type="checkbox"
  507. checked={config.sendPreviewBubble}
  508. onChange={(e) =>
  509. updateConfig(
  510. (config) =>
  511. (config.sendPreviewBubble = e.currentTarget.checked),
  512. )
  513. }
  514. ></input>
  515. </ListItem>
  516. </List>
  517. <SyncItems />
  518. <List>
  519. <ListItem
  520. title={Locale.Settings.Mask.Splash.Title}
  521. subTitle={Locale.Settings.Mask.Splash.SubTitle}
  522. >
  523. <input
  524. type="checkbox"
  525. checked={!config.dontShowMaskSplashScreen}
  526. onChange={(e) =>
  527. updateConfig(
  528. (config) =>
  529. (config.dontShowMaskSplashScreen =
  530. !e.currentTarget.checked),
  531. )
  532. }
  533. ></input>
  534. </ListItem>
  535. <ListItem
  536. title={Locale.Settings.Mask.Builtin.Title}
  537. subTitle={Locale.Settings.Mask.Builtin.SubTitle}
  538. >
  539. <input
  540. type="checkbox"
  541. checked={config.hideBuiltinMasks}
  542. onChange={(e) =>
  543. updateConfig(
  544. (config) =>
  545. (config.hideBuiltinMasks = e.currentTarget.checked),
  546. )
  547. }
  548. ></input>
  549. </ListItem>
  550. </List>
  551. <List>
  552. <ListItem
  553. title={Locale.Settings.Prompt.Disable.Title}
  554. subTitle={Locale.Settings.Prompt.Disable.SubTitle}
  555. >
  556. <input
  557. type="checkbox"
  558. checked={config.disablePromptHint}
  559. onChange={(e) =>
  560. updateConfig(
  561. (config) =>
  562. (config.disablePromptHint = e.currentTarget.checked),
  563. )
  564. }
  565. ></input>
  566. </ListItem>
  567. <ListItem
  568. title={Locale.Settings.Prompt.List}
  569. subTitle={Locale.Settings.Prompt.ListCount(
  570. builtinCount,
  571. customCount,
  572. )}
  573. >
  574. <IconButton
  575. icon={<EditIcon />}
  576. text={Locale.Settings.Prompt.Edit}
  577. onClick={() => setShowPromptModal(true)}
  578. />
  579. </ListItem>
  580. </List>
  581. <List>
  582. {showAccessCode ? (
  583. <ListItem
  584. title={Locale.Settings.AccessCode.Title}
  585. subTitle={Locale.Settings.AccessCode.SubTitle}
  586. >
  587. <PasswordInput
  588. value={accessStore.accessCode}
  589. type="text"
  590. placeholder={Locale.Settings.AccessCode.Placeholder}
  591. onChange={(e) => {
  592. accessStore.updateCode(e.currentTarget.value);
  593. }}
  594. />
  595. </ListItem>
  596. ) : (
  597. <></>
  598. )}
  599. {!accessStore.hideUserApiKey ? (
  600. <>
  601. <ListItem
  602. title={Locale.Settings.Endpoint.Title}
  603. subTitle={Locale.Settings.Endpoint.SubTitle}
  604. >
  605. <input
  606. type="text"
  607. value={accessStore.openaiUrl}
  608. placeholder="https://api.openai.com/"
  609. onChange={(e) =>
  610. accessStore.updateOpenAiUrl(e.currentTarget.value)
  611. }
  612. ></input>
  613. </ListItem>
  614. <ListItem
  615. title={Locale.Settings.Token.Title}
  616. subTitle={Locale.Settings.Token.SubTitle}
  617. >
  618. <PasswordInput
  619. value={accessStore.token}
  620. type="text"
  621. placeholder={Locale.Settings.Token.Placeholder}
  622. onChange={(e) => {
  623. accessStore.updateToken(e.currentTarget.value);
  624. }}
  625. />
  626. </ListItem>
  627. </>
  628. ) : null}
  629. {!accessStore.hideBalanceQuery ? (
  630. <ListItem
  631. title={Locale.Settings.Usage.Title}
  632. subTitle={
  633. showUsage
  634. ? loadingUsage
  635. ? Locale.Settings.Usage.IsChecking
  636. : Locale.Settings.Usage.SubTitle(
  637. usage?.used ?? "[?]",
  638. usage?.subscription ?? "[?]",
  639. )
  640. : Locale.Settings.Usage.NoAccess
  641. }
  642. >
  643. {!showUsage || loadingUsage ? (
  644. <div />
  645. ) : (
  646. <IconButton
  647. icon={<ResetIcon></ResetIcon>}
  648. text={Locale.Settings.Usage.Check}
  649. onClick={() => checkUsage(true)}
  650. />
  651. )}
  652. </ListItem>
  653. ) : null}
  654. <ListItem
  655. title={Locale.Settings.CustomModel.Title}
  656. subTitle={Locale.Settings.CustomModel.SubTitle}
  657. >
  658. <input
  659. type="text"
  660. value={config.customModels}
  661. placeholder="model1,model2,model3"
  662. onChange={(e) =>
  663. config.update(
  664. (config) => (config.customModels = e.currentTarget.value),
  665. )
  666. }
  667. ></input>
  668. </ListItem>
  669. </List>
  670. <List>
  671. <ModelConfigList
  672. modelConfig={config.modelConfig}
  673. updateConfig={(updater) => {
  674. const modelConfig = { ...config.modelConfig };
  675. updater(modelConfig);
  676. config.update((config) => (config.modelConfig = modelConfig));
  677. }}
  678. />
  679. </List>
  680. {shouldShowPromptModal && (
  681. <UserPromptModal onClose={() => setShowPromptModal(false)} />
  682. )}
  683. <DangerItems />
  684. </div>
  685. </ErrorBoundary>
  686. );
  687. }