sidebar.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. import React, { useEffect, useRef, useMemo, useState, Fragment } 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 DragIcon from "../icons/drag.svg";
  12. import DiscoveryIcon from "../icons/discovery.svg";
  13. import faviconSrc from "../icons/favicon.png";
  14. import { EditOutlined } from '@ant-design/icons';
  15. import Locale from "../locales";
  16. import { useAppConfig, useChatStore, useGlobalStore } from "../store";
  17. import {
  18. DEFAULT_SIDEBAR_WIDTH,
  19. MAX_SIDEBAR_WIDTH,
  20. MIN_SIDEBAR_WIDTH,
  21. NARROW_SIDEBAR_WIDTH,
  22. Path,
  23. PLUGINS,
  24. REPO_URL,
  25. } from "../constant";
  26. import { Link, useNavigate } from "react-router-dom";
  27. import { isIOS, useMobileScreen } from "../utils";
  28. import dynamic from "next/dynamic";
  29. import api from "@/app/api/api";
  30. import { Button, Dropdown, Form, Input, Menu, Modal } from "antd";
  31. import { downloadFile } from "../utils/index";
  32. const FormItem = Form.Item;
  33. const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
  34. loading: () => null,
  35. });
  36. export function useHotKey() {
  37. const chatStore = useChatStore();
  38. useEffect(() => {
  39. const onKeyDown = (e: KeyboardEvent) => {
  40. if (e.altKey || e.ctrlKey) {
  41. if (e.key === "ArrowUp") {
  42. chatStore.nextSession(-1);
  43. } else if (e.key === "ArrowDown") {
  44. chatStore.nextSession(1);
  45. }
  46. }
  47. };
  48. window.addEventListener("keydown", onKeyDown);
  49. return () => window.removeEventListener("keydown", onKeyDown);
  50. });
  51. }
  52. export function useDragSideBar() {
  53. const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
  54. const config = useAppConfig();
  55. const startX = useRef(0);
  56. const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
  57. const lastUpdateTime = useRef(Date.now());
  58. const toggleSideBar = () => {
  59. config.update((config) => {
  60. if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) {
  61. config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
  62. } else {
  63. config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
  64. }
  65. });
  66. };
  67. const onDragStart = (e: MouseEvent) => {
  68. // Remembers the initial width each time the mouse is pressed
  69. startX.current = e.clientX;
  70. startDragWidth.current = config.sidebarWidth;
  71. const dragStartTime = Date.now();
  72. const handleDragMove = (e: MouseEvent) => {
  73. if (Date.now() < lastUpdateTime.current + 20) {
  74. return;
  75. }
  76. lastUpdateTime.current = Date.now();
  77. const d = e.clientX - startX.current;
  78. const nextWidth = limit(startDragWidth.current + d);
  79. config.update((config) => {
  80. if (nextWidth < MIN_SIDEBAR_WIDTH) {
  81. config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
  82. } else {
  83. config.sidebarWidth = nextWidth;
  84. }
  85. });
  86. };
  87. const handleDragEnd = () => {
  88. // In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
  89. window.removeEventListener("pointermove", handleDragMove);
  90. window.removeEventListener("pointerup", handleDragEnd);
  91. // if user click the drag icon, should toggle the sidebar
  92. const shouldFireClick = Date.now() - dragStartTime < 300;
  93. if (shouldFireClick) {
  94. toggleSideBar();
  95. }
  96. };
  97. window.addEventListener("pointermove", handleDragMove);
  98. window.addEventListener("pointerup", handleDragEnd);
  99. };
  100. const isMobileScreen = useMobileScreen();
  101. const shouldNarrow =
  102. !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
  103. useEffect(() => {
  104. const barWidth = shouldNarrow
  105. ? NARROW_SIDEBAR_WIDTH
  106. : limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
  107. const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
  108. document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
  109. }, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
  110. return {
  111. onDragStart,
  112. shouldNarrow,
  113. };
  114. }
  115. export function SideBarContainer(props: {
  116. children: React.ReactNode;
  117. onDragStart: (e: MouseEvent) => void;
  118. shouldNarrow: boolean;
  119. className?: string;
  120. }) {
  121. const isMobileScreen = useMobileScreen();
  122. const isIOSMobile = useMemo(
  123. () => isIOS() && isMobileScreen,
  124. [isMobileScreen],
  125. );
  126. const { children, className, onDragStart, shouldNarrow } = props;
  127. return (
  128. <div
  129. className={`${styles.sidebar} ${className} ${shouldNarrow && styles["narrow-sidebar"]
  130. }`}
  131. style={{
  132. transition: isMobileScreen && isIOSMobile ? "none" : undefined,
  133. background: '#FFFFFF',
  134. overflowY: "auto",
  135. }}
  136. >
  137. {children}
  138. <div
  139. className={styles["sidebar-drag"]}
  140. onPointerDown={(e) => onDragStart(e as any)}
  141. >
  142. <DragIcon />
  143. </div>
  144. </div>
  145. );
  146. }
  147. export function SideBarHeader(props: {
  148. title?: string | React.ReactNode;
  149. subTitle?: string | React.ReactNode;
  150. logo?: React.ReactNode;
  151. children?: React.ReactNode;
  152. }) {
  153. const { title, subTitle, logo, children } = props;
  154. return (
  155. <Fragment>
  156. <div className={styles["sidebar-header"]} data-tauri-drag-region>
  157. <div className={styles["sidebar-title-container"]}>
  158. <div className={styles["sidebar-title"]} data-tauri-drag-region>
  159. {title}
  160. </div>
  161. <div className={styles["sidebar-sub-title"]}>{subTitle}</div>
  162. </div>
  163. <div className={styles["sidebar-logo"] + " no-dark"}>{logo}</div>
  164. </div>
  165. {children}
  166. </Fragment>
  167. );
  168. }
  169. export function SideBarBody(props: {
  170. children: React.ReactNode;
  171. onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
  172. }) {
  173. const { onClick, children } = props;
  174. return (
  175. <div className={styles["sidebar-body"]} onClick={onClick}>
  176. {children}
  177. </div>
  178. );
  179. }
  180. export function SideBarTail(props: {
  181. primaryAction?: React.ReactNode;
  182. secondaryAction?: React.ReactNode;
  183. }) {
  184. const { primaryAction, secondaryAction } = props;
  185. return (
  186. <div className={styles["sidebar-tail"]}>
  187. <div className={styles["sidebar-actions"]}>{primaryAction}</div>
  188. <div className={styles["sidebar-actions"]}>{secondaryAction}</div>
  189. </div>
  190. );
  191. }
  192. export const SideBar = (props: { className?: string }) => {
  193. useHotKey();
  194. const { onDragStart, shouldNarrow } = useDragSideBar();
  195. const [showPluginSelector, setShowPluginSelector] = useState(false);
  196. const navigate = useNavigate();
  197. const config = useAppConfig();
  198. const chatStore = useChatStore();
  199. const globalStore = useGlobalStore();
  200. const [menuList, setMenuList] = useState([])
  201. const [modalOpen, setModalOpen] = useState(false)
  202. const [form] = Form.useForm();
  203. // 获取聊天列表
  204. const fetchChatList = async () => {
  205. try {
  206. const res = await api.get(`/bigmodel/api/dialog/list/${globalStore.selectedAppId}`);
  207. const list = res.data.map((item: any) => {
  208. return {
  209. ...item,
  210. children: item.children.map((child: any) => {
  211. const items = [
  212. {
  213. key: '1',
  214. label: (
  215. <a onClick={() => {
  216. setModalOpen(true);
  217. form.setFieldsValue({
  218. dialogId: child.key,
  219. dialogName: child.label
  220. });
  221. }}>
  222. 重命名
  223. </a>
  224. ),
  225. },
  226. {
  227. key: '2',
  228. label: (
  229. <a onClick={async () => {
  230. try {
  231. const blob = await api.post(`/bigmodel/api/dialog/export/${child.key}`, {}, { responseType: 'blob' });
  232. const fileName = `${child.label}.xlsx`;
  233. downloadFile(blob, fileName);
  234. } catch (error) {
  235. console.error(error);
  236. }
  237. }}>
  238. 导出
  239. </a>
  240. ),
  241. },
  242. {
  243. key: '3',
  244. label: (
  245. <a onClick={async () => {
  246. try {
  247. await api.delete(`/bigmodel/api/dialog/del/${child.key}`);
  248. await fetchChatList()
  249. chatStore.clearSessions();
  250. chatStore.updateCurrentSession((value) => {
  251. value.appId = globalStore.selectedAppId;
  252. });
  253. } catch (error) {
  254. console.error(error);
  255. }
  256. }}>
  257. 删除
  258. </a>
  259. ),
  260. },
  261. ];
  262. return {
  263. ...child,
  264. label: <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
  265. <div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginRight: 10 }}>
  266. {child.label}
  267. </div>
  268. <div style={{ width: 20 }}>
  269. <Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
  270. <EditOutlined onClick={(e) => e.stopPropagation()} />
  271. </Dropdown>
  272. </div>
  273. </div>
  274. }
  275. })
  276. }
  277. })
  278. setMenuList(list);
  279. } catch (error) {
  280. console.error(error)
  281. }
  282. }
  283. useEffect(() => {
  284. fetchChatList();
  285. }, [globalStore.selectedAppId]);
  286. useEffect(() => {
  287. chatStore.clearSessions();
  288. }, []);
  289. return (
  290. <SideBarContainer
  291. onDragStart={onDragStart}
  292. shouldNarrow={shouldNarrow}
  293. {...props}
  294. >
  295. <SideBarHeader
  296. title="问答历史"
  297. logo={<img style={{ marginTop: '10%', marginRight: '10px', height: 42 }} src={faviconSrc.src} />}
  298. >
  299. {/* <div className={styles["sidebar-header-bar"]}>
  300. <IconButton
  301. icon={<MaskIcon />}
  302. text={shouldNarrow ? undefined : Locale.Mask.Name}
  303. className={styles["sidebar-bar-button"]}
  304. onClick={() => {
  305. if (config.dontShowMaskSplashScreen !== true) {
  306. navigate(Path.NewChat, { state: { fromHome: true } });
  307. } else {
  308. navigate(Path.Masks, { state: { fromHome: true } });
  309. }
  310. }}
  311. shadow
  312. />
  313. <IconButton
  314. icon={<DiscoveryIcon />}
  315. text={shouldNarrow ? undefined : Locale.Discovery.Name}
  316. className={styles["sidebar-bar-button"]}
  317. onClick={() => setShowPluginSelector(true)}
  318. shadow
  319. />
  320. </div> */}
  321. <Button
  322. type="primary"
  323. style={{ marginBottom: 20 }}
  324. onClick={() => {
  325. chatStore.clearSessions();
  326. chatStore.updateCurrentSession((value) => {
  327. value.appId = globalStore.selectedAppId;
  328. });
  329. navigate(Path.Chat);
  330. }}
  331. >
  332. 新建对话
  333. </Button>
  334. {/* {showPluginSelector && (
  335. <Selector
  336. items={[
  337. {
  338. title: "👇 Please select the plugin you need to use",
  339. value: "-",
  340. disable: true,
  341. },
  342. ...PLUGINS.map((item) => {
  343. return {
  344. title: item.name,
  345. value: item.path,
  346. };
  347. }),
  348. ]}
  349. onClose={() => setShowPluginSelector(false)}
  350. onSelection={(s) => {
  351. navigate(s[0], { state: { fromHome: true } });
  352. }}
  353. />
  354. )} */}
  355. </SideBarHeader>
  356. {/* <SideBarBody
  357. onClick={(e) => {
  358. if (e.target === e.currentTarget) {
  359. navigate(Path.Home);
  360. }
  361. }}
  362. >
  363. <ChatList narrow={shouldNarrow} />
  364. </SideBarBody> */}
  365. {/* <SideBarTail
  366. primaryAction={
  367. <>
  368. <div className={styles["sidebar-action"] + " " + styles.mobile}>
  369. <IconButton
  370. icon={<DeleteIcon />}
  371. onClick={async () => {
  372. if (await showConfirm(Locale.Home.DeleteChat)) {
  373. chatStore.deleteSession(chatStore.currentSessionIndex);
  374. }
  375. }}
  376. />
  377. </div>
  378. <div className={styles["sidebar-action"]}>
  379. <Link to={Path.Settings}>
  380. <IconButton
  381. aria={Locale.Settings.Title}
  382. icon={<SettingsIcon />}
  383. shadow
  384. />
  385. </Link>
  386. </div>
  387. <div className={styles["sidebar-action"]}>
  388. <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
  389. <IconButton
  390. aria={Locale.Export.MessageFromChatGPT}
  391. icon={<GithubIcon />}
  392. shadow
  393. />
  394. </a>
  395. </div>
  396. </>
  397. }
  398. secondaryAction={
  399. <IconButton
  400. icon={<AddIcon />}
  401. text={shouldNarrow ? undefined : Locale.Home.NewChat}
  402. onClick={() => {
  403. if (config.dontShowMaskSplashScreen) {
  404. chatStore.newSession();
  405. navigate(Path.Chat);
  406. } else {
  407. navigate(Path.NewChat);
  408. }
  409. }}
  410. shadow
  411. />
  412. }
  413. /> */}
  414. <Menu
  415. style={{ border: 'none' }}
  416. onClick={async ({ key }) => {
  417. const res = await api.get(`/bigmodel/api/dialog/detail/${key}`);
  418. const list = res.data.map(((item: any) => {
  419. return {
  420. content: item.content,
  421. date: item.create_time,
  422. id: item.did,
  423. role: item.type,
  424. }
  425. }))
  426. const session = {
  427. appId: res.data.length ? res.data[0].appId : '',
  428. dialogName: res.data.length ? res.data[0].dialog_name : '',
  429. id: res.data.length ? res.data[0].id : '',
  430. messages: list,
  431. }
  432. globalStore.setCurrentSession(session);
  433. chatStore.clearSessions();
  434. chatStore.updateCurrentSession((value) => {
  435. value.appId = session.appId;
  436. value.topic = session.dialogName;
  437. value.id = session.id;
  438. value.messages = list;
  439. });
  440. navigate('/chat', { state: { fromHome: true } });
  441. }}
  442. mode="inline"
  443. items={menuList}
  444. />
  445. <Modal
  446. title="重命名"
  447. open={modalOpen}
  448. width={300}
  449. maskClosable={false}
  450. onOk={() => {
  451. form.validateFields().then(async (values) => {
  452. setModalOpen(false);
  453. try {
  454. await api.put('/bigmodel/api/dialog/update', {
  455. id: values.dialogId,
  456. dialogName: values.dialogName
  457. });
  458. await fetchChatList()
  459. chatStore.updateCurrentSession((value) => {
  460. value.topic = values.dialogName;
  461. });
  462. } catch (error) {
  463. console.error(error);
  464. }
  465. }).catch((error) => {
  466. console.error(error);
  467. });
  468. }}
  469. onCancel={() => {
  470. setModalOpen(false);
  471. }}
  472. >
  473. <Form form={form} layout='inline'>
  474. <FormItem name='dialogId' noStyle />
  475. <FormItem
  476. label='名称'
  477. name='dialogName'
  478. rules={[{ required: true, message: '名称不能为空', whitespace: true }]}
  479. >
  480. <Input
  481. style={{ width: 300 }}
  482. placeholder='请输入'
  483. maxLength={20}
  484. />
  485. </FormItem>
  486. </Form>
  487. </Modal>
  488. </SideBarContainer>
  489. );
  490. }