mcp-market.tsx 24 KB

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