settings.tsx 18 KB

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