Spaces:
Sleeping
Sleeping
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> | |
); | |
} | |