| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745 |
- 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 { 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)}
- </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>
- );
- }
|