feat: code by huntun

This commit is contained in:
Junyan Qin
2025-08-06 21:57:43 +08:00
parent ed869f7e81
commit c0d56aa905
17 changed files with 2205 additions and 4 deletions

View File

@@ -0,0 +1,251 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import styles from '@/app/home/plugins/plugins.module.css';
import { MCPMarketCardVO } from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO';
import MCPMarketCardComponent from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardComponent';
import { spaceClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export default function MCPMarketComponent({
askInstallServer,
}: {
askInstallServer: (githubURL: string) => void;
}) {
const { t } = useTranslation();
const [marketServerList, setMarketServerList] = useState<MCPMarketCardVO[]>(
[],
);
const [totalCount, setTotalCount] = useState(0);
const [nowPage, setNowPage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [loading, setLoading] = useState(false);
const [sortByValue, setSortByValue] = useState<string>('pushed_at');
const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');
const searchTimeout = useRef<NodeJS.Timeout | null>(null);
const pageSize = 12;
useEffect(() => {
initData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function initData() {
getServerList();
}
function onInputSearchKeyword(keyword: string) {
setSearchKeyword(keyword);
// 清除之前的定时器
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
// 设置新的定时器
searchTimeout.current = setTimeout(() => {
setNowPage(1);
getServerList(1, keyword);
}, 500);
}
function getServerList(
page: number = nowPage,
keyword: string = searchKeyword,
sortBy: string = sortByValue,
sortOrder: string = sortOrderValue,
) {
setLoading(true);
spaceClient
.getMCPMarketServers(page, pageSize, keyword, sortBy, sortOrder)
.then((res) => {
setMarketServerList(
res.servers.map((marketServer) => {
let repository = marketServer.repository;
if (repository.startsWith('https://github.com/')) {
repository = repository.replace('https://github.com/', '');
}
if (repository.startsWith('github.com/')) {
repository = repository.replace('github.com/', '');
}
const author = repository.split('/')[0];
const name = repository.split('/')[1];
return new MCPMarketCardVO({
author: author,
description: marketServer.description,
githubURL: `https://github.com/${repository}`,
name: name,
serverId: String(marketServer.ID),
starCount: marketServer.stars,
version:
'version' in marketServer
? String(marketServer.version)
: '1.0.0', // 如果没有提供版本则默认为1.0.0
});
}),
);
setTotalCount(res.total);
setLoading(false);
console.log('market servers:', res);
})
.catch((error) => {
console.error(t('mcp.getServerListError'), error);
setLoading(false);
});
}
function handlePageChange(page: number) {
setNowPage(page);
getServerList(page);
}
function handleSortChange(value: string) {
const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim());
setSortByValue(newSortBy);
setSortOrderValue(newSortOrder);
setNowPage(1);
getServerList(1, searchKeyword, newSortBy, newSortOrder);
}
return (
<div className={`${styles.marketComponentBody}`}>
<div className="flex items-center justify-start mb-2 mt-2 pl-[0.8rem] pr-[0.8rem]">
<Input
style={{
width: '300px',
}}
value={searchKeyword}
placeholder={t('mcp.searchServer')}
onChange={(e) => onInputSearchKeyword(e.target.value)}
/>
<Select
value={`${sortByValue},${sortOrderValue}`}
onValueChange={handleSortChange}
>
<SelectTrigger className="w-[180px] ml-2 cursor-pointer">
<SelectValue placeholder={t('mcp.sortBy')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="stars,DESC">{t('mcp.mostStars')}</SelectItem>
<SelectItem value="created_at,DESC">
{t('mcp.recentlyAdded')}
</SelectItem>
<SelectItem value="pushed_at,DESC">
{t('mcp.recentlyUpdated')}
</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center justify-end ml-2">
{totalCount > 0 && (
<Pagination>
<PaginationContent>
<PaginationItem className="cursor-pointer">
<PaginationPrevious
onClick={() => handlePageChange(nowPage - 1)}
className={
nowPage <= 1 ? 'pointer-events-none opacity-50' : ''
}
/>
</PaginationItem>
{/* 如果总页数大于5则只显示5页如果总页数小于5则显示所有页 */}
{(() => {
const totalPages = Math.ceil(totalCount / pageSize);
const maxVisiblePages = 5;
let startPage = Math.max(
1,
nowPage - Math.floor(maxVisiblePages / 2),
);
const endPage = Math.min(
totalPages,
startPage + maxVisiblePages - 1,
);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
return Array.from(
{ length: endPage - startPage + 1 },
(_, i) => {
const pageNum = startPage + i;
return (
<PaginationItem
key={pageNum}
className="cursor-pointer"
>
<PaginationLink
isActive={pageNum === nowPage}
onClick={() => handlePageChange(pageNum)}
>
<span className="text-black select-none">
{pageNum}
</span>
</PaginationLink>
</PaginationItem>
);
},
);
})()}
<PaginationItem className="cursor-pointer">
<PaginationNext
onClick={() => handlePageChange(nowPage + 1)}
className={
nowPage >= Math.ceil(totalCount / pageSize)
? 'pointer-events-none opacity-50'
: ''
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</div>
<div className={`${styles.pluginListContainer}`}>
{loading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
{t('mcp.loading')}
</div>
) : marketServerList.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
{t('mcp.noMatchingServers')}
</div>
) : (
marketServerList.map((vo, index) => (
<div key={`${vo.serverId}-${index}`}>
<MCPMarketCardComponent
cardVO={vo}
installServer={(githubURL) => {
askInstallServer(githubURL);
}}
/>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { MCPMarketCardVO } from '@/app/home/plugins/mcp-market/mcp-market-card/MCPMarketCardVO';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
export default function MCPMarketCardComponent({
cardVO,
installServer,
}: {
cardVO: MCPMarketCardVO;
installServer: (serverURL: string) => void;
}) {
const { t } = useTranslation();
function handleInstallClick(serverURL: string) {
installServer(serverURL);
}
return (
<div className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem]">
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
<svg
className="w-16 h-16 text-[#2288ee]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M13.5 2C13.5 2.82843 14.1716 3.5 15 3.5C15.8284 3.5 16.5 2.82843 16.5 2C16.5 1.17157 15.8284 0.5 15 0.5C14.1716 0.5 13.5 1.17157 13.5 2ZM8.5 8C8.5 8.82843 9.17157 9.5 10 9.5C10.8284 9.5 11.5 8.82843 11.5 8C11.5 7.17157 10.8284 6.5 10 6.5C9.17157 6.5 8.5 7.17157 8.5 8ZM1.5 14C1.5 14.8284 2.17157 15.5 3 15.5C3.82843 15.5 4.5 14.8284 4.5 14C4.5 13.1716 3.82843 12.5 3 12.5C2.17157 12.5 1.5 13.1716 1.5 14ZM19.5 14C19.5 14.8284 20.1716 15.5 21 15.5C21.8284 15.5 22.5 14.8284 22.5 14C22.5 13.1716 21.8284 12.5 21 12.5C20.1716 12.5 19.5 13.1716 19.5 14ZM8.5 20C8.5 20.8284 9.17157 21.5 10 21.5C10.8284 21.5 11.5 20.8284 11.5 20C11.5 19.1716 10.8284 19 10 19C9.17157 19 8.5 19.1716 8.5 20ZM2.5 8L6.5 8L6.5 10L2.5 10L2.5 8ZM13.5 8L17.5 8L17.5 10L13.5 10L13.5 8ZM8.5 2L8.5 6L10.5 6L10.5 2L8.5 2ZM8.5 14L8.5 18L10.5 18L10.5 14L8.5 14ZM2.5 14L6.5 14L6.5 16L2.5 16L2.5 14ZM13.5 14L17.5 14L17.5 16L13.5 16L13.5 14Z"></path>
</svg>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-col items-start justify-start">
<div className="text-[0.7rem] text-[#666]">
{cardVO.author} /{' '}
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
<div className="text-[1.2rem] text-black">{cardVO.name}</div>
</div>
</div>
<div className="text-[0.8rem] text-[#666] line-clamp-2">
{cardVO.description}
</div>
</div>
<div className="w-full flex flex-row items-start justify-between gap-[0.6rem]">
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
<svg
className="w-[1.2rem] h-[1.2rem] text-[#ffcd27]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z"></path>
</svg>
<div className="text-base text-[#ffcd27] font-medium">
{t('mcp.starCount', { count: cardVO.starCount })}
</div>
</div>
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
<svg
className="w-[1.4rem] h-[1.4rem] text-black cursor-pointer"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
onClick={() => window.open(cardVO.githubURL, '_blank')}
>
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
</svg>
<Button
variant="default"
size="sm"
onClick={() => {
handleInstallClick(cardVO.githubURL);
}}
className="cursor-pointer"
>
{t('mcp.install')}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
export interface IMCPMarketCardVO {
serverId: string;
author: string;
name: string;
description: string;
starCount: number;
githubURL: string;
version: string;
}
export class MCPMarketCardVO implements IMCPMarketCardVO {
serverId: string;
description: string;
name: string;
author: string;
githubURL: string;
starCount: number;
version: string;
constructor(prop: IMCPMarketCardVO) {
this.description = prop.description;
this.name = prop.name;
this.author = prop.author;
this.githubURL = prop.githubURL;
this.starCount = prop.starCount;
this.serverId = prop.serverId;
this.version = prop.version;
}
}

View File

@@ -0,0 +1,47 @@
import { MCPServer, MCPServerConfig } from '@/app/infra/entities/api';
export class MCPCardVO {
name: string;
mode: 'stdio' | 'sse';
enable: boolean;
status: 'connected' | 'disconnected' | 'error';
tools: number;
error?: string;
config: MCPServerConfig;
constructor(data: MCPServer) {
this.name = data.name;
this.mode = data.mode;
this.enable = data.enable;
this.status = data.status;
this.tools = data.tools.length;
this.error = data.error;
this.config = data.config;
}
getStatusColor(): string {
switch (this.status) {
case 'connected':
return 'text-green-600';
case 'disconnected':
return 'text-gray-500';
case 'error':
return 'text-red-600';
default:
return 'text-gray-500';
}
}
getStatusIcon(): string {
switch (this.status) {
case 'connected':
return 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z';
case 'disconnected':
return 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
case 'error':
return 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z';
default:
return 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z';
}
}
}

View File

@@ -0,0 +1,217 @@
'use client';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO';
import MCPCardComponent from '@/app/home/plugins/mcp/mcp-card/MCPCardComponent';
import MCPForm from '@/app/home/plugins/mcp/mcp-form/MCPForm';
import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
export interface MCPComponentRef {
refreshServerList: () => void;
createServer: () => void;
}
// eslint-disable-next-line react/display-name
const MCPComponent = forwardRef<MCPComponentRef>((props, ref) => {
const { t } = useTranslation();
const [serverList, setServerList] = useState<MCPCardVO[]>([]);
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [selectedServer, setSelectedServer] = useState<MCPCardVO | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState<boolean>(false);
const [serverToDelete, setServerToDelete] = useState<MCPCardVO | null>(null);
const [deleting, setDeleting] = useState<boolean>(false);
useEffect(() => {
initData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function initData() {
getServerList();
}
function getServerList() {
httpClient
.getMCPServers()
.then((value) => {
setServerList(value.servers.map((server) => new MCPCardVO(server)));
})
.catch((error) => {
toast.error(t('mcp.getServerListError') + error.message);
});
}
useImperativeHandle(ref, () => ({
refreshServerList: getServerList,
createServer: () => {
setSelectedServer(null);
setModalOpen(true);
},
}));
function handleServerClick(server: MCPCardVO) {
setSelectedServer(server);
setModalOpen(true);
}
function handleDeleteClick(server: MCPCardVO, e: React.MouseEvent) {
e.stopPropagation();
setServerToDelete(server);
setDeleteDialogOpen(true);
}
async function confirmDelete() {
if (!serverToDelete) return;
setDeleting(true);
try {
const response = await httpClient.deleteMCPServer(serverToDelete.name);
const taskId = response.task_id;
// 监控任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((taskResp) => {
if (taskResp.runtime.done) {
clearInterval(interval);
setDeleting(false);
setDeleteDialogOpen(false);
if (taskResp.runtime.exception) {
toast.error(t('mcp.deleteError') + taskResp.runtime.exception);
} else {
toast.success(t('mcp.deleteSuccess'));
getServerList();
}
}
});
}, 1000);
} catch (error: unknown) {
setDeleting(false);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t('mcp.deleteError') + errorMessage);
}
}
return (
<>
{serverList.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 h-[calc(100vh-16rem)] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M13.5 2C13.5 2.82843 14.1716 3.5 15 3.5C15.8284 3.5 16.5 2.82843 16.5 2C16.5 1.17157 15.8284 0.5 15 0.5C14.1716 0.5 13.5 1.17157 13.5 2ZM8.5 8C8.5 8.82843 9.17157 9.5 10 9.5C10.8284 9.5 11.5 8.82843 11.5 8C11.5 7.17157 10.8284 6.5 10 6.5C9.17157 6.5 8.5 7.17157 8.5 8ZM1.5 14C1.5 14.8284 2.17157 15.5 3 15.5C3.82843 15.5 4.5 14.8284 4.5 14C4.5 13.1716 3.82843 12.5 3 12.5C2.17157 12.5 1.5 13.1716 1.5 14ZM19.5 14C19.5 14.8284 20.1716 15.5 21 15.5C21.8284 15.5 22.5 14.8284 22.5 14C22.5 13.1716 21.8284 12.5 21 12.5C20.1716 12.5 19.5 13.1716 19.5 14ZM8.5 20C8.5 20.8284 9.17157 21.5 10 21.5C10.8284 21.5 11.5 20.8284 11.5 20C11.5 19.1716 10.8284 19 10 19C9.17157 19 8.5 19.1716 8.5 20ZM2.5 8L6.5 8L6.5 10L2.5 10L2.5 8ZM13.5 8L17.5 8L17.5 10L13.5 10L13.5 8ZM8.5 2L8.5 6L10.5 6L10.5 2L8.5 2ZM8.5 14L8.5 18L10.5 18L10.5 14L8.5 14ZM2.5 14L6.5 14L6.5 16L2.5 16L2.5 14ZM13.5 14L17.5 14L17.5 16L13.5 16L13.5 14Z"></path>
</svg>
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
</div>
) : (
<div className={`${styles.pluginListContainer}`}>
{serverList.map((vo, index) => {
return (
<div key={index} className="relative group">
<MCPCardComponent
cardVO={vo}
onCardClick={() => handleServerClick(vo)}
onRefresh={getServerList}
/>
{/* 删除按钮 */}
<button
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-red-500 hover:bg-red-600 text-white rounded-full p-1"
onClick={(e) => handleDeleteClick(vo, e)}
>
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
);
})}
</div>
)}
{/* 编辑配置对话框 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle>
{selectedServer ? t('mcp.editServer') : t('mcp.createServer')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<MCPForm
serverName={selectedServer?.name}
isEdit={!!selectedServer}
onFormSubmit={() => {
setModalOpen(false);
getServerList();
}}
onFormCancel={() => {
setModalOpen(false);
}}
/>
</div>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('mcp.deleteServer')}</AlertDialogTitle>
<AlertDialogDescription>
{t('mcp.confirmDeleteServer', { name: serverToDelete?.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={deleting}
className="bg-red-600 hover:bg-red-700"
>
{deleting ? t('plugins.deleting') : t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
});
export default MCPComponent;

View File

@@ -0,0 +1,196 @@
import { MCPCardVO } from '@/app/home/plugins/mcp/MCPCardVO';
import { useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
export default function MCPCardComponent({
cardVO,
onCardClick,
onRefresh,
}: {
cardVO: MCPCardVO;
onCardClick: () => void;
onRefresh: () => void;
}) {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(cardVO.enable);
const [switchEnable, setSwitchEnable] = useState(true);
const [testing, setTesting] = useState(false);
function handleEnable(e: React.MouseEvent) {
e.stopPropagation(); // 阻止事件冒泡
setSwitchEnable(false);
httpClient
.toggleMCPServer(cardVO.name, !enabled)
.then((resp) => {
const taskId = resp.task_id;
// 监控任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((taskResp) => {
if (taskResp.runtime.done) {
clearInterval(interval);
if (taskResp.runtime.exception) {
toast.error(t('mcp.modifyFailed') + taskResp.runtime.exception);
} else {
setEnabled(!enabled);
toast.success(t('mcp.saveSuccess'));
onRefresh();
}
setSwitchEnable(true);
}
});
}, 1000);
})
.catch((err) => {
toast.error(t('mcp.modifyFailed') + err.message);
setSwitchEnable(true);
});
}
function handleTest(e: React.MouseEvent) {
e.stopPropagation(); // 阻止事件冒泡
setTesting(true);
httpClient
.testMCPServer(cardVO.name)
.then((resp) => {
const taskId = resp.task_id;
// 监控任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((taskResp) => {
if (taskResp.runtime.done) {
clearInterval(interval);
if (taskResp.runtime.exception) {
toast.error(t('mcp.testFailed') + taskResp.runtime.exception);
} else {
toast.success(t('mcp.testSuccess'));
onRefresh();
}
setTesting(false);
}
});
}, 1000);
})
.catch((err) => {
toast.error(t('mcp.testFailed') + err.message);
setTesting(false);
});
}
return (
<div
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_2px_2px_0_rgba(0,0,0,0.2)] p-[1.2rem] cursor-pointer"
onClick={onCardClick}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
<svg
className="w-16 h-16 text-[#2288ee]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M13.5 2C13.5 2.82843 14.1716 3.5 15 3.5C15.8284 3.5 16.5 2.82843 16.5 2C16.5 1.17157 15.8284 0.5 15 0.5C14.1716 0.5 13.5 1.17157 13.5 2ZM8.5 8C8.5 8.82843 9.17157 9.5 10 9.5C10.8284 9.5 11.5 8.82843 11.5 8C11.5 7.17157 10.8284 6.5 10 6.5C9.17157 6.5 8.5 7.17157 8.5 8ZM1.5 14C1.5 14.8284 2.17157 15.5 3 15.5C3.82843 15.5 4.5 14.8284 4.5 14C4.5 13.1716 3.82843 12.5 3 12.5C2.17157 12.5 1.5 13.1716 1.5 14ZM19.5 14C19.5 14.8284 20.1716 15.5 21 15.5C21.8284 15.5 22.5 14.8284 22.5 14C22.5 13.1716 21.8284 12.5 21 12.5C20.1716 12.5 19.5 13.1716 19.5 14ZM8.5 20C8.5 20.8284 9.17157 21.5 10 21.5C10.8284 21.5 11.5 20.8284 11.5 20C11.5 19.1716 10.8284 19 10 19C9.17157 19 8.5 19.1716 8.5 20ZM2.5 8L6.5 8L6.5 10L2.5 10L2.5 8ZM13.5 8L17.5 8L17.5 10L13.5 10L13.5 8ZM8.5 2L8.5 6L10.5 6L10.5 2L8.5 2ZM8.5 14L8.5 18L10.5 18L10.5 14L8.5 14ZM2.5 14L6.5 14L6.5 16L2.5 16L2.5 14ZM13.5 14L17.5 14L17.5 16L13.5 16L13.5 14Z"></path>
</svg>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-row items-center justify-start gap-[0.4rem]">
<div className="text-[1.2rem] text-black">{cardVO.name}</div>
<Badge variant="outline" className="text-[0.7rem]">
{cardVO.mode.toUpperCase()}
</Badge>
</div>
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] mt-1">
<svg
className={`w-4 h-4 ${cardVO.getStatusColor()}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={cardVO.getStatusIcon()}
/>
</svg>
<div className={`text-[0.8rem] ${cardVO.getStatusColor()}`}>
{cardVO.status === 'connected' && t('mcp.statusConnected')}
{cardVO.status === 'disconnected' &&
t('mcp.statusDisconnected')}
{cardVO.status === 'error' && t('mcp.statusError')}
</div>
</div>
{cardVO.error && (
<div className="text-[0.7rem] text-red-500 line-clamp-2 mt-1">
{cardVO.error}
</div>
)}
</div>
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
<svg
className="w-[1.2rem] h-[1.2rem] text-black"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.32943 3.27158C6.56252 2.8332 7.9923 3.10749 8.97927 4.09446C10.1002 5.21537 10.3019 6.90741 9.5843 8.23385L20.293 18.9437L18.8788 20.3579L8.16982 9.64875C6.84325 10.3669 5.15069 10.1654 4.02952 9.04421C3.04227 8.05696 2.7681 6.62665 3.20701 5.39332L5.44373 7.63C6.02952 8.21578 6.97927 8.21578 7.56505 7.63C8.15084 7.04421 8.15084 6.09446 7.56505 5.50868L5.32943 3.27158ZM15.6968 5.15512L18.8788 3.38736L20.293 4.80157L18.5252 7.98355L16.7574 8.3371L14.6361 10.4584L13.2219 9.04421L15.3432 6.92289L15.6968 5.15512ZM8.97927 13.2868L10.3935 14.7011L5.09018 20.0044C4.69966 20.3949 4.06649 20.3949 3.67597 20.0044C3.31334 19.6417 3.28744 19.0699 3.59826 18.6774L3.67597 18.5902L8.97927 13.2868Z" />
</svg>
<div className="text-base text-black font-medium">
{t('mcp.toolCount', { count: cardVO.tools })}
</div>
</div>
</div>
</div>
<div className="flex flex-col items-center justify-between h-full">
<div className="flex items-center justify-center">
<Switch
className="cursor-pointer"
checked={enabled}
onClick={(e) => handleEnable(e)}
disabled={!switchEnable}
/>
</div>
<div className="flex items-center justify-center gap-[0.4rem]">
<Button
variant="ghost"
size="sm"
className="p-1 h-8 w-8"
onClick={(e) => handleTest(e)}
disabled={testing}
>
<svg
className={`w-4 h-4 text-gray-600 ${
testing ? 'animate-spin' : ''
}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,409 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { httpClient } from '@/app/infra/http/HttpClient';
import { MCPServerConfig } from '@/app/infra/entities/api';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { PlusIcon, TrashIcon } from 'lucide-react';
interface MCPFormProps {
serverName?: string;
isEdit?: boolean;
onFormSubmit: () => void;
onFormCancel: () => void;
}
export default function MCPForm({
serverName,
isEdit = false,
onFormSubmit,
onFormCancel,
}: MCPFormProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<MCPServerConfig>({
name: '',
mode: 'stdio',
enable: true,
command: '',
args: [],
env: {},
url: '',
headers: {},
timeout: 10,
});
useEffect(() => {
if (isEdit && serverName) {
loadServerConfig();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, serverName]);
async function loadServerConfig() {
try {
const response = await httpClient.getMCPServer(serverName!);
setFormData(response.server.config);
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t('mcp.getServerListError') + errorMessage);
}
}
function handleInputChange(field: keyof MCPServerConfig, value: unknown) {
setFormData((prev) => ({
...prev,
[field]: value,
}));
}
function addArrayItem(field: 'args', value: string = '') {
const currentArray = formData[field] as string[];
handleInputChange(field, [...currentArray, value]);
}
function updateArrayItem(field: 'args', index: number, value: string) {
const currentArray = formData[field] as string[];
const newArray = [...currentArray];
newArray[index] = value;
handleInputChange(field, newArray);
}
function removeArrayItem(field: 'args', index: number) {
const currentArray = formData[field] as string[];
const newArray = currentArray.filter((_, i) => i !== index);
handleInputChange(field, newArray);
}
function addObjectItem(
field: 'env' | 'headers',
key: string = '',
value: string = '',
) {
const currentObj = formData[field] as Record<string, string>;
handleInputChange(field, {
...currentObj,
[key]: value,
});
}
function updateObjectItem(
field: 'env' | 'headers',
oldKey: string,
newKey: string,
value: string,
) {
const currentObj = formData[field] as Record<string, string>;
const newObj = { ...currentObj };
if (oldKey !== newKey) {
delete newObj[oldKey];
}
newObj[newKey] = value;
handleInputChange(field, newObj);
}
function removeObjectItem(field: 'env' | 'headers', key: string) {
const currentObj = formData[field] as Record<string, string>;
const newObj = { ...currentObj };
delete newObj[key];
handleInputChange(field, newObj);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// 验证表单
if (!formData.name.trim()) {
toast.error(t('mcp.serverNameRequired'));
return;
}
if (formData.mode === 'stdio' && !formData.command?.trim()) {
toast.error(t('mcp.commandRequired'));
return;
}
if (formData.mode === 'sse' && !formData.url?.trim()) {
toast.error(t('mcp.urlRequired'));
return;
}
setLoading(true);
try {
let taskId: number;
if (isEdit) {
const response = await httpClient.updateMCPServer(
serverName!,
formData,
);
taskId = response.task_id;
} else {
const response = await httpClient.createMCPServer(formData);
taskId = response.task_id;
}
// 监控任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((taskResp) => {
if (taskResp.runtime.done) {
clearInterval(interval);
setLoading(false);
if (taskResp.runtime.exception) {
toast.error(
(isEdit ? t('mcp.saveError') : t('mcp.createError')) +
taskResp.runtime.exception,
);
} else {
toast.success(
isEdit ? t('mcp.saveSuccess') : t('mcp.createSuccess'),
);
onFormSubmit();
}
}
});
}, 1000);
} catch (error: unknown) {
setLoading(false);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
(isEdit ? t('mcp.saveError') : t('mcp.createError')) + errorMessage,
);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* 基础配置 */}
<div className="space-y-4">
<div>
<Label htmlFor="name">{t('mcp.serverName')}</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
disabled={isEdit}
placeholder={t('mcp.serverName')}
/>
</div>
<div>
<Label htmlFor="enable">{t('common.enable')}</Label>
<div className="flex items-center space-x-2 mt-2">
<Switch
id="enable"
checked={formData.enable}
onCheckedChange={(checked) =>
handleInputChange('enable', checked)
}
/>
</div>
</div>
<div>
<Label>{t('mcp.serverMode')}</Label>
<Tabs
value={formData.mode}
onValueChange={(value) =>
handleInputChange('mode', value as 'stdio' | 'sse')
}
className="mt-2"
>
<TabsList>
<TabsTrigger value="stdio">{t('mcp.stdio')}</TabsTrigger>
<TabsTrigger value="sse">{t('mcp.sse')}</TabsTrigger>
</TabsList>
<TabsContent value="stdio" className="space-y-4 mt-4">
<div>
<Label htmlFor="command">{t('mcp.command')}</Label>
<Input
id="command"
value={formData.command || ''}
onChange={(e) => handleInputChange('command', e.target.value)}
placeholder="python -m your_mcp_server"
/>
</div>
<div>
<Label>{t('mcp.args')}</Label>
<div className="space-y-2 mt-2">
{(formData.args || []).map((arg, index) => (
<div key={index} className="flex items-center space-x-2">
<Input
value={arg}
onChange={(e) =>
updateArrayItem('args', index, e.target.value)
}
placeholder="参数"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeArrayItem('args', index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addArrayItem('args')}
>
<PlusIcon className="h-4 w-4 mr-2" />
{t('mcp.addArgument')}
</Button>
</div>
</div>
<div>
<Label>{t('mcp.env')}</Label>
<div className="space-y-2 mt-2">
{Object.entries(formData.env || {}).map(([key, value]) => (
<div key={key} className="flex items-center space-x-2">
<Input
value={key}
onChange={(e) =>
updateObjectItem('env', key, e.target.value, value)
}
placeholder={t('mcp.keyName')}
className="flex-1"
/>
<Input
value={value}
onChange={(e) =>
updateObjectItem('env', key, key, e.target.value)
}
placeholder={t('mcp.value')}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeObjectItem('env', key)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addObjectItem('env')}
>
<PlusIcon className="h-4 w-4 mr-2" />
{t('mcp.addEnvVar')}
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="sse" className="space-y-4 mt-4">
<div>
<Label htmlFor="url">{t('mcp.url')}</Label>
<Input
id="url"
value={formData.url || ''}
onChange={(e) => handleInputChange('url', e.target.value)}
placeholder="http://localhost:3000/sse"
/>
</div>
<div>
<Label htmlFor="timeout">{t('mcp.timeout')}</Label>
<Input
id="timeout"
type="number"
value={formData.timeout || 10}
onChange={(e) =>
handleInputChange('timeout', parseInt(e.target.value) || 10)
}
placeholder="10"
/>
</div>
<div>
<Label>{t('mcp.headers')}</Label>
<div className="space-y-2 mt-2">
{Object.entries(formData.headers || {}).map(
([key, value]) => (
<div key={key} className="flex items-center space-x-2">
<Input
value={key}
onChange={(e) =>
updateObjectItem(
'headers',
key,
e.target.value,
value,
)
}
placeholder={t('mcp.keyName')}
className="flex-1"
/>
<Input
value={value}
onChange={(e) =>
updateObjectItem(
'headers',
key,
key,
e.target.value,
)
}
placeholder={t('mcp.value')}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeObjectItem('headers', key)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
),
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addObjectItem('headers')}
>
<PlusIcon className="h-4 w-4 mr-2" />
{t('mcp.addHeader')}
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={loading}>
{loading ? t('common.saving') : t('common.save')}
</Button>
</div>
</form>
);
}

View File

@@ -4,6 +4,11 @@ import PluginInstalledComponent, {
} from '@/app/home/plugins/plugin-installed/PluginInstalledComponent';
import MarketPage from '@/app/home/plugins/plugin-market/PluginMarketComponent';
// import PluginSortDialog from '@/app/home/plugins/plugin-sort/PluginSortDialog';
import PluginMarketComponent from '@/app/home/plugins/plugin-market/PluginMarketComponent';
import MCPComponent, {
MCPComponentRef,
} from '@/app/home/plugins/mcp/MCPComponent';
import MCPMarketComponent from '@/app/home/plugins/mcp-market/MCPMarketComponent';
import styles from './plugins.module.css';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
@@ -46,20 +51,27 @@ enum PluginInstallStatus {
export default function PluginConfigPage() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('installed');
const [modalOpen, setModalOpen] = useState(false);
// const [sortModalOpen, setSortModalOpen] = useState(false);
const [activeTab, setActiveTab] = useState('installed');
const [installSource, setInstallSource] = useState<string>('local');
const [installInfo, setInstallInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
const [sortModalOpen, setSortModalOpen] = useState(false);
// const [mcpModalOpen, setMcpModalOpen] = useState(false);
const [mcpMarketInstallModalOpen, setMcpMarketInstallModalOpen] =
useState(false);
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.WAIT_INPUT);
const [mcpInstallStatus, setMcpInstallStatus] = useState<PluginInstallStatus>(
PluginInstallStatus.WAIT_INPUT,
);
const [installError, setInstallError] = useState<string | null>(null);
const [mcpInstallError, setMcpInstallError] = useState<string | null>(null);
const [githubURL, setGithubURL] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -106,11 +118,17 @@ export default function PluginConfigPage() {
});
}, 1000);
}
const [mcpGithubURL, setMcpGithubURL] = useState('');
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
const mcpComponentRef = useRef<MCPComponentRef>(null);
function handleModalConfirm() {
installPlugin(installSource, installInfo as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
}
function handleMcpModalConfirm() {
installMcpServer(mcpGithubURL);
}
function installPlugin(
installSource: string,
installInfo: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -291,6 +309,46 @@ export default function PluginConfigPage() {
return renderPluginConnectionErrorState();
}
function installMcpServer(url: string) {
setMcpInstallStatus(PluginInstallStatus.INSTALLING);
httpClient
.installMCPServerFromGithub(url)
.then((resp) => {
const taskId = resp.task_id;
let alreadySuccess = false;
console.log('taskId:', taskId);
// 每秒拉取一次任务状态
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((resp) => {
console.log('task status:', resp);
if (resp.runtime.done) {
clearInterval(interval);
if (resp.runtime.exception) {
setMcpInstallError(resp.runtime.exception);
setMcpInstallStatus(PluginInstallStatus.ERROR);
} else {
// success
if (!alreadySuccess) {
toast.success(t('mcp.installSuccess'));
alreadySuccess = true;
}
setMcpGithubURL('');
setMcpMarketInstallModalOpen(false);
mcpComponentRef.current?.refreshServerList();
}
}
});
}, 1000);
})
.catch((err) => {
console.log('error when install mcp server:', err);
setMcpInstallError(err.message);
setMcpInstallStatus(PluginInstallStatus.ERROR);
});
}
return (
<div
className={`${styles.pageContainer} ${isDragOver ? 'bg-blue-50' : ''}`}
@@ -316,6 +374,9 @@ export default function PluginConfigPage() {
{t('plugins.marketplace')}
</TabsTrigger>
)}
<TabsTrigger value="mcp" className="px-6 py-4 cursor-pointer">
MCP
</TabsTrigger>
</TabsList>
<div className="flex flex-row justify-end items-center">
@@ -372,6 +433,19 @@ export default function PluginConfigPage() {
}}
/>
</TabsContent>
<TabsContent value="mcp">
<MCPComponent ref={mcpComponentRef} />
</TabsContent>
<TabsContent value="mcp-market">
<MCPMarketComponent
askInstallServer={(githubURL) => {
setMcpGithubURL(githubURL);
setMcpMarketInstallModalOpen(true);
setMcpInstallStatus(PluginInstallStatus.WAIT_INPUT);
setMcpInstallError(null);
}}
/>
</TabsContent>
</Tabs>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
@@ -456,6 +530,66 @@ export default function PluginConfigPage() {
pluginInstalledRef.current?.refreshPluginList();
}}
/> */}
{/* MCP Server 安装对话框 */}
{/* <Dialog
open={mcpMarketInstallModalOpen}
onOpenChange={setMcpMarketInstallModalOpen}
>
<DialogContent className="w-[500px] p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<GithubIcon className="size-6" />
<span>{t('mcp.installFromGithub')}</span>
</DialogTitle>
</DialogHeader>
{mcpInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<div className="mt-4">
<p className="mb-2">{t('mcp.onlySupportGithub')}</p>
<Input
placeholder={t('mcp.enterGithubLink')}
value={mcpGithubURL}
onChange={(e) => setMcpGithubURL(e.target.value)}
className="mb-4"
/>
</div>
)}
{mcpInstallStatus === PluginInstallStatus.INSTALLING && (
<div className="mt-4">
<p className="mb-2">{t('mcp.installing')}</p>
</div>
)}
{mcpInstallStatus === PluginInstallStatus.ERROR && (
<div className="mt-4">
<p className="mb-2">{t('mcp.installFailed')}</p>
<p className="mb-2 text-red-500">{mcpInstallError}</p>
</div>
)}
<DialogFooter>
{mcpInstallStatus === PluginInstallStatus.WAIT_INPUT && (
<>
<Button
variant="outline"
onClick={() => setMcpMarketInstallModalOpen(false)}
>
{t('common.cancel')}
</Button>
<Button onClick={handleMcpModalConfirm}>
{t('common.confirm')}
</Button>
</>
)}
{mcpInstallStatus === PluginInstallStatus.ERROR && (
<Button
variant="default"
onClick={() => setMcpMarketInstallModalOpen(false)}
>
{t('common.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog> */}
</div>
);
}

View File

@@ -308,3 +308,66 @@ export interface RetrieveResult {
export interface ApiRespKnowledgeBaseRetrieve {
results: RetrieveResult[];
}
// MCP
export interface ApiRespMCPServers {
servers: MCPServer[];
}
export interface ApiRespMCPServer {
server: MCPServer;
}
export interface MCPServer {
name: string;
mode: 'stdio' | 'sse';
enable: boolean;
config: MCPServerConfig;
status: 'connected' | 'disconnected' | 'error';
tools: MCPTool[];
error?: string;
}
export interface MCPServerConfig {
name: string;
mode: 'stdio' | 'sse';
enable: boolean;
// stdio mode
command?: string;
args?: string[];
env?: Record<string, string>;
// sse mode
url?: string;
headers?: Record<string, string>;
timeout?: number;
}
export interface MCPTool {
name: string;
description: string;
parameters: object;
}
// MCP Market
export interface MCPMarketResponse {
servers: MCPMarketServer[];
total: number;
}
export interface MCPMarketServer {
ID: number;
CreatedAt: string; // ISO 8601 格式日期
UpdatedAt: string;
DeletedAt: string | null;
name: string;
author: string;
description: string;
repository: string; // GitHub 仓库路径
artifacts_path: string;
stars: number;
downloads: number;
status: 'initialized' | 'mounted';
synced_at: string;
pushed_at: string; // 最后一次代码推送时间
version?: string;
}

View File

@@ -33,6 +33,9 @@ import {
ApiRespProviderEmbeddingModel,
EmbeddingModel,
ApiRespPluginSystemStatus,
ApiRespMCPServers,
ApiRespMCPServer,
MCPServerConfig,
} from '@/app/infra/entities/api';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -488,6 +491,67 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/plugins/${author}/${name}/upgrade`);
}
// ============ MCP API ============
public getMCPServers(): Promise<ApiRespMCPServers> {
return this.get('/api/v1/mcp/servers');
}
public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {
return this.get(`/api/v1/mcp/servers/${serverName}`);
}
public createMCPServer(
server: MCPServerConfig,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/mcp/servers', server);
}
public updateMCPServer(
serverName: string,
server: Partial<MCPServerConfig>,
): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}`, server);
}
public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
return this.delete(`/api/v1/mcp/servers/${serverName}`);
}
public toggleMCPServer(
serverName: string,
target_enabled: boolean,
): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}/toggle`, {
target_enabled,
});
}
public testMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
return this.post(`/api/v1/mcp/servers/${serverName}/test`);
}
// public getMCPMarketServers(
// page: number,
// page_size: number,
// query: string,
// sort_by: string = 'stars',
// sort_order: string = 'DESC',
// ): Promise<MCPMarketResponse> {
// return this.post(`/api/v1/market/mcp`, {
// page,
// page_size,
// query,
// sort_by,
// sort_order,
// });
// }
public installMCPServerFromGithub(
source: string,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/mcp/install/github', { source });
}
// ============ System API ============
public getSystemInfo(): Promise<ApiRespSystemInfo> {
return this.get('/api/v1/system/info');

View File

@@ -0,0 +1,141 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -270,6 +270,70 @@ const zhHans = {
markAsReadSuccess: '已标记为已读',
markAsReadFailed: '标记为已读失败',
},
mcp: {
title: 'MCP管理',
description: '管理Model Context Protocol (MCP) 服务器扩展AI能力',
createServer: '创建MCP服务器',
editServer: '编辑MCP服务器',
deleteServer: '删除MCP服务器',
getServerListError: '获取MCP服务器列表失败',
serverName: '服务器名称',
serverMode: '连接模式',
stdio: 'Stdio模式',
sse: 'SSE模式',
serverConfig: 'MCP服务器配置',
noServerInstalled: '暂未配置任何MCP服务器',
serverNameRequired: '服务器名称不能为空',
commandRequired: '命令不能为空',
urlRequired: 'URL不能为空',
command: '命令',
args: '参数',
env: '环境变量',
url: 'URL地址',
headers: '请求头',
timeout: '超时时间',
addArgument: '添加参数',
addEnvVar: '添加环境变量',
addHeader: '添加请求头',
keyName: '键名',
value: '值',
connected: '已连接',
disconnected: '未连接',
error: '错误',
testConnection: '测试连接',
testing: '测试中...',
testSuccess: '连接测试成功',
testFailed: '连接测试失败:',
confirmDeleteServer: '你确定要删除MCP服务器{{name}})吗?',
deleteSuccess: '删除成功',
deleteError: '删除失败:',
saveSuccess: '保存成功',
saveError: '保存失败:',
createSuccess: '创建成功',
createError: '创建失败:',
modifyFailed: '修改失败:',
toolCount: '工具:{{count}}',
statusConnected: '已连接',
statusDisconnected: '未连接',
statusError: '连接错误',
serverStatus: '服务器状态',
marketplace: 'MCP商店',
searchServer: '搜索MCP服务器',
sortBy: '排序方式',
mostStars: '最多星标',
recentlyAdded: '最近添加',
recentlyUpdated: '最近更新',
loading: '加载中...',
noMatchingServers: '没有匹配的MCP服务器',
starCount: '星标:{{count}}',
install: '安装',
installing: '安装中...',
installSuccess: 'MCP服务器安装成功',
installFailed: 'MCP服务器安装失败',
installFromGithub: '从Github安装MCP服务器',
onlySupportGithub: '目前仅支持从Github安装MCP服务器',
enterGithubLink: '输入Github仓库链接',
},
pipelines: {
title: '流水线',
description: '流水线定义了对消息事件的处理流程,用于绑定到机器人',