mcp-market.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
  1. import { IconButton } from "./button";
  2. import { ErrorBoundary } from "./error";
  3. import styles from "./mcp-market.module.scss";
  4. import EditIcon from "../icons/edit.svg";
  5. import AddIcon from "../icons/add.svg";
  6. import CloseIcon from "../icons/close.svg";
  7. import DeleteIcon from "../icons/delete.svg";
  8. import RestartIcon from "../icons/reload.svg";
  9. import EyeIcon from "../icons/eye.svg";
  10. import { List, ListItem, Modal, showToast } from "./ui-lib";
  11. import { useNavigate } from "react-router-dom";
  12. import { useEffect, useState } from "react";
  13. import {
  14. addMcpServer,
  15. getClientsStatus,
  16. getClientTools,
  17. getMcpConfigFromFile,
  18. isMcpEnabled,
  19. pauseMcpServer,
  20. restartAllClients,
  21. resumeMcpServer,
  22. } from "../mcp/actions";
  23. import {
  24. ListToolsResponse,
  25. McpConfigData,
  26. PresetServer,
  27. ServerConfig,
  28. ServerStatusResponse,
  29. } from "../mcp/types";
  30. import clsx from "clsx";
  31. import PlayIcon from "../icons/play.svg";
  32. import StopIcon from "../icons/pause.svg";
  33. import { Path } from "../constant";
  34. interface ConfigProperty {
  35. type: string;
  36. description?: string;
  37. required?: boolean;
  38. minItems?: number;
  39. }
  40. export function McpMarketPage() {
  41. const navigate = useNavigate();
  42. const [mcpEnabled, setMcpEnabled] = useState(false);
  43. const [searchText, setSearchText] = useState("");
  44. const [userConfig, setUserConfig] = useState<Record<string, any>>({});
  45. const [editingServerId, setEditingServerId] = useState<string | undefined>();
  46. const [tools, setTools] = useState<ListToolsResponse["tools"] | null>(null);
  47. const [viewingServerId, setViewingServerId] = useState<string | undefined>();
  48. const [isLoading, setIsLoading] = useState(false);
  49. const [config, setConfig] = useState<McpConfigData>();
  50. const [clientStatuses, setClientStatuses] = useState<
  51. Record<string, ServerStatusResponse>
  52. >({});
  53. const [loadingPresets, setLoadingPresets] = useState(true);
  54. const [presetServers, setPresetServers] = useState<PresetServer[]>([]);
  55. const [loadingStates, setLoadingStates] = useState<Record<string, string>>(
  56. {},
  57. );
  58. // 检查 MCP 是否启用
  59. useEffect(() => {
  60. const checkMcpStatus = async () => {
  61. const enabled = await isMcpEnabled();
  62. setMcpEnabled(enabled);
  63. if (!enabled) {
  64. navigate(Path.Home);
  65. }
  66. };
  67. checkMcpStatus();
  68. }, [navigate]);
  69. // 添加状态轮询
  70. useEffect(() => {
  71. if (!mcpEnabled || !config) return;
  72. const updateStatuses = async () => {
  73. const statuses = await getClientsStatus();
  74. setClientStatuses(statuses);
  75. };
  76. // 立即执行一次
  77. updateStatuses();
  78. // 每 1000ms 轮询一次
  79. const timer = setInterval(updateStatuses, 1000);
  80. return () => clearInterval(timer);
  81. }, [mcpEnabled, config]);
  82. // 加载预设服务器
  83. useEffect(() => {
  84. const loadPresetServers = async () => {
  85. if (!mcpEnabled) return;
  86. try {
  87. setLoadingPresets(true);
  88. const response = await fetch("https://nextchat.club/mcp/list");
  89. if (!response.ok) {
  90. throw new Error("Failed to load preset servers");
  91. }
  92. const data = await response.json();
  93. setPresetServers(data?.data ?? []);
  94. } catch (error) {
  95. console.error("Failed to load preset servers:", error);
  96. showToast("Failed to load preset servers");
  97. } finally {
  98. setLoadingPresets(false);
  99. }
  100. };
  101. loadPresetServers();
  102. }, [mcpEnabled]);
  103. // 加载初始状态
  104. useEffect(() => {
  105. const loadInitialState = async () => {
  106. if (!mcpEnabled) return;
  107. try {
  108. setIsLoading(true);
  109. const config = await getMcpConfigFromFile();
  110. setConfig(config);
  111. // 获取所有客户端的状态
  112. const statuses = await getClientsStatus();
  113. setClientStatuses(statuses);
  114. } catch (error) {
  115. console.error("Failed to load initial state:", error);
  116. showToast("Failed to load initial state");
  117. } finally {
  118. setIsLoading(false);
  119. }
  120. };
  121. loadInitialState();
  122. }, [mcpEnabled]);
  123. // 加载当前编辑服务器的配置
  124. useEffect(() => {
  125. if (!editingServerId || !config) return;
  126. const currentConfig = config.mcpServers[editingServerId];
  127. if (currentConfig) {
  128. // 从当前配置中提取用户配置
  129. const preset = presetServers.find((s) => s.id === editingServerId);
  130. if (preset?.configSchema) {
  131. const userConfig: Record<string, any> = {};
  132. Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
  133. if (mapping.type === "spread") {
  134. // For spread types, extract the array from args.
  135. const startPos = mapping.position ?? 0;
  136. userConfig[key] = currentConfig.args.slice(startPos);
  137. } else if (mapping.type === "single") {
  138. // For single types, get a single value
  139. userConfig[key] = currentConfig.args[mapping.position ?? 0];
  140. } else if (
  141. mapping.type === "env" &&
  142. mapping.key &&
  143. currentConfig.env
  144. ) {
  145. // For env types, get values from environment variables
  146. userConfig[key] = currentConfig.env[mapping.key];
  147. }
  148. });
  149. setUserConfig(userConfig);
  150. }
  151. } else {
  152. setUserConfig({});
  153. }
  154. }, [editingServerId, config, presetServers]);
  155. if (!mcpEnabled) {
  156. return null;
  157. }
  158. // 检查服务器是否已添加
  159. const isServerAdded = (id: string) => {
  160. return id in (config?.mcpServers ?? {});
  161. };
  162. // 保存服务器配置
  163. const saveServerConfig = async () => {
  164. const preset = presetServers.find((s) => s.id === editingServerId);
  165. if (!preset || !preset.configSchema || !editingServerId) return;
  166. const savingServerId = editingServerId;
  167. setEditingServerId(undefined);
  168. try {
  169. updateLoadingState(savingServerId, "Updating configuration...");
  170. // 构建服务器配置
  171. const args = [...preset.baseArgs];
  172. const env: Record<string, string> = {};
  173. Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
  174. const value = userConfig[key];
  175. if (mapping.type === "spread" && Array.isArray(value)) {
  176. const pos = mapping.position ?? 0;
  177. args.splice(pos, 0, ...value);
  178. } else if (
  179. mapping.type === "single" &&
  180. mapping.position !== undefined
  181. ) {
  182. args[mapping.position] = value;
  183. } else if (
  184. mapping.type === "env" &&
  185. mapping.key &&
  186. typeof value === "string"
  187. ) {
  188. env[mapping.key] = value;
  189. }
  190. });
  191. const serverConfig: ServerConfig = {
  192. command: preset.command,
  193. args,
  194. ...(Object.keys(env).length > 0 ? { env } : {}),
  195. };
  196. const newConfig = await addMcpServer(savingServerId, serverConfig);
  197. setConfig(newConfig);
  198. showToast("Server configuration updated successfully");
  199. } catch (error) {
  200. showToast(
  201. error instanceof Error ? error.message : "Failed to save configuration",
  202. );
  203. } finally {
  204. updateLoadingState(savingServerId, null);
  205. }
  206. };
  207. // 获取服务器支持的 Tools
  208. const loadTools = async (id: string) => {
  209. try {
  210. const result = await getClientTools(id);
  211. if (result) {
  212. setTools(result);
  213. } else {
  214. throw new Error("Failed to load tools");
  215. }
  216. } catch (error) {
  217. showToast("Failed to load tools");
  218. console.error(error);
  219. setTools(null);
  220. }
  221. };
  222. // 更新加载状态的辅助函数
  223. const updateLoadingState = (id: string, message: string | null) => {
  224. setLoadingStates((prev) => {
  225. if (message === null) {
  226. const { [id]: _, ...rest } = prev;
  227. return rest;
  228. }
  229. return { ...prev, [id]: message };
  230. });
  231. };
  232. // 修改添加服务器函数
  233. const addServer = async (preset: PresetServer) => {
  234. if (!preset.configurable) {
  235. try {
  236. const serverId = preset.id;
  237. updateLoadingState(serverId, "Creating MCP client...");
  238. const serverConfig: ServerConfig = {
  239. command: preset.command,
  240. args: [...preset.baseArgs],
  241. };
  242. const newConfig = await addMcpServer(preset.id, serverConfig);
  243. setConfig(newConfig);
  244. // 更新状态
  245. const statuses = await getClientsStatus();
  246. setClientStatuses(statuses);
  247. } finally {
  248. updateLoadingState(preset.id, null);
  249. }
  250. } else {
  251. // 如果需要配置,打开配置对话框
  252. setEditingServerId(preset.id);
  253. setUserConfig({});
  254. }
  255. };
  256. // 修改暂停服务器函数
  257. const pauseServer = async (id: string) => {
  258. try {
  259. updateLoadingState(id, "Stopping server...");
  260. const newConfig = await pauseMcpServer(id);
  261. setConfig(newConfig);
  262. showToast("Server stopped successfully");
  263. } catch (error) {
  264. showToast("Failed to stop server");
  265. console.error(error);
  266. } finally {
  267. updateLoadingState(id, null);
  268. }
  269. };
  270. // Restart server
  271. const restartServer = async (id: string) => {
  272. try {
  273. updateLoadingState(id, "Starting server...");
  274. await resumeMcpServer(id);
  275. } catch (error) {
  276. showToast(
  277. error instanceof Error
  278. ? error.message
  279. : "Failed to start server, please check logs",
  280. );
  281. console.error(error);
  282. } finally {
  283. updateLoadingState(id, null);
  284. }
  285. };
  286. // Restart all clients
  287. const handleRestartAll = async () => {
  288. try {
  289. updateLoadingState("all", "Restarting all servers...");
  290. const newConfig = await restartAllClients();
  291. setConfig(newConfig);
  292. showToast("Restarting all clients");
  293. } catch (error) {
  294. showToast("Failed to restart clients");
  295. console.error(error);
  296. } finally {
  297. updateLoadingState("all", null);
  298. }
  299. };
  300. // Render configuration form
  301. const renderConfigForm = () => {
  302. const preset = presetServers.find((s) => s.id === editingServerId);
  303. if (!preset?.configSchema) return null;
  304. return Object.entries(preset.configSchema.properties).map(
  305. ([key, prop]: [string, ConfigProperty]) => {
  306. if (prop.type === "array") {
  307. const currentValue = userConfig[key as keyof typeof userConfig] || [];
  308. const itemLabel = (prop as any).itemLabel || key;
  309. const addButtonText =
  310. (prop as any).addButtonText || `Add ${itemLabel}`;
  311. return (
  312. <ListItem
  313. key={key}
  314. title={key}
  315. subTitle={prop.description}
  316. vertical
  317. >
  318. <div className={styles["path-list"]}>
  319. {(currentValue as string[]).map(
  320. (value: string, index: number) => (
  321. <div key={index} className={styles["path-item"]}>
  322. <input
  323. type="text"
  324. value={value}
  325. placeholder={`${itemLabel} ${index + 1}`}
  326. onChange={(e) => {
  327. const newValue = [...currentValue] as string[];
  328. newValue[index] = e.target.value;
  329. setUserConfig({ ...userConfig, [key]: newValue });
  330. }}
  331. />
  332. <IconButton
  333. icon={<DeleteIcon />}
  334. className={styles["delete-button"]}
  335. onClick={() => {
  336. const newValue = [...currentValue] as string[];
  337. newValue.splice(index, 1);
  338. setUserConfig({ ...userConfig, [key]: newValue });
  339. }}
  340. />
  341. </div>
  342. ),
  343. )}
  344. <IconButton
  345. icon={<AddIcon />}
  346. text={addButtonText}
  347. className={styles["add-button"]}
  348. bordered
  349. onClick={() => {
  350. const newValue = [...currentValue, ""] as string[];
  351. setUserConfig({ ...userConfig, [key]: newValue });
  352. }}
  353. />
  354. </div>
  355. </ListItem>
  356. );
  357. } else if (prop.type === "string") {
  358. const currentValue = userConfig[key as keyof typeof userConfig] || "";
  359. return (
  360. <ListItem key={key} title={key} subTitle={prop.description}>
  361. <input
  362. aria-label={key}
  363. type="text"
  364. value={currentValue}
  365. placeholder={`Enter ${key}`}
  366. onChange={(e) => {
  367. setUserConfig({ ...userConfig, [key]: e.target.value });
  368. }}
  369. />
  370. </ListItem>
  371. );
  372. }
  373. return null;
  374. },
  375. );
  376. };
  377. const checkServerStatus = (clientId: string) => {
  378. return clientStatuses[clientId] || { status: "undefined", errorMsg: null };
  379. };
  380. const getServerStatusDisplay = (clientId: string) => {
  381. const status = checkServerStatus(clientId);
  382. const statusMap = {
  383. undefined: null, // 未配置/未找到不显示
  384. // 添加初始化状态
  385. initializing: (
  386. <span className={clsx(styles["server-status"], styles["initializing"])}>
  387. Initializing
  388. </span>
  389. ),
  390. paused: (
  391. <span className={clsx(styles["server-status"], styles["stopped"])}>
  392. Stopped
  393. </span>
  394. ),
  395. active: <span className={styles["server-status"]}>Running</span>,
  396. error: (
  397. <span className={clsx(styles["server-status"], styles["error"])}>
  398. Error
  399. <span className={styles["error-message"]}>: {status.errorMsg}</span>
  400. </span>
  401. ),
  402. };
  403. return statusMap[status.status];
  404. };
  405. // Get the type of operation status
  406. const getOperationStatusType = (message: string) => {
  407. if (message.toLowerCase().includes("stopping")) return "stopping";
  408. if (message.toLowerCase().includes("starting")) return "starting";
  409. if (message.toLowerCase().includes("error")) return "error";
  410. return "default";
  411. };
  412. // 渲染服务器列表
  413. const renderServerList = () => {
  414. if (loadingPresets) {
  415. return (
  416. <div className={styles["loading-container"]}>
  417. <div className={styles["loading-text"]}>
  418. Loading preset server list...
  419. </div>
  420. </div>
  421. );
  422. }
  423. if (!Array.isArray(presetServers) || presetServers.length === 0) {
  424. return (
  425. <div className={styles["empty-container"]}>
  426. <div className={styles["empty-text"]}>No servers available</div>
  427. </div>
  428. );
  429. }
  430. return presetServers
  431. .filter((server) => {
  432. if (searchText.length === 0) return true;
  433. const searchLower = searchText.toLowerCase();
  434. return (
  435. server.name.toLowerCase().includes(searchLower) ||
  436. server.description.toLowerCase().includes(searchLower) ||
  437. server.tags.some((tag) => tag.toLowerCase().includes(searchLower))
  438. );
  439. })
  440. .sort((a, b) => {
  441. const aStatus = checkServerStatus(a.id).status;
  442. const bStatus = checkServerStatus(b.id).status;
  443. const aLoading = loadingStates[a.id];
  444. const bLoading = loadingStates[b.id];
  445. // 定义状态优先级
  446. const statusPriority: Record<string, number> = {
  447. error: 0, // Highest priority for error status
  448. active: 1, // Second for active
  449. initializing: 2, // Initializing
  450. starting: 3, // Starting
  451. stopping: 4, // Stopping
  452. paused: 5, // Paused
  453. undefined: 6, // Lowest priority for undefined
  454. };
  455. // Get actual status (including loading status)
  456. const getEffectiveStatus = (status: string, loading?: string) => {
  457. if (loading) {
  458. const operationType = getOperationStatusType(loading);
  459. return operationType === "default" ? status : operationType;
  460. }
  461. if (status === "initializing" && !loading) {
  462. return "active";
  463. }
  464. return status;
  465. };
  466. const aEffectiveStatus = getEffectiveStatus(aStatus, aLoading);
  467. const bEffectiveStatus = getEffectiveStatus(bStatus, bLoading);
  468. // 首先按状态排序
  469. if (aEffectiveStatus !== bEffectiveStatus) {
  470. return (
  471. (statusPriority[aEffectiveStatus] ?? 6) -
  472. (statusPriority[bEffectiveStatus] ?? 6)
  473. );
  474. }
  475. // Sort by name when statuses are the same
  476. return a.name.localeCompare(b.name);
  477. })
  478. .map((server) => (
  479. <div
  480. className={clsx(styles["mcp-market-item"], {
  481. [styles["loading"]]: loadingStates[server.id],
  482. })}
  483. key={server.id}
  484. >
  485. <div className={styles["mcp-market-header"]}>
  486. <div className={styles["mcp-market-title"]}>
  487. <div className={styles["mcp-market-name"]}>
  488. {server.name}
  489. {loadingStates[server.id] && (
  490. <span
  491. className={styles["operation-status"]}
  492. data-status={getOperationStatusType(
  493. loadingStates[server.id],
  494. )}
  495. >
  496. {loadingStates[server.id]}
  497. </span>
  498. )}
  499. {!loadingStates[server.id] && getServerStatusDisplay(server.id)}
  500. </div>
  501. <div className={styles["tags-container"]}>
  502. {server.tags.map((tag, index) => (
  503. <span key={index} className={styles["tag"]}>
  504. {tag}
  505. </span>
  506. ))}
  507. </div>
  508. <div
  509. className={clsx(styles["mcp-market-info"], "one-line")}
  510. title={server.description}
  511. >
  512. {server.description}
  513. </div>
  514. </div>
  515. <div className={styles["mcp-market-actions"]}>
  516. {isServerAdded(server.id) ? (
  517. <>
  518. {server.configurable && (
  519. <IconButton
  520. icon={<EditIcon />}
  521. text="Configure"
  522. onClick={() => setEditingServerId(server.id)}
  523. disabled={isLoading}
  524. />
  525. )}
  526. {checkServerStatus(server.id).status === "paused" ? (
  527. <>
  528. <IconButton
  529. icon={<PlayIcon />}
  530. text="Start"
  531. onClick={() => restartServer(server.id)}
  532. disabled={isLoading}
  533. />
  534. {/* <IconButton
  535. icon={<DeleteIcon />}
  536. text="Remove"
  537. onClick={() => removeServer(server.id)}
  538. disabled={isLoading}
  539. /> */}
  540. </>
  541. ) : (
  542. <>
  543. <IconButton
  544. icon={<EyeIcon />}
  545. text="Tools"
  546. onClick={async () => {
  547. setViewingServerId(server.id);
  548. await loadTools(server.id);
  549. }}
  550. disabled={
  551. isLoading ||
  552. checkServerStatus(server.id).status === "error"
  553. }
  554. />
  555. <IconButton
  556. icon={<StopIcon />}
  557. text="Stop"
  558. onClick={() => pauseServer(server.id)}
  559. disabled={isLoading}
  560. />
  561. </>
  562. )}
  563. </>
  564. ) : (
  565. <IconButton
  566. icon={<AddIcon />}
  567. text="Add"
  568. onClick={() => addServer(server)}
  569. disabled={isLoading}
  570. />
  571. )}
  572. </div>
  573. </div>
  574. </div>
  575. ));
  576. };
  577. return (
  578. <ErrorBoundary>
  579. <div className={styles["mcp-market-page"]}>
  580. <div className="window-header">
  581. <div className="window-header-title">
  582. <div className="window-header-main-title">
  583. MCP Market
  584. {loadingStates["all"] && (
  585. <span className={styles["loading-indicator"]}>
  586. {loadingStates["all"]}
  587. </span>
  588. )}
  589. </div>
  590. <div className="window-header-sub-title">
  591. {Object.keys(config?.mcpServers ?? {}).length} servers configured
  592. </div>
  593. </div>
  594. <div className="window-actions">
  595. <div className="window-action-button">
  596. <IconButton
  597. icon={<RestartIcon />}
  598. bordered
  599. onClick={handleRestartAll}
  600. text="Restart All"
  601. disabled={isLoading}
  602. />
  603. </div>
  604. <div className="window-action-button">
  605. <IconButton
  606. icon={<CloseIcon />}
  607. bordered
  608. onClick={() => navigate(-1)}
  609. disabled={isLoading}
  610. />
  611. </div>
  612. </div>
  613. </div>
  614. <div className={styles["mcp-market-page-body"]}>
  615. <div className={styles["mcp-market-filter"]}>
  616. <input
  617. type="text"
  618. className={styles["search-bar"]}
  619. placeholder={"Search MCP Server"}
  620. autoFocus
  621. onInput={(e) => setSearchText(e.currentTarget.value)}
  622. />
  623. </div>
  624. <div className={styles["server-list"]}>{renderServerList()}</div>
  625. </div>
  626. {/*编辑服务器配置*/}
  627. {editingServerId && (
  628. <div className="modal-mask">
  629. <Modal
  630. title={`Configure Server - ${editingServerId}`}
  631. onClose={() => !isLoading && setEditingServerId(undefined)}
  632. actions={[
  633. <IconButton
  634. key="cancel"
  635. text="Cancel"
  636. onClick={() => setEditingServerId(undefined)}
  637. bordered
  638. disabled={isLoading}
  639. />,
  640. <IconButton
  641. key="confirm"
  642. text="Save"
  643. type="primary"
  644. onClick={saveServerConfig}
  645. bordered
  646. disabled={isLoading}
  647. />,
  648. ]}
  649. >
  650. <List>{renderConfigForm()}</List>
  651. </Modal>
  652. </div>
  653. )}
  654. {viewingServerId && (
  655. <div className="modal-mask">
  656. <Modal
  657. title={`Server Details - ${viewingServerId}`}
  658. onClose={() => setViewingServerId(undefined)}
  659. actions={[
  660. <IconButton
  661. key="close"
  662. text="Close"
  663. onClick={() => setViewingServerId(undefined)}
  664. bordered
  665. />,
  666. ]}
  667. >
  668. <div className={styles["tools-list"]}>
  669. {isLoading ? (
  670. <div>Loading...</div>
  671. ) : tools?.tools ? (
  672. tools.tools.map(
  673. (tool: ListToolsResponse["tools"], index: number) => (
  674. <div key={index} className={styles["tool-item"]}>
  675. <div className={styles["tool-name"]}>{tool.name}</div>
  676. <div className={styles["tool-description"]}>
  677. {tool.description}
  678. </div>
  679. </div>
  680. ),
  681. )
  682. ) : (
  683. <div>No tools available</div>
  684. )}
  685. </div>
  686. </Modal>
  687. </div>
  688. )}
  689. </div>
  690. </ErrorBoundary>
  691. );
  692. }