sidebar.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import React, { useEffect, useRef, useMemo, useState } from "react";
  2. import styles from "./home.module.scss";
  3. import { IconButton } from "./button";
  4. import SettingsIcon from "../icons/settings.svg";
  5. import GithubIcon from "../icons/github.svg";
  6. import ChatGptIcon from "../icons/chatgpt.svg";
  7. import AddIcon from "../icons/add.svg";
  8. import CloseIcon from "../icons/close.svg";
  9. import DeleteIcon from "../icons/delete.svg";
  10. import MaskIcon from "../icons/mask.svg";
  11. import PluginIcon from "../icons/plugin.svg";
  12. import DragIcon from "../icons/drag.svg";
  13. import Locale from "../locales";
  14. import { ModelType, useAppConfig, useChatStore } from "../store";
  15. import {
  16. DEFAULT_SIDEBAR_WIDTH,
  17. MAX_SIDEBAR_WIDTH,
  18. MIN_SIDEBAR_WIDTH,
  19. NARROW_SIDEBAR_WIDTH,
  20. Path,
  21. PLUGINS,
  22. REPO_URL,
  23. } from "../constant";
  24. import { Link, useLocation, useNavigate } from "react-router-dom";
  25. import { isIOS, useMobileScreen } from "../utils";
  26. import dynamic from "next/dynamic";
  27. import { Selector, showConfirm, showToast } from "./ui-lib";
  28. import de from "@/app/locales/de";
  29. const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
  30. loading: () => null,
  31. });
  32. const SdPanel = dynamic(async () => (await import("./sd-panel")).SdPanel, {
  33. loading: () => null,
  34. });
  35. function useHotKey() {
  36. const chatStore = useChatStore();
  37. useEffect(() => {
  38. const onKeyDown = (e: KeyboardEvent) => {
  39. if (e.altKey || e.ctrlKey) {
  40. if (e.key === "ArrowUp") {
  41. chatStore.nextSession(-1);
  42. } else if (e.key === "ArrowDown") {
  43. chatStore.nextSession(1);
  44. }
  45. }
  46. };
  47. window.addEventListener("keydown", onKeyDown);
  48. return () => window.removeEventListener("keydown", onKeyDown);
  49. });
  50. }
  51. function useDragSideBar() {
  52. const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
  53. const config = useAppConfig();
  54. const startX = useRef(0);
  55. const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
  56. const lastUpdateTime = useRef(Date.now());
  57. const toggleSideBar = () => {
  58. config.update((config) => {
  59. if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) {
  60. config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
  61. } else {
  62. config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
  63. }
  64. });
  65. };
  66. const onDragStart = (e: MouseEvent) => {
  67. // Remembers the initial width each time the mouse is pressed
  68. startX.current = e.clientX;
  69. startDragWidth.current = config.sidebarWidth;
  70. const dragStartTime = Date.now();
  71. const handleDragMove = (e: MouseEvent) => {
  72. if (Date.now() < lastUpdateTime.current + 20) {
  73. return;
  74. }
  75. lastUpdateTime.current = Date.now();
  76. const d = e.clientX - startX.current;
  77. const nextWidth = limit(startDragWidth.current + d);
  78. config.update((config) => {
  79. if (nextWidth < MIN_SIDEBAR_WIDTH) {
  80. config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
  81. } else {
  82. config.sidebarWidth = nextWidth;
  83. }
  84. });
  85. };
  86. const handleDragEnd = () => {
  87. // In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
  88. window.removeEventListener("pointermove", handleDragMove);
  89. window.removeEventListener("pointerup", handleDragEnd);
  90. // if user click the drag icon, should toggle the sidebar
  91. const shouldFireClick = Date.now() - dragStartTime < 300;
  92. if (shouldFireClick) {
  93. toggleSideBar();
  94. }
  95. };
  96. window.addEventListener("pointermove", handleDragMove);
  97. window.addEventListener("pointerup", handleDragEnd);
  98. };
  99. const isMobileScreen = useMobileScreen();
  100. const shouldNarrow =
  101. !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
  102. useEffect(() => {
  103. const barWidth = shouldNarrow
  104. ? NARROW_SIDEBAR_WIDTH
  105. : limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
  106. const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
  107. document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
  108. }, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
  109. return {
  110. onDragStart,
  111. shouldNarrow,
  112. };
  113. }
  114. export function SideBar(props: { className?: string }) {
  115. const chatStore = useChatStore();
  116. // drag side bar
  117. const { onDragStart, shouldNarrow } = useDragSideBar();
  118. const navigate = useNavigate();
  119. const config = useAppConfig();
  120. const isMobileScreen = useMobileScreen();
  121. const isIOSMobile = useMemo(
  122. () => isIOS() && isMobileScreen,
  123. [isMobileScreen],
  124. );
  125. const [showPluginSelector, setShowPluginSelector] = useState(false);
  126. const location = useLocation();
  127. useHotKey();
  128. let bodyComponent: React.JSX.Element;
  129. let isChat: boolean = false;
  130. switch (location.pathname) {
  131. case Path.Sd:
  132. case Path.SdPanel:
  133. bodyComponent = <SdPanel />;
  134. break;
  135. default:
  136. isChat = true;
  137. bodyComponent = <ChatList narrow={shouldNarrow} />;
  138. }
  139. // @ts-ignore
  140. return (
  141. <div
  142. className={`${styles.sidebar} ${props.className} ${
  143. shouldNarrow && styles["narrow-sidebar"]
  144. }`}
  145. style={{
  146. // #3016 disable transition on ios mobile screen
  147. transition: isMobileScreen && isIOSMobile ? "none" : undefined,
  148. }}
  149. >
  150. <div className={styles["sidebar-header"]} data-tauri-drag-region>
  151. <div className={styles["sidebar-title"]} data-tauri-drag-region>
  152. NextChat
  153. </div>
  154. <div className={styles["sidebar-sub-title"]}>
  155. Build your own AI assistant.
  156. </div>
  157. <div className={styles["sidebar-logo"] + " no-dark"}>
  158. <ChatGptIcon />
  159. </div>
  160. </div>
  161. <div className={styles["sidebar-header-bar"]}>
  162. <IconButton
  163. icon={<MaskIcon />}
  164. text={shouldNarrow ? undefined : Locale.Mask.Name}
  165. className={styles["sidebar-bar-button"]}
  166. onClick={() => {
  167. if (config.dontShowMaskSplashScreen !== true) {
  168. navigate(Path.NewChat, { state: { fromHome: true } });
  169. } else {
  170. navigate(Path.Masks, { state: { fromHome: true } });
  171. }
  172. }}
  173. shadow
  174. />
  175. <IconButton
  176. icon={<PluginIcon />}
  177. text={shouldNarrow ? undefined : Locale.Plugin.Name}
  178. className={styles["sidebar-bar-button"]}
  179. onClick={() => setShowPluginSelector(true)}
  180. shadow
  181. />
  182. </div>
  183. <div
  184. className={styles["sidebar-body"]}
  185. onClick={(e) => {
  186. if (isChat && e.target === e.currentTarget) {
  187. navigate(Path.Home);
  188. }
  189. }}
  190. >
  191. {bodyComponent}
  192. </div>
  193. <div className={styles["sidebar-tail"]}>
  194. <div className={styles["sidebar-actions"]}>
  195. {isChat && (
  196. <div className={styles["sidebar-action"] + " " + styles.mobile}>
  197. <IconButton
  198. icon={<DeleteIcon />}
  199. onClick={async () => {
  200. if (await showConfirm(Locale.Home.DeleteChat)) {
  201. chatStore.deleteSession(chatStore.currentSessionIndex);
  202. }
  203. }}
  204. />
  205. </div>
  206. )}
  207. <div className={styles["sidebar-action"]}>
  208. <Link to={Path.Settings}>
  209. <IconButton icon={<SettingsIcon />} shadow />
  210. </Link>
  211. </div>
  212. <div className={styles["sidebar-action"]}>
  213. <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
  214. <IconButton icon={<GithubIcon />} shadow />
  215. </a>
  216. </div>
  217. </div>
  218. {isChat && (
  219. <div>
  220. <IconButton
  221. icon={<AddIcon />}
  222. text={shouldNarrow ? undefined : Locale.Home.NewChat}
  223. onClick={() => {
  224. if (config.dontShowMaskSplashScreen) {
  225. chatStore.newSession();
  226. navigate(Path.Chat);
  227. } else {
  228. navigate(Path.NewChat);
  229. }
  230. }}
  231. shadow
  232. />
  233. </div>
  234. )}
  235. </div>
  236. <div
  237. className={styles["sidebar-drag"]}
  238. onPointerDown={(e) => onDragStart(e as any)}
  239. >
  240. <DragIcon />
  241. </div>
  242. {showPluginSelector && (
  243. <Selector
  244. items={[
  245. {
  246. title: "👇 Please select the plugin you need to use",
  247. value: "-",
  248. disable: true,
  249. },
  250. ...PLUGINS.map((item) => {
  251. return {
  252. title: item.name,
  253. value: item.path,
  254. };
  255. }),
  256. ]}
  257. onClose={() => setShowPluginSelector(false)}
  258. onSelection={(s) => {
  259. navigate(s[0], { state: { fromHome: true } });
  260. }}
  261. />
  262. )}
  263. </div>
  264. );
  265. }