sidebar.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import React, { Fragment, useEffect, useMemo, useRef, 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 DeleteIcon from "../icons/delete.svg";
  9. import MaskIcon from "../icons/mask.svg";
  10. import McpIcon from "../icons/mcp.svg";
  11. import DragIcon from "../icons/drag.svg";
  12. import DiscoveryIcon from "../icons/discovery.svg";
  13. import Locale from "../locales";
  14. import { 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, useNavigate } from "react-router-dom";
  25. import { isIOS, useMobileScreen } from "../utils";
  26. import dynamic from "next/dynamic";
  27. import { Selector, showConfirm } from "./ui-lib";
  28. import clsx from "clsx";
  29. import { isMcpEnabled } from "../mcp/actions";
  30. const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
  31. loading: () => null,
  32. });
  33. export function useHotKey() {
  34. const chatStore = useChatStore();
  35. useEffect(() => {
  36. const onKeyDown = (e: KeyboardEvent) => {
  37. if (e.altKey || e.ctrlKey) {
  38. if (e.key === "ArrowUp") {
  39. chatStore.nextSession(-1);
  40. } else if (e.key === "ArrowDown") {
  41. chatStore.nextSession(1);
  42. }
  43. }
  44. };
  45. window.addEventListener("keydown", onKeyDown);
  46. return () => window.removeEventListener("keydown", onKeyDown);
  47. });
  48. }
  49. export function useDragSideBar() {
  50. const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
  51. const config = useAppConfig();
  52. const startX = useRef(0);
  53. const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
  54. const lastUpdateTime = useRef(Date.now());
  55. const toggleSideBar = () => {
  56. config.update((config) => {
  57. if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) {
  58. config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
  59. } else {
  60. config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
  61. }
  62. });
  63. };
  64. const onDragStart = (e: MouseEvent) => {
  65. // Remembers the initial width each time the mouse is pressed
  66. startX.current = e.clientX;
  67. startDragWidth.current = config.sidebarWidth;
  68. const dragStartTime = Date.now();
  69. const handleDragMove = (e: MouseEvent) => {
  70. if (Date.now() < lastUpdateTime.current + 20) {
  71. return;
  72. }
  73. lastUpdateTime.current = Date.now();
  74. const d = e.clientX - startX.current;
  75. const nextWidth = limit(startDragWidth.current + d);
  76. config.update((config) => {
  77. if (nextWidth < MIN_SIDEBAR_WIDTH) {
  78. config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
  79. } else {
  80. config.sidebarWidth = nextWidth;
  81. }
  82. });
  83. };
  84. const handleDragEnd = () => {
  85. // In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
  86. window.removeEventListener("pointermove", handleDragMove);
  87. window.removeEventListener("pointerup", handleDragEnd);
  88. // if user click the drag icon, should toggle the sidebar
  89. const shouldFireClick = Date.now() - dragStartTime < 300;
  90. if (shouldFireClick) {
  91. toggleSideBar();
  92. }
  93. };
  94. window.addEventListener("pointermove", handleDragMove);
  95. window.addEventListener("pointerup", handleDragEnd);
  96. };
  97. const isMobileScreen = useMobileScreen();
  98. const shouldNarrow =
  99. !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
  100. useEffect(() => {
  101. const barWidth = shouldNarrow
  102. ? NARROW_SIDEBAR_WIDTH
  103. : limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
  104. const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
  105. document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
  106. }, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
  107. return {
  108. onDragStart,
  109. shouldNarrow,
  110. };
  111. }
  112. export function SideBarContainer(props: {
  113. children: React.ReactNode;
  114. onDragStart: (e: MouseEvent) => void;
  115. shouldNarrow: boolean;
  116. className?: string;
  117. }) {
  118. const isMobileScreen = useMobileScreen();
  119. const isIOSMobile = useMemo(
  120. () => isIOS() && isMobileScreen,
  121. [isMobileScreen],
  122. );
  123. const { children, className, onDragStart, shouldNarrow } = props;
  124. return (
  125. <div
  126. className={clsx(styles.sidebar, className, {
  127. [styles["narrow-sidebar"]]: shouldNarrow,
  128. })}
  129. style={{
  130. // #3016 disable transition on ios mobile screen
  131. transition: isMobileScreen && isIOSMobile ? "none" : undefined,
  132. }}
  133. >
  134. {children}
  135. <div
  136. className={styles["sidebar-drag"]}
  137. onPointerDown={(e) => onDragStart(e as any)}
  138. >
  139. <DragIcon />
  140. </div>
  141. </div>
  142. );
  143. }
  144. export function SideBarHeader(props: {
  145. title?: string | React.ReactNode;
  146. subTitle?: string | React.ReactNode;
  147. logo?: React.ReactNode;
  148. children?: React.ReactNode;
  149. shouldNarrow?: boolean;
  150. }) {
  151. const { title, subTitle, logo, children, shouldNarrow } = props;
  152. return (
  153. <Fragment>
  154. <div
  155. className={clsx(styles["sidebar-header"], {
  156. [styles["sidebar-header-narrow"]]: shouldNarrow,
  157. })}
  158. data-tauri-drag-region
  159. >
  160. <div className={styles["sidebar-title-container"]}>
  161. <div className={styles["sidebar-title"]} data-tauri-drag-region>
  162. {title}
  163. </div>
  164. <div className={styles["sidebar-sub-title"]}>{subTitle}</div>
  165. </div>
  166. <div className={clsx(styles["sidebar-logo"], "no-dark")}>{logo}</div>
  167. </div>
  168. {children}
  169. </Fragment>
  170. );
  171. }
  172. export function SideBarBody(props: {
  173. children: React.ReactNode;
  174. onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
  175. }) {
  176. const { onClick, children } = props;
  177. return (
  178. <div className={styles["sidebar-body"]} onClick={onClick}>
  179. {children}
  180. </div>
  181. );
  182. }
  183. export function SideBarTail(props: {
  184. primaryAction?: React.ReactNode;
  185. secondaryAction?: React.ReactNode;
  186. }) {
  187. const { primaryAction, secondaryAction } = props;
  188. return (
  189. <div className={styles["sidebar-tail"]}>
  190. <div className={styles["sidebar-actions"]}>{primaryAction}</div>
  191. <div className={styles["sidebar-actions"]}>{secondaryAction}</div>
  192. </div>
  193. );
  194. }
  195. export function SideBar(props: { className?: string }) {
  196. useHotKey();
  197. const { onDragStart, shouldNarrow } = useDragSideBar();
  198. const [showPluginSelector, setShowPluginSelector] = useState(false);
  199. const navigate = useNavigate();
  200. const config = useAppConfig();
  201. const chatStore = useChatStore();
  202. const [mcpEnabled, setMcpEnabled] = useState(false);
  203. useEffect(() => {
  204. // 检查 MCP 是否启用
  205. const checkMcpStatus = async () => {
  206. const enabled = await isMcpEnabled();
  207. setMcpEnabled(enabled);
  208. console.log("[SideBar] MCP enabled:", enabled);
  209. };
  210. checkMcpStatus();
  211. }, []);
  212. return (
  213. <SideBarContainer
  214. onDragStart={onDragStart}
  215. shouldNarrow={shouldNarrow}
  216. {...props}
  217. >
  218. <SideBarHeader
  219. title="NextChat"
  220. subTitle="Build your own AI assistant."
  221. logo={<ChatGptIcon />}
  222. shouldNarrow={shouldNarrow}
  223. >
  224. <div className={styles["sidebar-header-bar"]}>
  225. <IconButton
  226. icon={<MaskIcon />}
  227. text={shouldNarrow ? undefined : Locale.Mask.Name}
  228. className={styles["sidebar-bar-button"]}
  229. onClick={() => {
  230. if (config.dontShowMaskSplashScreen !== true) {
  231. navigate(Path.NewChat, { state: { fromHome: true } });
  232. } else {
  233. navigate(Path.Masks, { state: { fromHome: true } });
  234. }
  235. }}
  236. shadow
  237. />
  238. {mcpEnabled && (
  239. <IconButton
  240. icon={<McpIcon />}
  241. text={shouldNarrow ? undefined : Locale.Mcp.Name}
  242. className={styles["sidebar-bar-button"]}
  243. onClick={() => {
  244. navigate(Path.McpMarket, { state: { fromHome: true } });
  245. }}
  246. shadow
  247. />
  248. )}
  249. <IconButton
  250. icon={<DiscoveryIcon />}
  251. text={shouldNarrow ? undefined : Locale.Discovery.Name}
  252. className={styles["sidebar-bar-button"]}
  253. onClick={() => setShowPluginSelector(true)}
  254. shadow
  255. />
  256. </div>
  257. {showPluginSelector && (
  258. <Selector
  259. items={[
  260. ...PLUGINS.map((item) => {
  261. return {
  262. title: item.name,
  263. value: item.path,
  264. };
  265. }),
  266. ]}
  267. onClose={() => setShowPluginSelector(false)}
  268. onSelection={(s) => {
  269. navigate(s[0], { state: { fromHome: true } });
  270. }}
  271. />
  272. )}
  273. </SideBarHeader>
  274. <SideBarBody
  275. onClick={(e) => {
  276. if (e.target === e.currentTarget) {
  277. navigate(Path.Home);
  278. }
  279. }}
  280. >
  281. <ChatList narrow={shouldNarrow} />
  282. </SideBarBody>
  283. <SideBarTail
  284. primaryAction={
  285. <>
  286. <div className={clsx(styles["sidebar-action"], styles.mobile)}>
  287. <IconButton
  288. icon={<DeleteIcon />}
  289. onClick={async () => {
  290. if (await showConfirm(Locale.Home.DeleteChat)) {
  291. chatStore.deleteSession(chatStore.currentSessionIndex);
  292. }
  293. }}
  294. />
  295. </div>
  296. <div className={styles["sidebar-action"]}>
  297. <Link to={Path.Settings}>
  298. <IconButton
  299. aria={Locale.Settings.Title}
  300. icon={<SettingsIcon />}
  301. shadow
  302. />
  303. </Link>
  304. </div>
  305. <div className={styles["sidebar-action"]}>
  306. <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
  307. <IconButton
  308. aria={Locale.Export.MessageFromChatGPT}
  309. icon={<GithubIcon />}
  310. shadow
  311. />
  312. </a>
  313. </div>
  314. </>
  315. }
  316. secondaryAction={
  317. <IconButton
  318. icon={<AddIcon />}
  319. text={shouldNarrow ? undefined : Locale.Home.NewChat}
  320. onClick={() => {
  321. if (config.dontShowMaskSplashScreen) {
  322. chatStore.newSession();
  323. navigate(Path.Chat);
  324. } else {
  325. navigate(Path.NewChat);
  326. }
  327. }}
  328. shadow
  329. />
  330. }
  331. />
  332. </SideBarContainer>
  333. );
  334. }