mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-11-04 16:23:41 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			369 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			369 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import React, { Fragment, useEffect, useMemo, useRef, useState } from "react";
 | 
						|
 | 
						|
import styles from "./home.module.scss";
 | 
						|
 | 
						|
import { IconButton } from "./button";
 | 
						|
import SettingsIcon from "../icons/settings.svg";
 | 
						|
import GithubIcon from "../icons/github.svg";
 | 
						|
import ChatGptIcon from "../icons/chatgpt.svg";
 | 
						|
import AddIcon from "../icons/add.svg";
 | 
						|
import DeleteIcon from "../icons/delete.svg";
 | 
						|
import MaskIcon from "../icons/mask.svg";
 | 
						|
import McpIcon from "../icons/mcp.svg";
 | 
						|
import DragIcon from "../icons/drag.svg";
 | 
						|
import DiscoveryIcon from "../icons/discovery.svg";
 | 
						|
 | 
						|
import Locale from "../locales";
 | 
						|
 | 
						|
import { useAppConfig, useChatStore } from "../store";
 | 
						|
 | 
						|
import {
 | 
						|
  DEFAULT_SIDEBAR_WIDTH,
 | 
						|
  MAX_SIDEBAR_WIDTH,
 | 
						|
  MIN_SIDEBAR_WIDTH,
 | 
						|
  NARROW_SIDEBAR_WIDTH,
 | 
						|
  Path,
 | 
						|
  REPO_URL,
 | 
						|
} from "../constant";
 | 
						|
 | 
						|
import { Link, useNavigate } from "react-router-dom";
 | 
						|
import { isIOS, useMobileScreen } from "../utils";
 | 
						|
import dynamic from "next/dynamic";
 | 
						|
import { Selector, showConfirm } from "./ui-lib";
 | 
						|
import clsx from "clsx";
 | 
						|
import { isMcpEnabled } from "../mcp/actions";
 | 
						|
 | 
						|
const DISCOVERY = [
 | 
						|
  { name: Locale.Plugin.Name, path: Path.Plugins },
 | 
						|
  { name: "Stable Diffusion", path: Path.Sd },
 | 
						|
  { name: Locale.SearchChat.Page.Title, path: Path.SearchChat },
 | 
						|
];
 | 
						|
 | 
						|
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
 | 
						|
  loading: () => null,
 | 
						|
});
 | 
						|
 | 
						|
export function useHotKey() {
 | 
						|
  const chatStore = useChatStore();
 | 
						|
 | 
						|
  useEffect(() => {
 | 
						|
    const onKeyDown = (e: KeyboardEvent) => {
 | 
						|
      if (e.altKey || e.ctrlKey) {
 | 
						|
        if (e.key === "ArrowUp") {
 | 
						|
          chatStore.nextSession(-1);
 | 
						|
        } else if (e.key === "ArrowDown") {
 | 
						|
          chatStore.nextSession(1);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    };
 | 
						|
 | 
						|
    window.addEventListener("keydown", onKeyDown);
 | 
						|
    return () => window.removeEventListener("keydown", onKeyDown);
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
export function useDragSideBar() {
 | 
						|
  const limit = (x: number) => Math.min(MAX_SIDEBAR_WIDTH, x);
 | 
						|
 | 
						|
  const config = useAppConfig();
 | 
						|
  const startX = useRef(0);
 | 
						|
  const startDragWidth = useRef(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
 | 
						|
  const lastUpdateTime = useRef(Date.now());
 | 
						|
 | 
						|
  const toggleSideBar = () => {
 | 
						|
    config.update((config) => {
 | 
						|
      if (config.sidebarWidth < MIN_SIDEBAR_WIDTH) {
 | 
						|
        config.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
 | 
						|
      } else {
 | 
						|
        config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
 | 
						|
      }
 | 
						|
    });
 | 
						|
  };
 | 
						|
 | 
						|
  const onDragStart = (e: MouseEvent) => {
 | 
						|
    // Remembers the initial width each time the mouse is pressed
 | 
						|
    startX.current = e.clientX;
 | 
						|
    startDragWidth.current = config.sidebarWidth;
 | 
						|
    const dragStartTime = Date.now();
 | 
						|
 | 
						|
    const handleDragMove = (e: MouseEvent) => {
 | 
						|
      if (Date.now() < lastUpdateTime.current + 20) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      lastUpdateTime.current = Date.now();
 | 
						|
      const d = e.clientX - startX.current;
 | 
						|
      const nextWidth = limit(startDragWidth.current + d);
 | 
						|
      config.update((config) => {
 | 
						|
        if (nextWidth < MIN_SIDEBAR_WIDTH) {
 | 
						|
          config.sidebarWidth = NARROW_SIDEBAR_WIDTH;
 | 
						|
        } else {
 | 
						|
          config.sidebarWidth = nextWidth;
 | 
						|
        }
 | 
						|
      });
 | 
						|
    };
 | 
						|
 | 
						|
    const handleDragEnd = () => {
 | 
						|
      // In useRef the data is non-responsive, so `config.sidebarWidth` can't get the dynamic sidebarWidth
 | 
						|
      window.removeEventListener("pointermove", handleDragMove);
 | 
						|
      window.removeEventListener("pointerup", handleDragEnd);
 | 
						|
 | 
						|
      // if user click the drag icon, should toggle the sidebar
 | 
						|
      const shouldFireClick = Date.now() - dragStartTime < 300;
 | 
						|
      if (shouldFireClick) {
 | 
						|
        toggleSideBar();
 | 
						|
      }
 | 
						|
    };
 | 
						|
 | 
						|
    window.addEventListener("pointermove", handleDragMove);
 | 
						|
    window.addEventListener("pointerup", handleDragEnd);
 | 
						|
  };
 | 
						|
 | 
						|
  const isMobileScreen = useMobileScreen();
 | 
						|
  const shouldNarrow =
 | 
						|
    !isMobileScreen && config.sidebarWidth < MIN_SIDEBAR_WIDTH;
 | 
						|
 | 
						|
  useEffect(() => {
 | 
						|
    const barWidth = shouldNarrow
 | 
						|
      ? NARROW_SIDEBAR_WIDTH
 | 
						|
      : limit(config.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH);
 | 
						|
    const sideBarWidth = isMobileScreen ? "100vw" : `${barWidth}px`;
 | 
						|
    document.documentElement.style.setProperty("--sidebar-width", sideBarWidth);
 | 
						|
  }, [config.sidebarWidth, isMobileScreen, shouldNarrow]);
 | 
						|
 | 
						|
  return {
 | 
						|
    onDragStart,
 | 
						|
    shouldNarrow,
 | 
						|
  };
 | 
						|
}
 | 
						|
 | 
						|
export function SideBarContainer(props: {
 | 
						|
  children: React.ReactNode;
 | 
						|
  onDragStart: (e: MouseEvent) => void;
 | 
						|
  shouldNarrow: boolean;
 | 
						|
  className?: string;
 | 
						|
}) {
 | 
						|
  const isMobileScreen = useMobileScreen();
 | 
						|
  const isIOSMobile = useMemo(
 | 
						|
    () => isIOS() && isMobileScreen,
 | 
						|
    [isMobileScreen],
 | 
						|
  );
 | 
						|
  const { children, className, onDragStart, shouldNarrow } = props;
 | 
						|
  return (
 | 
						|
    <div
 | 
						|
      className={clsx(styles.sidebar, className, {
 | 
						|
        [styles["narrow-sidebar"]]: shouldNarrow,
 | 
						|
      })}
 | 
						|
      style={{
 | 
						|
        // #3016 disable transition on ios mobile screen
 | 
						|
        transition: isMobileScreen && isIOSMobile ? "none" : undefined,
 | 
						|
      }}
 | 
						|
    >
 | 
						|
      {children}
 | 
						|
      <div
 | 
						|
        className={styles["sidebar-drag"]}
 | 
						|
        onPointerDown={(e) => onDragStart(e as any)}
 | 
						|
      >
 | 
						|
        <DragIcon />
 | 
						|
      </div>
 | 
						|
    </div>
 | 
						|
  );
 | 
						|
}
 | 
						|
 | 
						|
export function SideBarHeader(props: {
 | 
						|
  title?: string | React.ReactNode;
 | 
						|
  subTitle?: string | React.ReactNode;
 | 
						|
  logo?: React.ReactNode;
 | 
						|
  children?: React.ReactNode;
 | 
						|
  shouldNarrow?: boolean;
 | 
						|
}) {
 | 
						|
  const { title, subTitle, logo, children, shouldNarrow } = props;
 | 
						|
  return (
 | 
						|
    <Fragment>
 | 
						|
      <div
 | 
						|
        className={clsx(styles["sidebar-header"], {
 | 
						|
          [styles["sidebar-header-narrow"]]: shouldNarrow,
 | 
						|
        })}
 | 
						|
        data-tauri-drag-region
 | 
						|
      >
 | 
						|
        <div className={styles["sidebar-title-container"]}>
 | 
						|
          <div className={styles["sidebar-title"]} data-tauri-drag-region>
 | 
						|
            {title}
 | 
						|
          </div>
 | 
						|
          <div className={styles["sidebar-sub-title"]}>{subTitle}</div>
 | 
						|
        </div>
 | 
						|
        <div className={clsx(styles["sidebar-logo"], "no-dark")}>{logo}</div>
 | 
						|
      </div>
 | 
						|
      {children}
 | 
						|
    </Fragment>
 | 
						|
  );
 | 
						|
}
 | 
						|
 | 
						|
export function SideBarBody(props: {
 | 
						|
  children: React.ReactNode;
 | 
						|
  onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
 | 
						|
}) {
 | 
						|
  const { onClick, children } = props;
 | 
						|
  return (
 | 
						|
    <div className={styles["sidebar-body"]} onClick={onClick}>
 | 
						|
      {children}
 | 
						|
    </div>
 | 
						|
  );
 | 
						|
}
 | 
						|
 | 
						|
export function SideBarTail(props: {
 | 
						|
  primaryAction?: React.ReactNode;
 | 
						|
  secondaryAction?: React.ReactNode;
 | 
						|
}) {
 | 
						|
  const { primaryAction, secondaryAction } = props;
 | 
						|
 | 
						|
  return (
 | 
						|
    <div className={styles["sidebar-tail"]}>
 | 
						|
      <div className={styles["sidebar-actions"]}>{primaryAction}</div>
 | 
						|
      <div className={styles["sidebar-actions"]}>{secondaryAction}</div>
 | 
						|
    </div>
 | 
						|
  );
 | 
						|
}
 | 
						|
 | 
						|
export function SideBar(props: { className?: string }) {
 | 
						|
  useHotKey();
 | 
						|
  const { onDragStart, shouldNarrow } = useDragSideBar();
 | 
						|
  const [showDiscoverySelector, setshowDiscoverySelector] = useState(false);
 | 
						|
  const navigate = useNavigate();
 | 
						|
  const config = useAppConfig();
 | 
						|
  const chatStore = useChatStore();
 | 
						|
  const [mcpEnabled, setMcpEnabled] = useState(false);
 | 
						|
 | 
						|
  useEffect(() => {
 | 
						|
    // 检查 MCP 是否启用
 | 
						|
    const checkMcpStatus = async () => {
 | 
						|
      const enabled = await isMcpEnabled();
 | 
						|
      setMcpEnabled(enabled);
 | 
						|
      console.log("[SideBar] MCP enabled:", enabled);
 | 
						|
    };
 | 
						|
    checkMcpStatus();
 | 
						|
  }, []);
 | 
						|
 | 
						|
  return (
 | 
						|
    <SideBarContainer
 | 
						|
      onDragStart={onDragStart}
 | 
						|
      shouldNarrow={shouldNarrow}
 | 
						|
      {...props}
 | 
						|
    >
 | 
						|
      <SideBarHeader
 | 
						|
        title="NextChat"
 | 
						|
        subTitle="Build your own AI assistant."
 | 
						|
        logo={<ChatGptIcon />}
 | 
						|
        shouldNarrow={shouldNarrow}
 | 
						|
      >
 | 
						|
        <div className={styles["sidebar-header-bar"]}>
 | 
						|
          <IconButton
 | 
						|
            icon={<MaskIcon />}
 | 
						|
            text={shouldNarrow ? undefined : Locale.Mask.Name}
 | 
						|
            className={styles["sidebar-bar-button"]}
 | 
						|
            onClick={() => {
 | 
						|
              if (config.dontShowMaskSplashScreen !== true) {
 | 
						|
                navigate(Path.NewChat, { state: { fromHome: true } });
 | 
						|
              } else {
 | 
						|
                navigate(Path.Masks, { state: { fromHome: true } });
 | 
						|
              }
 | 
						|
            }}
 | 
						|
            shadow
 | 
						|
          />
 | 
						|
          {mcpEnabled && (
 | 
						|
            <IconButton
 | 
						|
              icon={<McpIcon />}
 | 
						|
              text={shouldNarrow ? undefined : Locale.Mcp.Name}
 | 
						|
              className={styles["sidebar-bar-button"]}
 | 
						|
              onClick={() => {
 | 
						|
                navigate(Path.McpMarket, { state: { fromHome: true } });
 | 
						|
              }}
 | 
						|
              shadow
 | 
						|
            />
 | 
						|
          )}
 | 
						|
          <IconButton
 | 
						|
            icon={<DiscoveryIcon />}
 | 
						|
            text={shouldNarrow ? undefined : Locale.Discovery.Name}
 | 
						|
            className={styles["sidebar-bar-button"]}
 | 
						|
            onClick={() => setshowDiscoverySelector(true)}
 | 
						|
            shadow
 | 
						|
          />
 | 
						|
        </div>
 | 
						|
        {showDiscoverySelector && (
 | 
						|
          <Selector
 | 
						|
            items={[
 | 
						|
              ...DISCOVERY.map((item) => {
 | 
						|
                return {
 | 
						|
                  title: item.name,
 | 
						|
                  value: item.path,
 | 
						|
                };
 | 
						|
              }),
 | 
						|
            ]}
 | 
						|
            onClose={() => setshowDiscoverySelector(false)}
 | 
						|
            onSelection={(s) => {
 | 
						|
              navigate(s[0], { state: { fromHome: true } });
 | 
						|
            }}
 | 
						|
          />
 | 
						|
        )}
 | 
						|
      </SideBarHeader>
 | 
						|
      <SideBarBody
 | 
						|
        onClick={(e) => {
 | 
						|
          if (e.target === e.currentTarget) {
 | 
						|
            navigate(Path.Home);
 | 
						|
          }
 | 
						|
        }}
 | 
						|
      >
 | 
						|
        <ChatList narrow={shouldNarrow} />
 | 
						|
      </SideBarBody>
 | 
						|
      <SideBarTail
 | 
						|
        primaryAction={
 | 
						|
          <>
 | 
						|
            <div className={clsx(styles["sidebar-action"], styles.mobile)}>
 | 
						|
              <IconButton
 | 
						|
                icon={<DeleteIcon />}
 | 
						|
                onClick={async () => {
 | 
						|
                  if (await showConfirm(Locale.Home.DeleteChat)) {
 | 
						|
                    chatStore.deleteSession(chatStore.currentSessionIndex);
 | 
						|
                  }
 | 
						|
                }}
 | 
						|
              />
 | 
						|
            </div>
 | 
						|
            <div className={styles["sidebar-action"]}>
 | 
						|
              <Link to={Path.Settings}>
 | 
						|
                <IconButton
 | 
						|
                  aria={Locale.Settings.Title}
 | 
						|
                  icon={<SettingsIcon />}
 | 
						|
                  shadow
 | 
						|
                />
 | 
						|
              </Link>
 | 
						|
            </div>
 | 
						|
            <div className={styles["sidebar-action"]}>
 | 
						|
              <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
 | 
						|
                <IconButton
 | 
						|
                  aria={Locale.Export.MessageFromChatGPT}
 | 
						|
                  icon={<GithubIcon />}
 | 
						|
                  shadow
 | 
						|
                />
 | 
						|
              </a>
 | 
						|
            </div>
 | 
						|
          </>
 | 
						|
        }
 | 
						|
        secondaryAction={
 | 
						|
          <IconButton
 | 
						|
            icon={<AddIcon />}
 | 
						|
            text={shouldNarrow ? undefined : Locale.Home.NewChat}
 | 
						|
            onClick={() => {
 | 
						|
              if (config.dontShowMaskSplashScreen) {
 | 
						|
                chatStore.newSession();
 | 
						|
                navigate(Path.Chat);
 | 
						|
              } else {
 | 
						|
                navigate(Path.NewChat);
 | 
						|
              }
 | 
						|
            }}
 | 
						|
            shadow
 | 
						|
          />
 | 
						|
        }
 | 
						|
      />
 | 
						|
    </SideBarContainer>
 | 
						|
  );
 | 
						|
}
 |