mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-11-13 04:33:42 +08:00
Merge branch 'main' into main
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
Fragment,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import SendWhiteIcon from "../icons/send-white.svg";
|
||||
import BrainIcon from "../icons/brain.svg";
|
||||
import RenameIcon from "../icons/rename.svg";
|
||||
import EditIcon from "../icons/rename.svg";
|
||||
import ExportIcon from "../icons/share.svg";
|
||||
import ReturnIcon from "../icons/return.svg";
|
||||
import CopyIcon from "../icons/copy.svg";
|
||||
@@ -24,11 +25,11 @@ import MaskIcon from "../icons/mask.svg";
|
||||
import MaxIcon from "../icons/max.svg";
|
||||
import MinIcon from "../icons/min.svg";
|
||||
import ResetIcon from "../icons/reload.svg";
|
||||
import ReloadIcon from "../icons/reload.svg";
|
||||
import BreakIcon from "../icons/break.svg";
|
||||
import SettingsIcon from "../icons/chat-settings.svg";
|
||||
import DeleteIcon from "../icons/clear.svg";
|
||||
import PinIcon from "../icons/pin.svg";
|
||||
import EditIcon from "../icons/rename.svg";
|
||||
import ConfirmIcon from "../icons/confirm.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import CancelIcon from "../icons/cancel.svg";
|
||||
@@ -45,35 +46,35 @@ import QualityIcon from "../icons/hd.svg";
|
||||
import StyleIcon from "../icons/palette.svg";
|
||||
import PluginIcon from "../icons/plugin.svg";
|
||||
import ShortcutkeyIcon from "../icons/shortcutkey.svg";
|
||||
import ReloadIcon from "../icons/reload.svg";
|
||||
import McpToolIcon from "../icons/tool.svg";
|
||||
import HeadphoneIcon from "../icons/headphone.svg";
|
||||
import {
|
||||
ChatMessage,
|
||||
SubmitKey,
|
||||
useChatStore,
|
||||
BOT_HELLO,
|
||||
ChatMessage,
|
||||
createMessage,
|
||||
useAccessStore,
|
||||
Theme,
|
||||
useAppConfig,
|
||||
DEFAULT_TOPIC,
|
||||
ModelType,
|
||||
SubmitKey,
|
||||
Theme,
|
||||
useAccessStore,
|
||||
useAppConfig,
|
||||
useChatStore,
|
||||
usePluginStore,
|
||||
} from "../store";
|
||||
|
||||
import {
|
||||
copyToClipboard,
|
||||
selectOrCopy,
|
||||
autoGrowTextArea,
|
||||
useMobileScreen,
|
||||
getMessageTextContent,
|
||||
copyToClipboard,
|
||||
getMessageImages,
|
||||
isVisionModel,
|
||||
getMessageTextContent,
|
||||
isDalle3,
|
||||
showPlugins,
|
||||
isVisionModel,
|
||||
safeLocalStorage,
|
||||
getModelSizes,
|
||||
supportsCustomSize,
|
||||
useMobileScreen,
|
||||
selectOrCopy,
|
||||
showPlugins,
|
||||
} from "../utils";
|
||||
|
||||
import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
|
||||
@@ -104,8 +105,8 @@ import {
|
||||
ModelProvider,
|
||||
Path,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
UNFINISHED_INPUT,
|
||||
ServiceProvider,
|
||||
UNFINISHED_INPUT,
|
||||
} from "../constant";
|
||||
import { Avatar } from "./emoji";
|
||||
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
|
||||
@@ -115,9 +116,7 @@ import { prettyObject } from "../utils/format";
|
||||
import { ExportMessageModal } from "./exporter";
|
||||
import { getClientConfig } from "../config/client";
|
||||
import { useAllModels } from "../utils/hooks";
|
||||
import { MultimodalContent } from "../client/api";
|
||||
|
||||
import { ClientApi } from "../client/api";
|
||||
import { ClientApi, MultimodalContent } from "../client/api";
|
||||
import { createTTSPlayer } from "../utils/audio";
|
||||
import { MsEdgeTTS, OUTPUT_FORMAT } from "../utils/ms_edge_tts";
|
||||
|
||||
@@ -125,6 +124,7 @@ import { isEmpty } from "lodash-es";
|
||||
import { getModelProvider } from "../utils/model";
|
||||
import { RealtimeChat } from "@/app/components/realtime-chat";
|
||||
import clsx from "clsx";
|
||||
import { getAvailableClientsCount, isMcpEnabled } from "../mcp/actions";
|
||||
|
||||
const localStorage = safeLocalStorage();
|
||||
|
||||
@@ -134,6 +134,34 @@ const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
|
||||
loading: () => <LoadingIcon />,
|
||||
});
|
||||
|
||||
const MCPAction = () => {
|
||||
const navigate = useNavigate();
|
||||
const [count, setCount] = useState<number>(0);
|
||||
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMcpStatus = async () => {
|
||||
const enabled = await isMcpEnabled();
|
||||
setMcpEnabled(enabled);
|
||||
if (enabled) {
|
||||
const count = await getAvailableClientsCount();
|
||||
setCount(count);
|
||||
}
|
||||
};
|
||||
checkMcpStatus();
|
||||
}, []);
|
||||
|
||||
if (!mcpEnabled) return null;
|
||||
|
||||
return (
|
||||
<ChatAction
|
||||
onClick={() => navigate(Path.McpMarket)}
|
||||
text={`MCP${count ? ` (${count})` : ""}`}
|
||||
icon={<McpToolIcon />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function SessionConfigModel(props: { onClose: () => void }) {
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
@@ -425,11 +453,11 @@ export function ChatAction(props: {
|
||||
function useScrollToBottom(
|
||||
scrollRef: RefObject<HTMLDivElement>,
|
||||
detach: boolean = false,
|
||||
messages: ChatMessage[],
|
||||
) {
|
||||
// for auto-scroll
|
||||
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
function scrollDomToBottom() {
|
||||
const scrollDomToBottom = useCallback(() => {
|
||||
const dom = scrollRef.current;
|
||||
if (dom) {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -437,7 +465,7 @@ function useScrollToBottom(
|
||||
dom.scrollTo(0, dom.scrollHeight);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [scrollRef]);
|
||||
|
||||
// auto scroll
|
||||
useEffect(() => {
|
||||
@@ -446,6 +474,15 @@ function useScrollToBottom(
|
||||
}
|
||||
});
|
||||
|
||||
// auto scroll when messages length changes
|
||||
const lastMessagesLength = useRef(messages.length);
|
||||
useEffect(() => {
|
||||
if (messages.length > lastMessagesLength.current && !detach) {
|
||||
scrollDomToBottom();
|
||||
}
|
||||
lastMessagesLength.current = messages.length;
|
||||
}, [messages.length, detach, scrollDomToBottom]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
autoScroll,
|
||||
@@ -475,6 +512,7 @@ export function ChatActions(props: {
|
||||
|
||||
// switch themes
|
||||
const theme = config.theme;
|
||||
|
||||
function nextTheme() {
|
||||
const themes = [Theme.Auto, Theme.Light, Theme.Dark];
|
||||
const themeIndex = themes.indexOf(theme);
|
||||
@@ -794,6 +832,7 @@ export function ChatActions(props: {
|
||||
icon={<ShortcutkeyIcon />}
|
||||
/>
|
||||
)}
|
||||
{!isMobileScreen && <MCPAction />}
|
||||
</>
|
||||
<div className={styles["chat-input-actions-end"]}>
|
||||
{config.realtimeConfig.enable && (
|
||||
@@ -987,6 +1026,7 @@ function _Chat() {
|
||||
const { setAutoScroll, scrollDomToBottom } = useScrollToBottom(
|
||||
scrollRef,
|
||||
(isScrolledToBottom || isAttachWithTop) && !isTyping,
|
||||
session.messages,
|
||||
);
|
||||
const [hitBottom, setHitBottom] = useState(true);
|
||||
const isMobileScreen = useMobileScreen();
|
||||
@@ -1246,6 +1286,7 @@ function _Chat() {
|
||||
const accessStore = useAccessStore();
|
||||
const [speechStatus, setSpeechStatus] = useState(false);
|
||||
const [speechLoading, setSpeechLoading] = useState(false);
|
||||
|
||||
async function openaiSpeech(text: string) {
|
||||
if (speechStatus) {
|
||||
ttsPlayer.stop();
|
||||
@@ -1345,6 +1386,7 @@ function _Chat() {
|
||||
const [msgRenderIndex, _setMsgRenderIndex] = useState(
|
||||
Math.max(0, renderMessages.length - CHAT_PAGE_SIZE),
|
||||
);
|
||||
|
||||
function setMsgRenderIndex(newIndex: number) {
|
||||
newIndex = Math.min(renderMessages.length - CHAT_PAGE_SIZE, newIndex);
|
||||
newIndex = Math.max(0, newIndex);
|
||||
@@ -1380,6 +1422,7 @@ function _Chat() {
|
||||
setHitBottom(isHitBottom);
|
||||
setAutoScroll(isHitBottom);
|
||||
};
|
||||
|
||||
function scrollToBottom() {
|
||||
setMsgRenderIndex(renderMessages.length - CHAT_PAGE_SIZE);
|
||||
scrollDomToBottom();
|
||||
@@ -1737,252 +1780,264 @@ function _Chat() {
|
||||
setAutoScroll(false);
|
||||
}}
|
||||
>
|
||||
{messages.map((message, i) => {
|
||||
const isUser = message.role === "user";
|
||||
const isContext = i < context.length;
|
||||
const showActions =
|
||||
i > 0 &&
|
||||
!(message.preview || message.content.length === 0) &&
|
||||
!isContext;
|
||||
const showTyping = message.preview || message.streaming;
|
||||
{messages
|
||||
// TODO
|
||||
// .filter((m) => !m.isMcpResponse)
|
||||
.map((message, i) => {
|
||||
const isUser = message.role === "user";
|
||||
const isContext = i < context.length;
|
||||
const showActions =
|
||||
i > 0 &&
|
||||
!(message.preview || message.content.length === 0) &&
|
||||
!isContext;
|
||||
const showTyping = message.preview || message.streaming;
|
||||
|
||||
const shouldShowClearContextDivider =
|
||||
i === clearContextIndex - 1;
|
||||
const shouldShowClearContextDivider =
|
||||
i === clearContextIndex - 1;
|
||||
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<div
|
||||
className={
|
||||
isUser
|
||||
? styles["chat-message-user"]
|
||||
: styles["chat-message"]
|
||||
}
|
||||
>
|
||||
<div className={styles["chat-message-container"]}>
|
||||
<div className={styles["chat-message-header"]}>
|
||||
<div className={styles["chat-message-avatar"]}>
|
||||
<div className={styles["chat-message-edit"]}>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
aria={Locale.Chat.Actions.Edit}
|
||||
onClick={async () => {
|
||||
const newMessage = await showPrompt(
|
||||
Locale.Chat.Actions.Edit,
|
||||
getMessageTextContent(message),
|
||||
10,
|
||||
);
|
||||
let newContent: string | MultimodalContent[] =
|
||||
newMessage;
|
||||
const images = getMessageImages(message);
|
||||
if (images.length > 0) {
|
||||
newContent = [
|
||||
{ type: "text", text: newMessage },
|
||||
];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
newContent.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: images[i],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
chatStore.updateTargetSession(
|
||||
session,
|
||||
(session) => {
|
||||
const m = session.mask.context
|
||||
.concat(session.messages)
|
||||
.find((m) => m.id === message.id);
|
||||
if (m) {
|
||||
m.content = newContent;
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<div
|
||||
className={
|
||||
isUser
|
||||
? styles["chat-message-user"]
|
||||
: styles["chat-message"]
|
||||
}
|
||||
>
|
||||
<div className={styles["chat-message-container"]}>
|
||||
<div className={styles["chat-message-header"]}>
|
||||
<div className={styles["chat-message-avatar"]}>
|
||||
<div className={styles["chat-message-edit"]}>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
aria={Locale.Chat.Actions.Edit}
|
||||
onClick={async () => {
|
||||
const newMessage = await showPrompt(
|
||||
Locale.Chat.Actions.Edit,
|
||||
getMessageTextContent(message),
|
||||
10,
|
||||
);
|
||||
let newContent:
|
||||
| string
|
||||
| MultimodalContent[] = newMessage;
|
||||
const images = getMessageImages(message);
|
||||
if (images.length > 0) {
|
||||
newContent = [
|
||||
{ type: "text", text: newMessage },
|
||||
];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
newContent.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: images[i],
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
></IconButton>
|
||||
</div>
|
||||
{isUser ? (
|
||||
<Avatar avatar={config.avatar} />
|
||||
) : (
|
||||
<>
|
||||
{["system"].includes(message.role) ? (
|
||||
<Avatar avatar="2699-fe0f" />
|
||||
) : (
|
||||
<MaskAvatar
|
||||
avatar={session.mask.avatar}
|
||||
model={
|
||||
message.model ||
|
||||
session.mask.modelConfig.model
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
chatStore.updateTargetSession(
|
||||
session,
|
||||
(session) => {
|
||||
const m = session.mask.context
|
||||
.concat(session.messages)
|
||||
.find((m) => m.id === message.id);
|
||||
if (m) {
|
||||
m.content = newContent;
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
></IconButton>
|
||||
</div>
|
||||
{isUser ? (
|
||||
<Avatar avatar={config.avatar} />
|
||||
) : (
|
||||
<>
|
||||
{["system"].includes(message.role) ? (
|
||||
<Avatar avatar="2699-fe0f" />
|
||||
) : (
|
||||
<MaskAvatar
|
||||
avatar={session.mask.avatar}
|
||||
model={
|
||||
message.model ||
|
||||
session.mask.modelConfig.model
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isUser && (
|
||||
<div className={styles["chat-model-name"]}>
|
||||
{message.model}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isUser && (
|
||||
<div className={styles["chat-model-name"]}>
|
||||
{message.model}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showActions && (
|
||||
<div className={styles["chat-message-actions"]}>
|
||||
<div className={styles["chat-input-actions"]}>
|
||||
{message.streaming ? (
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Stop}
|
||||
icon={<StopIcon />}
|
||||
onClick={() => onUserStop(message.id ?? i)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{showActions && (
|
||||
<div className={styles["chat-message-actions"]}>
|
||||
<div className={styles["chat-input-actions"]}>
|
||||
{message.streaming ? (
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Retry}
|
||||
icon={<ResetIcon />}
|
||||
onClick={() => onResend(message)}
|
||||
/>
|
||||
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Delete}
|
||||
icon={<DeleteIcon />}
|
||||
onClick={() => onDelete(message.id ?? i)}
|
||||
/>
|
||||
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Pin}
|
||||
icon={<PinIcon />}
|
||||
onClick={() => onPinMessage(message)}
|
||||
/>
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Copy}
|
||||
icon={<CopyIcon />}
|
||||
text={Locale.Chat.Actions.Stop}
|
||||
icon={<StopIcon />}
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
getMessageTextContent(message),
|
||||
)
|
||||
onUserStop(message.id ?? i)
|
||||
}
|
||||
/>
|
||||
{config.ttsConfig.enable && (
|
||||
) : (
|
||||
<>
|
||||
<ChatAction
|
||||
text={
|
||||
speechStatus
|
||||
? Locale.Chat.Actions.StopSpeech
|
||||
: Locale.Chat.Actions.Speech
|
||||
}
|
||||
icon={
|
||||
speechStatus ? (
|
||||
<SpeakStopIcon />
|
||||
) : (
|
||||
<SpeakIcon />
|
||||
)
|
||||
}
|
||||
text={Locale.Chat.Actions.Retry}
|
||||
icon={<ResetIcon />}
|
||||
onClick={() => onResend(message)}
|
||||
/>
|
||||
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Delete}
|
||||
icon={<DeleteIcon />}
|
||||
onClick={() =>
|
||||
openaiSpeech(
|
||||
onDelete(message.id ?? i)
|
||||
}
|
||||
/>
|
||||
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Pin}
|
||||
icon={<PinIcon />}
|
||||
onClick={() => onPinMessage(message)}
|
||||
/>
|
||||
<ChatAction
|
||||
text={Locale.Chat.Actions.Copy}
|
||||
icon={<CopyIcon />}
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
getMessageTextContent(message),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{config.ttsConfig.enable && (
|
||||
<ChatAction
|
||||
text={
|
||||
speechStatus
|
||||
? Locale.Chat.Actions.StopSpeech
|
||||
: Locale.Chat.Actions.Speech
|
||||
}
|
||||
icon={
|
||||
speechStatus ? (
|
||||
<SpeakStopIcon />
|
||||
) : (
|
||||
<SpeakIcon />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
openaiSpeech(
|
||||
getMessageTextContent(message),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{message?.tools?.length == 0 && showTyping && (
|
||||
<div className={styles["chat-message-status"]}>
|
||||
{Locale.Chat.Typing}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{message?.tools?.length == 0 && showTyping && (
|
||||
<div className={styles["chat-message-status"]}>
|
||||
{Locale.Chat.Typing}
|
||||
</div>
|
||||
)}
|
||||
{/*@ts-ignore*/}
|
||||
{message?.tools?.length > 0 && (
|
||||
<div className={styles["chat-message-tools"]}>
|
||||
{message?.tools?.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
title={tool?.errorMsg}
|
||||
className={styles["chat-message-tool"]}
|
||||
>
|
||||
{tool.isError === false ? (
|
||||
<ConfirmIcon />
|
||||
) : tool.isError === true ? (
|
||||
<CloseIcon />
|
||||
) : (
|
||||
<LoadingButtonIcon />
|
||||
)}
|
||||
<span>{tool?.function?.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles["chat-message-item"]}>
|
||||
<Markdown
|
||||
key={message.streaming ? "loading" : "done"}
|
||||
content={getMessageTextContent(message)}
|
||||
loading={
|
||||
(message.preview || message.streaming) &&
|
||||
message.content.length === 0 &&
|
||||
!isUser
|
||||
}
|
||||
// onContextMenu={(e) => onRightClick(e, message)} // hard to use
|
||||
onDoubleClickCapture={() => {
|
||||
if (!isMobileScreen) return;
|
||||
setUserInput(getMessageTextContent(message));
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
fontFamily={fontFamily}
|
||||
parentRef={scrollRef}
|
||||
defaultShow={i >= messages.length - 6}
|
||||
/>
|
||||
{getMessageImages(message).length == 1 && (
|
||||
<img
|
||||
className={styles["chat-message-item-image"]}
|
||||
src={getMessageImages(message)[0]}
|
||||
alt=""
|
||||
/>
|
||||
{/*@ts-ignore*/}
|
||||
{message?.tools?.length > 0 && (
|
||||
<div className={styles["chat-message-tools"]}>
|
||||
{message?.tools?.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
title={tool?.errorMsg}
|
||||
className={styles["chat-message-tool"]}
|
||||
>
|
||||
{tool.isError === false ? (
|
||||
<ConfirmIcon />
|
||||
) : tool.isError === true ? (
|
||||
<CloseIcon />
|
||||
) : (
|
||||
<LoadingButtonIcon />
|
||||
)}
|
||||
<span>{tool?.function?.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{getMessageImages(message).length > 1 && (
|
||||
<div
|
||||
className={styles["chat-message-item-images"]}
|
||||
style={
|
||||
{
|
||||
"--image-count":
|
||||
getMessageImages(message).length,
|
||||
} as React.CSSProperties
|
||||
<div className={styles["chat-message-item"]}>
|
||||
<Markdown
|
||||
key={message.streaming ? "loading" : "done"}
|
||||
content={getMessageTextContent(message)}
|
||||
loading={
|
||||
(message.preview || message.streaming) &&
|
||||
message.content.length === 0 &&
|
||||
!isUser
|
||||
}
|
||||
>
|
||||
{getMessageImages(message).map((image, index) => {
|
||||
return (
|
||||
<img
|
||||
className={
|
||||
styles["chat-message-item-image-multi"]
|
||||
}
|
||||
key={index}
|
||||
src={image}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
})}
|
||||
// onContextMenu={(e) => onRightClick(e, message)} // hard to use
|
||||
onDoubleClickCapture={() => {
|
||||
if (!isMobileScreen) return;
|
||||
setUserInput(getMessageTextContent(message));
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
fontFamily={fontFamily}
|
||||
parentRef={scrollRef}
|
||||
defaultShow={i >= messages.length - 6}
|
||||
/>
|
||||
{getMessageImages(message).length == 1 && (
|
||||
<img
|
||||
className={styles["chat-message-item-image"]}
|
||||
src={getMessageImages(message)[0]}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
{getMessageImages(message).length > 1 && (
|
||||
<div
|
||||
className={styles["chat-message-item-images"]}
|
||||
style={
|
||||
{
|
||||
"--image-count":
|
||||
getMessageImages(message).length,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{getMessageImages(message).map(
|
||||
(image, index) => {
|
||||
return (
|
||||
<img
|
||||
className={
|
||||
styles[
|
||||
"chat-message-item-image-multi"
|
||||
]
|
||||
}
|
||||
key={index}
|
||||
src={image}
|
||||
alt=""
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{message?.audio_url && (
|
||||
<div className={styles["chat-message-audio"]}>
|
||||
<audio src={message.audio_url} controls />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{message?.audio_url && (
|
||||
<div className={styles["chat-message-audio"]}>
|
||||
<audio src={message.audio_url} controls />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles["chat-message-action-date"]}>
|
||||
{isContext
|
||||
? Locale.Chat.IsContext
|
||||
: message.date.toLocaleString()}
|
||||
<div className={styles["chat-message-action-date"]}>
|
||||
{isContext
|
||||
? Locale.Chat.IsContext
|
||||
: message.date.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowClearContextDivider && <ClearContextDivider />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{shouldShowClearContextDivider && <ClearContextDivider />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles["chat-input-panel"]}>
|
||||
<PromptHints
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
require("../polyfill");
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import styles from "./home.module.scss";
|
||||
|
||||
import BotIcon from "../icons/bot.svg";
|
||||
@@ -18,8 +18,8 @@ import { getISOLang, getLang } from "../locales";
|
||||
|
||||
import {
|
||||
HashRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { SideBar } from "./sidebar";
|
||||
@@ -29,6 +29,7 @@ import { getClientConfig } from "../config/client";
|
||||
import { type ClientApi, getClientApi } from "../client/api";
|
||||
import { useAccessStore } from "../store";
|
||||
import clsx from "clsx";
|
||||
import { initializeMcpSystem, isMcpEnabled } from "../mcp/actions";
|
||||
|
||||
export function Loading(props: { noLogo?: boolean }) {
|
||||
return (
|
||||
@@ -74,6 +75,13 @@ const Sd = dynamic(async () => (await import("./sd")).Sd, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const McpMarketPage = dynamic(
|
||||
async () => (await import("./mcp-market")).McpMarketPage,
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
export function useSwitchTheme() {
|
||||
const config = useAppConfig();
|
||||
|
||||
@@ -193,6 +201,7 @@ function Screen() {
|
||||
<Route path={Path.SearchChat} element={<SearchChat />} />
|
||||
<Route path={Path.Chat} element={<Chat />} />
|
||||
<Route path={Path.Settings} element={<Settings />} />
|
||||
<Route path={Path.McpMarket} element={<McpMarketPage />} />
|
||||
</Routes>
|
||||
</WindowContent>
|
||||
</>
|
||||
@@ -233,6 +242,20 @@ export function Home() {
|
||||
useEffect(() => {
|
||||
console.log("[Config] got config from build time", getClientConfig());
|
||||
useAccessStore.getState().fetch();
|
||||
|
||||
const initMcp = async () => {
|
||||
try {
|
||||
const enabled = await isMcpEnabled();
|
||||
if (enabled) {
|
||||
console.log("[MCP] initializing...");
|
||||
await initializeMcpSystem();
|
||||
console.log("[MCP] initialized");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[MCP] failed to initialize:", err);
|
||||
}
|
||||
};
|
||||
initMcp();
|
||||
}, []);
|
||||
|
||||
if (!useHasHydrated()) {
|
||||
|
||||
657
app/components/mcp-market.module.scss
Normal file
657
app/components/mcp-market.module.scss
Normal file
@@ -0,0 +1,657 @@
|
||||
@import "../styles/animation.scss";
|
||||
|
||||
.mcp-market-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.loading-indicator {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.mcp-market-page-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
.loading-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
background-color: var(--white);
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
animation: slide-in ease 0.3s;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: var(--black);
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mcp-market-filter {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
animation: slide-in ease 0.3s;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
|
||||
.search-bar {
|
||||
flex-grow: 1;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.server-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.mcp-market-item {
|
||||
padding: 20px;
|
||||
border: var(--border-in-light);
|
||||
animation: slide-in ease 0.3s;
|
||||
background-color: var(--white);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
position: relative;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: loading-pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.operation-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: #16a34a;
|
||||
color: #fff;
|
||||
animation: pulse 1.5s infinite;
|
||||
|
||||
&[data-status="stopping"] {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
&[data-status="starting"] {
|
||||
background-color: #4ade80;
|
||||
}
|
||||
|
||||
&[data-status="error"] {
|
||||
background-color: #f87171;
|
||||
}
|
||||
}
|
||||
|
||||
.mcp-market-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
|
||||
.mcp-market-title {
|
||||
flex-grow: 1;
|
||||
margin-right: 20px;
|
||||
max-width: calc(100% - 300px);
|
||||
}
|
||||
|
||||
.mcp-market-name {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.server-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: #22c55e;
|
||||
color: #fff;
|
||||
|
||||
&.error {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
&.stopped {
|
||||
background-color: #6b7280;
|
||||
}
|
||||
|
||||
&.initializing {
|
||||
background-color: #f59e0b;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.repo-link {
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-decoration: none;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--gray);
|
||||
color: var(--black);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.mcp-market-info {
|
||||
color: var(--black);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mcp-market-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
min-width: 180px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.array-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 10px;
|
||||
background-color: var(--white);
|
||||
|
||||
.array-input-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--gray-50);
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--gray-200);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-100);
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: var(--white);
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--primary-10);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--gray-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.icon-button.add-path-button) {
|
||||
width: 100%;
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
height: 36px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
filter: brightness(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.path-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.path-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 10px;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--primary-10);
|
||||
}
|
||||
}
|
||||
|
||||
.browse-button {
|
||||
padding: 8px;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
background-color: transparent;
|
||||
color: var(--black-50);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
padding: 8px;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
background-color: transparent;
|
||||
color: var(--black-50);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.add-button {
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px 12px;
|
||||
background-color: transparent;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
color: var(--black);
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.config-section {
|
||||
width: 100%;
|
||||
|
||||
.config-header {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.config-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--black);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.config-description {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.array-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 10px;
|
||||
background-color: var(--white);
|
||||
|
||||
.array-input-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--gray-50);
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--gray-200);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-100);
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: var(--white);
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--primary-10);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--gray-300);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.icon-button) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--gray-200);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-100);
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.icon-button.add-path-button) {
|
||||
width: 100%;
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
height: 36px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
filter: brightness(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-item {
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: var(--border-in-light);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
background-color: var(--white);
|
||||
color: var(--black);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--primary-10);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--gray-300) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tools-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
|
||||
.tool-item {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.tool-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--black);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid var(--primary);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
line-height: 1.6;
|
||||
padding-left: 15px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.modal-content {
|
||||
margin-top: 20px;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.list-header {
|
||||
margin-bottom: 0;
|
||||
|
||||
.list-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-transform: capitalize;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.list-sub-title {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
755
app/components/mcp-market.tsx
Normal file
755
app/components/mcp-market.tsx
Normal file
@@ -0,0 +1,755 @@
|
||||
import { IconButton } from "./button";
|
||||
import { ErrorBoundary } from "./error";
|
||||
import styles from "./mcp-market.module.scss";
|
||||
import EditIcon from "../icons/edit.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import RestartIcon from "../icons/reload.svg";
|
||||
import EyeIcon from "../icons/eye.svg";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
import { List, ListItem, Modal, showToast } from "./ui-lib";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
addMcpServer,
|
||||
getClientsStatus,
|
||||
getClientTools,
|
||||
getMcpConfigFromFile,
|
||||
isMcpEnabled,
|
||||
pauseMcpServer,
|
||||
restartAllClients,
|
||||
resumeMcpServer,
|
||||
} from "../mcp/actions";
|
||||
import {
|
||||
ListToolsResponse,
|
||||
McpConfigData,
|
||||
PresetServer,
|
||||
ServerConfig,
|
||||
ServerStatusResponse,
|
||||
} from "../mcp/types";
|
||||
import clsx from "clsx";
|
||||
import PlayIcon from "../icons/play.svg";
|
||||
import StopIcon from "../icons/pause.svg";
|
||||
import { Path } from "../constant";
|
||||
|
||||
interface ConfigProperty {
|
||||
type: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
minItems?: number;
|
||||
}
|
||||
|
||||
export function McpMarketPage() {
|
||||
const navigate = useNavigate();
|
||||
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [userConfig, setUserConfig] = useState<Record<string, any>>({});
|
||||
const [editingServerId, setEditingServerId] = useState<string | undefined>();
|
||||
const [tools, setTools] = useState<ListToolsResponse["tools"] | null>(null);
|
||||
const [viewingServerId, setViewingServerId] = useState<string | undefined>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [config, setConfig] = useState<McpConfigData>();
|
||||
const [clientStatuses, setClientStatuses] = useState<
|
||||
Record<string, ServerStatusResponse>
|
||||
>({});
|
||||
const [loadingPresets, setLoadingPresets] = useState(true);
|
||||
const [presetServers, setPresetServers] = useState<PresetServer[]>([]);
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// 检查 MCP 是否启用
|
||||
useEffect(() => {
|
||||
const checkMcpStatus = async () => {
|
||||
const enabled = await isMcpEnabled();
|
||||
setMcpEnabled(enabled);
|
||||
if (!enabled) {
|
||||
navigate(Path.Home);
|
||||
}
|
||||
};
|
||||
checkMcpStatus();
|
||||
}, [navigate]);
|
||||
|
||||
// 添加状态轮询
|
||||
useEffect(() => {
|
||||
if (!mcpEnabled || !config) return;
|
||||
|
||||
const updateStatuses = async () => {
|
||||
const statuses = await getClientsStatus();
|
||||
setClientStatuses(statuses);
|
||||
};
|
||||
|
||||
// 立即执行一次
|
||||
updateStatuses();
|
||||
// 每 1000ms 轮询一次
|
||||
const timer = setInterval(updateStatuses, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [mcpEnabled, config]);
|
||||
|
||||
// 加载预设服务器
|
||||
useEffect(() => {
|
||||
const loadPresetServers = async () => {
|
||||
if (!mcpEnabled) return;
|
||||
try {
|
||||
setLoadingPresets(true);
|
||||
const response = await fetch("https://nextchat.club/mcp/list");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load preset servers");
|
||||
}
|
||||
const data = await response.json();
|
||||
setPresetServers(data?.data ?? []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load preset servers:", error);
|
||||
showToast("Failed to load preset servers");
|
||||
} finally {
|
||||
setLoadingPresets(false);
|
||||
}
|
||||
};
|
||||
loadPresetServers();
|
||||
}, [mcpEnabled]);
|
||||
|
||||
// 加载初始状态
|
||||
useEffect(() => {
|
||||
const loadInitialState = async () => {
|
||||
if (!mcpEnabled) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const config = await getMcpConfigFromFile();
|
||||
setConfig(config);
|
||||
|
||||
// 获取所有客户端的状态
|
||||
const statuses = await getClientsStatus();
|
||||
setClientStatuses(statuses);
|
||||
} catch (error) {
|
||||
console.error("Failed to load initial state:", error);
|
||||
showToast("Failed to load initial state");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadInitialState();
|
||||
}, [mcpEnabled]);
|
||||
|
||||
// 加载当前编辑服务器的配置
|
||||
useEffect(() => {
|
||||
if (!editingServerId || !config) return;
|
||||
const currentConfig = config.mcpServers[editingServerId];
|
||||
if (currentConfig) {
|
||||
// 从当前配置中提取用户配置
|
||||
const preset = presetServers.find((s) => s.id === editingServerId);
|
||||
if (preset?.configSchema) {
|
||||
const userConfig: Record<string, any> = {};
|
||||
Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
|
||||
if (mapping.type === "spread") {
|
||||
// For spread types, extract the array from args.
|
||||
const startPos = mapping.position ?? 0;
|
||||
userConfig[key] = currentConfig.args.slice(startPos);
|
||||
} else if (mapping.type === "single") {
|
||||
// For single types, get a single value
|
||||
userConfig[key] = currentConfig.args[mapping.position ?? 0];
|
||||
} else if (
|
||||
mapping.type === "env" &&
|
||||
mapping.key &&
|
||||
currentConfig.env
|
||||
) {
|
||||
// For env types, get values from environment variables
|
||||
userConfig[key] = currentConfig.env[mapping.key];
|
||||
}
|
||||
});
|
||||
setUserConfig(userConfig);
|
||||
}
|
||||
} else {
|
||||
setUserConfig({});
|
||||
}
|
||||
}, [editingServerId, config, presetServers]);
|
||||
|
||||
if (!mcpEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查服务器是否已添加
|
||||
const isServerAdded = (id: string) => {
|
||||
return id in (config?.mcpServers ?? {});
|
||||
};
|
||||
|
||||
// 保存服务器配置
|
||||
const saveServerConfig = async () => {
|
||||
const preset = presetServers.find((s) => s.id === editingServerId);
|
||||
if (!preset || !preset.configSchema || !editingServerId) return;
|
||||
|
||||
const savingServerId = editingServerId;
|
||||
setEditingServerId(undefined);
|
||||
|
||||
try {
|
||||
updateLoadingState(savingServerId, "Updating configuration...");
|
||||
// 构建服务器配置
|
||||
const args = [...preset.baseArgs];
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
|
||||
const value = userConfig[key];
|
||||
if (mapping.type === "spread" && Array.isArray(value)) {
|
||||
const pos = mapping.position ?? 0;
|
||||
args.splice(pos, 0, ...value);
|
||||
} else if (
|
||||
mapping.type === "single" &&
|
||||
mapping.position !== undefined
|
||||
) {
|
||||
args[mapping.position] = value;
|
||||
} else if (
|
||||
mapping.type === "env" &&
|
||||
mapping.key &&
|
||||
typeof value === "string"
|
||||
) {
|
||||
env[mapping.key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const serverConfig: ServerConfig = {
|
||||
command: preset.command,
|
||||
args,
|
||||
...(Object.keys(env).length > 0 ? { env } : {}),
|
||||
};
|
||||
|
||||
const newConfig = await addMcpServer(savingServerId, serverConfig);
|
||||
setConfig(newConfig);
|
||||
showToast("Server configuration updated successfully");
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "Failed to save configuration",
|
||||
);
|
||||
} finally {
|
||||
updateLoadingState(savingServerId, null);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取服务器支持的 Tools
|
||||
const loadTools = async (id: string) => {
|
||||
try {
|
||||
const result = await getClientTools(id);
|
||||
if (result) {
|
||||
setTools(result);
|
||||
} else {
|
||||
throw new Error("Failed to load tools");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast("Failed to load tools");
|
||||
console.error(error);
|
||||
setTools(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新加载状态的辅助函数
|
||||
const updateLoadingState = (id: string, message: string | null) => {
|
||||
setLoadingStates((prev) => {
|
||||
if (message === null) {
|
||||
const { [id]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return { ...prev, [id]: message };
|
||||
});
|
||||
};
|
||||
|
||||
// 修改添加服务器函数
|
||||
const addServer = async (preset: PresetServer) => {
|
||||
if (!preset.configurable) {
|
||||
try {
|
||||
const serverId = preset.id;
|
||||
updateLoadingState(serverId, "Creating MCP client...");
|
||||
|
||||
const serverConfig: ServerConfig = {
|
||||
command: preset.command,
|
||||
args: [...preset.baseArgs],
|
||||
};
|
||||
const newConfig = await addMcpServer(preset.id, serverConfig);
|
||||
setConfig(newConfig);
|
||||
|
||||
// 更新状态
|
||||
const statuses = await getClientsStatus();
|
||||
setClientStatuses(statuses);
|
||||
} finally {
|
||||
updateLoadingState(preset.id, null);
|
||||
}
|
||||
} else {
|
||||
// 如果需要配置,打开配置对话框
|
||||
setEditingServerId(preset.id);
|
||||
setUserConfig({});
|
||||
}
|
||||
};
|
||||
|
||||
// 修改暂停服务器函数
|
||||
const pauseServer = async (id: string) => {
|
||||
try {
|
||||
updateLoadingState(id, "Stopping server...");
|
||||
const newConfig = await pauseMcpServer(id);
|
||||
setConfig(newConfig);
|
||||
showToast("Server stopped successfully");
|
||||
} catch (error) {
|
||||
showToast("Failed to stop server");
|
||||
console.error(error);
|
||||
} finally {
|
||||
updateLoadingState(id, null);
|
||||
}
|
||||
};
|
||||
|
||||
// Restart server
|
||||
const restartServer = async (id: string) => {
|
||||
try {
|
||||
updateLoadingState(id, "Starting server...");
|
||||
await resumeMcpServer(id);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to start server, please check logs",
|
||||
);
|
||||
console.error(error);
|
||||
} finally {
|
||||
updateLoadingState(id, null);
|
||||
}
|
||||
};
|
||||
|
||||
// Restart all clients
|
||||
const handleRestartAll = async () => {
|
||||
try {
|
||||
updateLoadingState("all", "Restarting all servers...");
|
||||
const newConfig = await restartAllClients();
|
||||
setConfig(newConfig);
|
||||
showToast("Restarting all clients");
|
||||
} catch (error) {
|
||||
showToast("Failed to restart clients");
|
||||
console.error(error);
|
||||
} finally {
|
||||
updateLoadingState("all", null);
|
||||
}
|
||||
};
|
||||
|
||||
// Render configuration form
|
||||
const renderConfigForm = () => {
|
||||
const preset = presetServers.find((s) => s.id === editingServerId);
|
||||
if (!preset?.configSchema) return null;
|
||||
|
||||
return Object.entries(preset.configSchema.properties).map(
|
||||
([key, prop]: [string, ConfigProperty]) => {
|
||||
if (prop.type === "array") {
|
||||
const currentValue = userConfig[key as keyof typeof userConfig] || [];
|
||||
const itemLabel = (prop as any).itemLabel || key;
|
||||
const addButtonText =
|
||||
(prop as any).addButtonText || `Add ${itemLabel}`;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
title={key}
|
||||
subTitle={prop.description}
|
||||
vertical
|
||||
>
|
||||
<div className={styles["path-list"]}>
|
||||
{(currentValue as string[]).map(
|
||||
(value: string, index: number) => (
|
||||
<div key={index} className={styles["path-item"]}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={`${itemLabel} ${index + 1}`}
|
||||
onChange={(e) => {
|
||||
const newValue = [...currentValue] as string[];
|
||||
newValue[index] = e.target.value;
|
||||
setUserConfig({ ...userConfig, [key]: newValue });
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
className={styles["delete-button"]}
|
||||
onClick={() => {
|
||||
const newValue = [...currentValue] as string[];
|
||||
newValue.splice(index, 1);
|
||||
setUserConfig({ ...userConfig, [key]: newValue });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text={addButtonText}
|
||||
className={styles["add-button"]}
|
||||
bordered
|
||||
onClick={() => {
|
||||
const newValue = [...currentValue, ""] as string[];
|
||||
setUserConfig({ ...userConfig, [key]: newValue });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ListItem>
|
||||
);
|
||||
} else if (prop.type === "string") {
|
||||
const currentValue = userConfig[key as keyof typeof userConfig] || "";
|
||||
return (
|
||||
<ListItem key={key} title={key} subTitle={prop.description}>
|
||||
<input
|
||||
aria-label={key}
|
||||
type="text"
|
||||
value={currentValue}
|
||||
placeholder={`Enter ${key}`}
|
||||
onChange={(e) => {
|
||||
setUserConfig({ ...userConfig, [key]: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const checkServerStatus = (clientId: string) => {
|
||||
return clientStatuses[clientId] || { status: "undefined", errorMsg: null };
|
||||
};
|
||||
|
||||
const getServerStatusDisplay = (clientId: string) => {
|
||||
const status = checkServerStatus(clientId);
|
||||
|
||||
const statusMap = {
|
||||
undefined: null, // 未配置/未找到不显示
|
||||
// 添加初始化状态
|
||||
initializing: (
|
||||
<span className={clsx(styles["server-status"], styles["initializing"])}>
|
||||
Initializing
|
||||
</span>
|
||||
),
|
||||
paused: (
|
||||
<span className={clsx(styles["server-status"], styles["stopped"])}>
|
||||
Stopped
|
||||
</span>
|
||||
),
|
||||
active: <span className={styles["server-status"]}>Running</span>,
|
||||
error: (
|
||||
<span className={clsx(styles["server-status"], styles["error"])}>
|
||||
Error
|
||||
<span className={styles["error-message"]}>: {status.errorMsg}</span>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
|
||||
return statusMap[status.status];
|
||||
};
|
||||
|
||||
// Get the type of operation status
|
||||
const getOperationStatusType = (message: string) => {
|
||||
if (message.toLowerCase().includes("stopping")) return "stopping";
|
||||
if (message.toLowerCase().includes("starting")) return "starting";
|
||||
if (message.toLowerCase().includes("error")) return "error";
|
||||
return "default";
|
||||
};
|
||||
|
||||
// 渲染服务器列表
|
||||
const renderServerList = () => {
|
||||
if (loadingPresets) {
|
||||
return (
|
||||
<div className={styles["loading-container"]}>
|
||||
<div className={styles["loading-text"]}>
|
||||
Loading preset server list...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(presetServers) || presetServers.length === 0) {
|
||||
return (
|
||||
<div className={styles["empty-container"]}>
|
||||
<div className={styles["empty-text"]}>No servers available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return presetServers
|
||||
.filter((server) => {
|
||||
if (searchText.length === 0) return true;
|
||||
const searchLower = searchText.toLowerCase();
|
||||
return (
|
||||
server.name.toLowerCase().includes(searchLower) ||
|
||||
server.description.toLowerCase().includes(searchLower) ||
|
||||
server.tags.some((tag) => tag.toLowerCase().includes(searchLower))
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aStatus = checkServerStatus(a.id).status;
|
||||
const bStatus = checkServerStatus(b.id).status;
|
||||
const aLoading = loadingStates[a.id];
|
||||
const bLoading = loadingStates[b.id];
|
||||
|
||||
// 定义状态优先级
|
||||
const statusPriority: Record<string, number> = {
|
||||
error: 0, // Highest priority for error status
|
||||
active: 1, // Second for active
|
||||
initializing: 2, // Initializing
|
||||
starting: 3, // Starting
|
||||
stopping: 4, // Stopping
|
||||
paused: 5, // Paused
|
||||
undefined: 6, // Lowest priority for undefined
|
||||
};
|
||||
|
||||
// Get actual status (including loading status)
|
||||
const getEffectiveStatus = (status: string, loading?: string) => {
|
||||
if (loading) {
|
||||
const operationType = getOperationStatusType(loading);
|
||||
return operationType === "default" ? status : operationType;
|
||||
}
|
||||
|
||||
if (status === "initializing" && !loading) {
|
||||
return "active";
|
||||
}
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
const aEffectiveStatus = getEffectiveStatus(aStatus, aLoading);
|
||||
const bEffectiveStatus = getEffectiveStatus(bStatus, bLoading);
|
||||
|
||||
// 首先按状态排序
|
||||
if (aEffectiveStatus !== bEffectiveStatus) {
|
||||
return (
|
||||
(statusPriority[aEffectiveStatus] ?? 6) -
|
||||
(statusPriority[bEffectiveStatus] ?? 6)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by name when statuses are the same
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((server) => (
|
||||
<div
|
||||
className={clsx(styles["mcp-market-item"], {
|
||||
[styles["loading"]]: loadingStates[server.id],
|
||||
})}
|
||||
key={server.id}
|
||||
>
|
||||
<div className={styles["mcp-market-header"]}>
|
||||
<div className={styles["mcp-market-title"]}>
|
||||
<div className={styles["mcp-market-name"]}>
|
||||
{server.name}
|
||||
{loadingStates[server.id] && (
|
||||
<span
|
||||
className={styles["operation-status"]}
|
||||
data-status={getOperationStatusType(
|
||||
loadingStates[server.id],
|
||||
)}
|
||||
>
|
||||
{loadingStates[server.id]}
|
||||
</span>
|
||||
)}
|
||||
{!loadingStates[server.id] && getServerStatusDisplay(server.id)}
|
||||
{server.repo && (
|
||||
<a
|
||||
href={server.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles["repo-link"]}
|
||||
title="Open repository"
|
||||
>
|
||||
<GithubIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles["tags-container"]}>
|
||||
{server.tags.map((tag, index) => (
|
||||
<span key={index} className={styles["tag"]}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles["mcp-market-info"], "one-line")}
|
||||
title={server.description}
|
||||
>
|
||||
{server.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["mcp-market-actions"]}>
|
||||
{isServerAdded(server.id) ? (
|
||||
<>
|
||||
{server.configurable && (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
text="Configure"
|
||||
onClick={() => setEditingServerId(server.id)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
{checkServerStatus(server.id).status === "paused" ? (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<PlayIcon />}
|
||||
text="Start"
|
||||
onClick={() => restartServer(server.id)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{/* <IconButton
|
||||
icon={<DeleteIcon />}
|
||||
text="Remove"
|
||||
onClick={() => removeServer(server.id)}
|
||||
disabled={isLoading}
|
||||
/> */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
text="Tools"
|
||||
onClick={async () => {
|
||||
setViewingServerId(server.id);
|
||||
await loadTools(server.id);
|
||||
}}
|
||||
disabled={
|
||||
isLoading ||
|
||||
checkServerStatus(server.id).status === "error"
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<StopIcon />}
|
||||
text="Stop"
|
||||
onClick={() => pauseServer(server.id)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<AddIcon />}
|
||||
text="Add"
|
||||
onClick={() => addServer(server)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mcp-market-page"]}>
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
MCP Market
|
||||
{loadingStates["all"] && (
|
||||
<span className={styles["loading-indicator"]}>
|
||||
{loadingStates["all"]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="window-header-sub-title">
|
||||
{Object.keys(config?.mcpServers ?? {}).length} servers configured
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<RestartIcon />}
|
||||
bordered
|
||||
onClick={handleRestartAll}
|
||||
text="Restart All"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
bordered
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["mcp-market-page-body"]}>
|
||||
<div className={styles["mcp-market-filter"]}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles["search-bar"]}
|
||||
placeholder={"Search MCP Server"}
|
||||
autoFocus
|
||||
onInput={(e) => setSearchText(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles["server-list"]}>{renderServerList()}</div>
|
||||
</div>
|
||||
|
||||
{/*编辑服务器配置*/}
|
||||
{editingServerId && (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={`Configure Server - ${editingServerId}`}
|
||||
onClose={() => !isLoading && setEditingServerId(undefined)}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="cancel"
|
||||
text="Cancel"
|
||||
onClick={() => setEditingServerId(undefined)}
|
||||
bordered
|
||||
disabled={isLoading}
|
||||
/>,
|
||||
<IconButton
|
||||
key="confirm"
|
||||
text="Save"
|
||||
type="primary"
|
||||
onClick={saveServerConfig}
|
||||
bordered
|
||||
disabled={isLoading}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<List>{renderConfigForm()}</List>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewingServerId && (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={`Server Details - ${viewingServerId}`}
|
||||
onClose={() => setViewingServerId(undefined)}
|
||||
actions={[
|
||||
<IconButton
|
||||
key="close"
|
||||
text="Close"
|
||||
onClick={() => setViewingServerId(undefined)}
|
||||
bordered
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className={styles["tools-list"]}>
|
||||
{isLoading ? (
|
||||
<div>Loading...</div>
|
||||
) : tools?.tools ? (
|
||||
tools.tools.map(
|
||||
(tool: ListToolsResponse["tools"], index: number) => (
|
||||
<div key={index} className={styles["tool-item"]}>
|
||||
<div className={styles["tool-name"]}>{tool.name}</div>
|
||||
<div className={styles["tool-description"]}>
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div>No tools available</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useMemo, useState, Fragment } from "react";
|
||||
import React, { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import styles from "./home.module.scss";
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
|
||||
@@ -28,8 +29,9 @@ import {
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { isIOS, useMobileScreen } from "../utils";
|
||||
import dynamic from "next/dynamic";
|
||||
import { showConfirm, Selector } from "./ui-lib";
|
||||
import { Selector, showConfirm } from "./ui-lib";
|
||||
import clsx from "clsx";
|
||||
import { isMcpEnabled } from "../mcp/actions";
|
||||
|
||||
const DISCOVERY = [
|
||||
{ name: Locale.Plugin.Name, path: Path.Plugins },
|
||||
@@ -133,6 +135,7 @@ export function useDragSideBar() {
|
||||
shouldNarrow,
|
||||
};
|
||||
}
|
||||
|
||||
export function SideBarContainer(props: {
|
||||
children: React.ReactNode;
|
||||
onDragStart: (e: MouseEvent) => void;
|
||||
@@ -228,6 +231,17 @@ export function SideBar(props: { className?: string }) {
|
||||
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
|
||||
@@ -255,6 +269,17 @@ export function SideBar(props: { className?: string }) {
|
||||
}}
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user