feat(mcp): 添加 MCP 服务器管理功能
- 新增 Server MCP API 路由 (/api/mcp/*) - GET /servers - 获取所有服务器状态 - POST /servers/:name/connect|disconnect|enable|disable - GET /tools - 获取所有 MCP 工具 - GET /config - 获取 MCP 配置 - 新增 UI MCPPanel 组件 - 显示服务器列表和状态指示灯 - 支持连接/断开/启用/禁用操作 - 展开查看服务器配置和工具列表 - 响应式设计支持移动端 - 集成到 Web 和 Desktop - 添加 Plug 图标按钮到工具栏 - 点击打开 MCP 管理面板
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
FileBrowser,
|
FileBrowser,
|
||||||
ConfigPanel,
|
ConfigPanel,
|
||||||
CommandPanel,
|
CommandPanel,
|
||||||
|
MCPPanel,
|
||||||
Toaster,
|
Toaster,
|
||||||
listSessions,
|
listSessions,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -21,6 +22,7 @@ export function App() {
|
|||||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [showCommands, setShowCommands] = useState(false);
|
const [showCommands, setShowCommands] = useState(false);
|
||||||
|
const [showMCP, setShowMCP] = useState(false);
|
||||||
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
||||||
|
|
||||||
// 初始化:加载或创建会话
|
// 初始化:加载或创建会话
|
||||||
@@ -92,6 +94,7 @@ export function App() {
|
|||||||
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
|
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
|
||||||
onOpenConfig={() => setShowConfig(true)}
|
onOpenConfig={() => setShowConfig(true)}
|
||||||
onOpenCommands={() => setShowCommands(true)}
|
onOpenCommands={() => setShowCommands(true)}
|
||||||
|
onOpenMCP={() => setShowMCP(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center h-full">
|
<div className="flex-1 flex items-center justify-center h-full">
|
||||||
@@ -118,6 +121,9 @@ export function App() {
|
|||||||
{/* 命令面板 */}
|
{/* 命令面板 */}
|
||||||
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} />}
|
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} />}
|
||||||
|
|
||||||
|
{/* MCP 面板 */}
|
||||||
|
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} />}
|
||||||
|
|
||||||
{/* Toast 通知 */}
|
{/* Toast 通知 */}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal } from 'lucide-react';
|
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
@@ -21,6 +21,7 @@ interface ChatPageProps {
|
|||||||
onToggleFileBrowser?: () => void;
|
onToggleFileBrowser?: () => void;
|
||||||
onOpenConfig?: () => void;
|
onOpenConfig?: () => void;
|
||||||
onOpenCommands?: () => void;
|
onOpenCommands?: () => void;
|
||||||
|
onOpenMCP?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPage({
|
export function ChatPage({
|
||||||
@@ -30,6 +31,7 @@ export function ChatPage({
|
|||||||
onToggleFileBrowser,
|
onToggleFileBrowser,
|
||||||
onOpenConfig,
|
onOpenConfig,
|
||||||
onOpenCommands,
|
onOpenCommands,
|
||||||
|
onOpenMCP,
|
||||||
}: ChatPageProps) {
|
}: ChatPageProps) {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -123,8 +125,21 @@ export function ChatPage({
|
|||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
|
|
||||||
{/* 工具栏按钮 */}
|
{/* 工具栏按钮 */}
|
||||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands) && (
|
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP) && (
|
||||||
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
||||||
|
{/* MCP 按钮 */}
|
||||||
|
{onOpenMCP && (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
onClick={onOpenMCP}
|
||||||
|
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
|
||||||
|
title="MCP Servers"
|
||||||
|
>
|
||||||
|
<Plug size={20} />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 命令按钮 */}
|
{/* 命令按钮 */}
|
||||||
{onOpenCommands && (
|
{onOpenCommands && (
|
||||||
<motion.button
|
<motion.button
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
|
|||||||
import { logger } from 'hono/logger';
|
import { logger } from 'hono/logger';
|
||||||
import { createBunWebSocket } from 'hono/bun';
|
import { createBunWebSocket } from 'hono/bun';
|
||||||
|
|
||||||
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter } from './routes/index.js';
|
import { sessionsRouter, toolsRouter, configRouter, filesRouter, commandsRouter, mcpRouter } from './routes/index.js';
|
||||||
import {
|
import {
|
||||||
handleWebSocket,
|
handleWebSocket,
|
||||||
handleWebSocketMessage,
|
handleWebSocketMessage,
|
||||||
@@ -83,6 +83,7 @@ api.route('/tools', toolsRouter);
|
|||||||
api.route('/config', configRouter);
|
api.route('/config', configRouter);
|
||||||
api.route('/files', filesRouter);
|
api.route('/files', filesRouter);
|
||||||
api.route('/commands', commandsRouter);
|
api.route('/commands', commandsRouter);
|
||||||
|
api.route('/mcp', mcpRouter);
|
||||||
|
|
||||||
// SSE 事件流
|
// SSE 事件流
|
||||||
api.get('/sessions/:id/events', handleSSE);
|
api.get('/sessions/:id/events', handleSSE);
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { toolsRouter, registerTool, getRegisteredTools } from './tools.js';
|
|||||||
export { configRouter, getConfig, setConfig } from './config.js';
|
export { configRouter, getConfig, setConfig } from './config.js';
|
||||||
export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js';
|
export { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.js';
|
||||||
export { commandsRouter } from './commands.js';
|
export { commandsRouter } from './commands.js';
|
||||||
|
export { mcpRouter } from './mcp.js';
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
/**
|
||||||
|
* MCP API Routes
|
||||||
|
*
|
||||||
|
* 提供 MCP 服务器管理的 REST API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { getConfig } from './config.js';
|
||||||
|
|
||||||
|
// Core MCP 模块类型
|
||||||
|
interface MCPModule {
|
||||||
|
getMCPManager: () => MCPManager;
|
||||||
|
loadMCPConfig: (workdir: string) => Promise<MCPConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPManager {
|
||||||
|
initialize(config: MCPConfig): Promise<void>;
|
||||||
|
shutdown(): Promise<void>;
|
||||||
|
reconnect(serverName: string): Promise<void>;
|
||||||
|
setServerEnabled(serverName: string, enabled: boolean): Promise<void>;
|
||||||
|
getServerStatuses(): MCPServerStatus[];
|
||||||
|
getServerStatus(name: string): MCPServerStatus | undefined;
|
||||||
|
getTools(): MCPTool[];
|
||||||
|
getTool(name: string): MCPTool | undefined;
|
||||||
|
isInitialized(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPConfig {
|
||||||
|
mcp?: Record<string, MCPServerConfig>;
|
||||||
|
tools?: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPServerConfig {
|
||||||
|
type: 'local' | 'remote';
|
||||||
|
command?: string[];
|
||||||
|
url?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPServerStatus {
|
||||||
|
name: string;
|
||||||
|
type: 'local' | 'remote';
|
||||||
|
status: 'connected' | 'connecting' | 'disconnected' | 'disabled' | 'error';
|
||||||
|
toolCount: number;
|
||||||
|
error?: string;
|
||||||
|
lastConnected?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPTool {
|
||||||
|
server: string;
|
||||||
|
name: string;
|
||||||
|
originalName: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mcpRouter = new Hono();
|
||||||
|
|
||||||
|
// Core 模块缓存
|
||||||
|
let mcpModule: MCPModule | null = null;
|
||||||
|
let currentConfig: MCPConfig | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 MCP 模块
|
||||||
|
*/
|
||||||
|
async function initMCPModule(): Promise<MCPModule | null> {
|
||||||
|
if (mcpModule) return mcpModule;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const corePath = '@ai-assistant/core';
|
||||||
|
const core = (await import(corePath)) as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof core.getMCPManager !== 'function' ||
|
||||||
|
typeof core.loadMCPConfig !== 'function'
|
||||||
|
) {
|
||||||
|
console.warn('[MCP] Core module missing MCP exports');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpModule = {
|
||||||
|
getMCPManager: core.getMCPManager as () => MCPManager,
|
||||||
|
loadMCPConfig: core.loadMCPConfig as (workdir: string) => Promise<MCPConfig>,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化 MCP Manager
|
||||||
|
const config = getConfig();
|
||||||
|
currentConfig = await mcpModule.loadMCPConfig(config.workdir);
|
||||||
|
const manager = mcpModule.getMCPManager();
|
||||||
|
|
||||||
|
if (!manager.isInitialized() && currentConfig.mcp) {
|
||||||
|
await manager.initialize(currentConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[MCP] MCP module initialized');
|
||||||
|
return mcpModule;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MCP] Failed to load MCP module:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /mcp/servers - 获取所有服务器状态
|
||||||
|
*/
|
||||||
|
mcpRouter.get('/servers', async (c) => {
|
||||||
|
const module = await initMCPModule();
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'MCP module not available',
|
||||||
|
},
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = module.getMCPManager();
|
||||||
|
const statuses = manager.getServerStatuses();
|
||||||
|
|
||||||
|
// 添加配置信息
|
||||||
|
const serversWithConfig = statuses.map((status) => {
|
||||||
|
const serverConfig = currentConfig?.mcp?.[status.name];
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
config: serverConfig
|
||||||
|
? {
|
||||||
|
type: serverConfig.type,
|
||||||
|
command: serverConfig.type === 'local' ? serverConfig.command : undefined,
|
||||||
|
url: serverConfig.type === 'remote' ? serverConfig.url : undefined,
|
||||||
|
timeout: serverConfig.timeout,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: serversWithConfig,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /mcp/servers/:name - 获取单个服务器详情
|
||||||
|
*/
|
||||||
|
mcpRouter.get('/servers/:name', async (c) => {
|
||||||
|
const name = c.req.param('name');
|
||||||
|
const module = await initMCPModule();
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'MCP module not available',
|
||||||
|
},
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = module.getMCPManager();
|
||||||
|
const status = manager.getServerStatus(name);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: `Server not found: ${name}`,
|
||||||
|
},
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverConfig = currentConfig?.mcp?.[name];
|
||||||
|
const tools = manager
|
||||||
|
.getTools()
|
||||||
|
.filter((tool) => tool.server === name);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...status,
|
||||||
|
config: serverConfig
|
||||||
|
? {
|
||||||
|
type: serverConfig.type,
|
||||||
|
command: serverConfig.type === 'local' ? serverConfig.command : undefined,
|
||||||
|
url: serverConfig.type === 'remote' ? serverConfig.url : undefined,
|
||||||
|
timeout: serverConfig.timeout,
|
||||||
|
cwd: serverConfig.type === 'local' ? serverConfig.cwd : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
tools: tools.map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
originalName: t.originalName,
|
||||||
|
description: t.description,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /mcp/servers/:name/connect - 连接服务器
|
||||||
|
*/
|
||||||
|
mcpRouter.post('/servers/:name/connect', async (c) => {
|
||||||
|
const name = c.req.param('name');
|
||||||
|
const module = await initMCPModule();
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'MCP module not available',
|
||||||
|
},
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = module.getMCPManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.reconnect(name);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: `Server ${name} connected`,
|
||||||
|
status: manager.getServerStatus(name),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Connection failed',
|
||||||
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /mcp/servers/:name/disconnect - 断开服务器
|
||||||
|
*/
|
||||||
|
mcpRouter.post('/servers/:name/disconnect', async (c) => {
|
||||||
|
const name = c.req.param('name');
|
||||||
|
const module = await initMCPModule();
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'MCP module not available',
|
||||||
|
},
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = module.getMCPManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 通过禁用来断开连接
|
||||||
|
await manager.setServerEnabled(name, false);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: `Server ${name} disconnected`,
|
||||||
|
status: manager.getServerStatus(name),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Disconnect failed',
|
||||||
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /mcp/servers/:name/enable - 启用服务器
|
||||||
|
*/
|
||||||
|
mcpRouter.post('/servers/:name/enable', async (c) => {
|
||||||
|
const name = c.req.param('name');
|
||||||
|
const module = await initMCPModule();
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'MCP module not available',
|
||||||
|
},
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = module.getMCPManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.setServerEnabled(name, true);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: `Server ${name} enabled`,
|
||||||
|
status: manager.getServerStatus(name),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Enable failed',
|
||||||
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /mcp/servers/:name/disable - 禁用服务器
|
||||||
|
*/
|
||||||
|
mcpRouter.post('/servers/:name/disable', async (c) => {
|
||||||
|
const name = c.req.param('name');
|
||||||
|
const module = await initMCPModule();
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'MCP module not available',
|
||||||
|
},
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = module.getMCPManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.setServerEnabled(name, false);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
message: `Server ${name} disabled`,
|
||||||
|
status: manager.getServerStatus(name),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Disable failed',
|
||||||
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /mcp/tools - 获取所有 MCP 工具
|
||||||
|
*/
|
||||||
|
mcpRouter.get('/tools', async (c) => {
|
||||||
|
const module = await initMCPModule();
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'MCP module not available',
|
||||||
|
},
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = module.getMCPManager();
|
||||||
|
const tools = manager.getTools();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: tools.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
server: tool.server,
|
||||||
|
originalName: tool.originalName,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /mcp/tools/:name - 获取单个工具详情
|
||||||
|
*/
|
||||||
|
mcpRouter.get('/tools/:name', async (c) => {
|
||||||
|
const name = c.req.param('name');
|
||||||
|
const module = await initMCPModule();
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'MCP module not available',
|
||||||
|
},
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = module.getMCPManager();
|
||||||
|
const tool = manager.getTool(name);
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: `Tool not found: ${name}`,
|
||||||
|
},
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
name: tool.name,
|
||||||
|
server: tool.server,
|
||||||
|
originalName: tool.originalName,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /mcp/config - 获取 MCP 配置
|
||||||
|
*/
|
||||||
|
mcpRouter.get('/config', async (c) => {
|
||||||
|
const module = await initMCPModule();
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'MCP module not available',
|
||||||
|
},
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回配置(隐藏敏感信息)
|
||||||
|
const safeConfig: MCPConfig = {
|
||||||
|
mcp: {},
|
||||||
|
tools: currentConfig?.tools,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentConfig?.mcp) {
|
||||||
|
for (const [name, config] of Object.entries(currentConfig.mcp)) {
|
||||||
|
safeConfig.mcp![name] = {
|
||||||
|
type: config.type,
|
||||||
|
command: config.type === 'local' ? config.command : undefined,
|
||||||
|
url: config.type === 'remote' ? config.url : undefined,
|
||||||
|
enabled: config.enabled,
|
||||||
|
timeout: config.timeout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
data: safeConfig,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,9 @@ import type {
|
|||||||
CreateCommandInput,
|
CreateCommandInput,
|
||||||
UpdateCommandInput,
|
UpdateCommandInput,
|
||||||
CommandContent,
|
CommandContent,
|
||||||
|
MCPServerStatus,
|
||||||
|
MCPToolInfo,
|
||||||
|
MCPConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
@@ -37,6 +40,11 @@ export type {
|
|||||||
CreateCommandInput,
|
CreateCommandInput,
|
||||||
UpdateCommandInput,
|
UpdateCommandInput,
|
||||||
CommandContent,
|
CommandContent,
|
||||||
|
MCPServerStatus,
|
||||||
|
MCPServerStatusType,
|
||||||
|
MCPToolInfo,
|
||||||
|
MCPConfig,
|
||||||
|
MCPServerConfigInfo,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// API Configuration
|
// API Configuration
|
||||||
@@ -235,3 +243,104 @@ export async function deleteCommand(
|
|||||||
): Promise<{ success: boolean; data?: { name: string; path: string }; error?: string }> {
|
): Promise<{ success: boolean; data?: { name: string; path: string }; error?: string }> {
|
||||||
return request('DELETE', `/commands/${encodeURIComponent(name)}`);
|
return request('DELETE', `/commands/${encodeURIComponent(name)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ MCP API ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 MCP 服务器状态
|
||||||
|
*/
|
||||||
|
export async function listMCPServers(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: MCPServerStatus[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return request('GET', '/mcp/servers');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个 MCP 服务器详情
|
||||||
|
*/
|
||||||
|
export async function getMCPServer(name: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MCPServerStatus;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return request('GET', `/mcp/servers/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接 MCP 服务器
|
||||||
|
*/
|
||||||
|
export async function connectMCPServer(name: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { message: string; status: MCPServerStatus };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return request('POST', `/mcp/servers/${encodeURIComponent(name)}/connect`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开 MCP 服务器
|
||||||
|
*/
|
||||||
|
export async function disconnectMCPServer(name: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { message: string; status: MCPServerStatus };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return request('POST', `/mcp/servers/${encodeURIComponent(name)}/disconnect`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用 MCP 服务器
|
||||||
|
*/
|
||||||
|
export async function enableMCPServer(name: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { message: string; status: MCPServerStatus };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return request('POST', `/mcp/servers/${encodeURIComponent(name)}/enable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用 MCP 服务器
|
||||||
|
*/
|
||||||
|
export async function disableMCPServer(name: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { message: string; status: MCPServerStatus };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return request('POST', `/mcp/servers/${encodeURIComponent(name)}/disable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 MCP 工具
|
||||||
|
*/
|
||||||
|
export async function listMCPTools(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: MCPToolInfo[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return request('GET', '/mcp/tools');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个 MCP 工具详情
|
||||||
|
*/
|
||||||
|
export async function getMCPTool(name: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MCPToolInfo;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return request('GET', `/mcp/tools/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 MCP 配置
|
||||||
|
*/
|
||||||
|
export async function getMCPConfig(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: MCPConfig;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
return request('GET', '/mcp/config');
|
||||||
|
}
|
||||||
|
|||||||
@@ -171,3 +171,68 @@ export interface CommandContent {
|
|||||||
source: string;
|
source: string;
|
||||||
sourcePath?: string;
|
sourcePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ MCP 相关 ============
|
||||||
|
|
||||||
|
/** MCP 服务器状态类型 */
|
||||||
|
export type MCPServerStatusType =
|
||||||
|
| 'connected'
|
||||||
|
| 'connecting'
|
||||||
|
| 'disconnected'
|
||||||
|
| 'disabled'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
/** MCP 服务器状态 */
|
||||||
|
export interface MCPServerStatus {
|
||||||
|
/** 服务器名称 */
|
||||||
|
name: string;
|
||||||
|
/** 服务器类型 */
|
||||||
|
type: 'local' | 'remote';
|
||||||
|
/** 当前状态 */
|
||||||
|
status: MCPServerStatusType;
|
||||||
|
/** 工具数量 */
|
||||||
|
toolCount: number;
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string;
|
||||||
|
/** 配置信息 */
|
||||||
|
config?: {
|
||||||
|
type: 'local' | 'remote';
|
||||||
|
command?: string[];
|
||||||
|
url?: string;
|
||||||
|
timeout?: number;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
/** 工具列表(仅详情接口返回) */
|
||||||
|
tools?: MCPToolInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MCP 工具信息 */
|
||||||
|
export interface MCPToolInfo {
|
||||||
|
/** 完整工具名: {server}-{originalName} */
|
||||||
|
name: string;
|
||||||
|
/** 来源服务器名称 */
|
||||||
|
server: string;
|
||||||
|
/** MCP 服务器中的原始名称 */
|
||||||
|
originalName: string;
|
||||||
|
/** 工具描述 */
|
||||||
|
description: string;
|
||||||
|
/** 输入参数 JSON Schema */
|
||||||
|
inputSchema?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MCP 配置 */
|
||||||
|
export interface MCPConfig {
|
||||||
|
/** MCP 服务器配置 */
|
||||||
|
mcp?: Record<string, MCPServerConfigInfo>;
|
||||||
|
/** 工具启用/禁用配置 */
|
||||||
|
tools?: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MCP 服务器配置信息 */
|
||||||
|
export interface MCPServerConfigInfo {
|
||||||
|
type: 'local' | 'remote';
|
||||||
|
command?: string[];
|
||||||
|
url?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
/**
|
||||||
|
* MCPPanel Component
|
||||||
|
*
|
||||||
|
* MCP 服务器管理面板:列出服务器状态、连接/断开、启用/禁用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
RefreshCw,
|
||||||
|
Plug,
|
||||||
|
PlugZap,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Server,
|
||||||
|
Globe,
|
||||||
|
Wrench,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
|
||||||
|
import { Button } from '../primitives/Button';
|
||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
import {
|
||||||
|
listMCPServers,
|
||||||
|
connectMCPServer,
|
||||||
|
disconnectMCPServer,
|
||||||
|
enableMCPServer,
|
||||||
|
disableMCPServer,
|
||||||
|
type MCPServerStatus,
|
||||||
|
} from '../api/client.js';
|
||||||
|
|
||||||
|
interface MCPPanelProps {
|
||||||
|
onClose: () => void;
|
||||||
|
/** 是否启用响应式布局 */
|
||||||
|
responsive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态颜色映射
|
||||||
|
function getStatusColor(status: MCPServerStatus['status']) {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
return 'bg-green-500';
|
||||||
|
case 'connecting':
|
||||||
|
return 'bg-yellow-500 animate-pulse';
|
||||||
|
case 'disconnected':
|
||||||
|
return 'bg-gray-500';
|
||||||
|
case 'disabled':
|
||||||
|
return 'bg-gray-600';
|
||||||
|
case 'error':
|
||||||
|
return 'bg-red-500';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态文字
|
||||||
|
function getStatusText(status: MCPServerStatus['status']) {
|
||||||
|
switch (status) {
|
||||||
|
case 'connected':
|
||||||
|
return 'Connected';
|
||||||
|
case 'connecting':
|
||||||
|
return 'Connecting...';
|
||||||
|
case 'disconnected':
|
||||||
|
return 'Disconnected';
|
||||||
|
case 'disabled':
|
||||||
|
return 'Disabled';
|
||||||
|
case 'error':
|
||||||
|
return 'Error';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型图标
|
||||||
|
function getTypeIcon(type: 'local' | 'remote') {
|
||||||
|
return type === 'local' ? (
|
||||||
|
<Server size={14} className="text-blue-400" />
|
||||||
|
) : (
|
||||||
|
<Globe size={14} className="text-purple-400" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPPanel({ onClose, responsive = false }: MCPPanelProps) {
|
||||||
|
// 数据状态
|
||||||
|
const [servers, setServers] = useState<MCPServerStatus[]>([]);
|
||||||
|
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// UI 状态
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 加载服务器列表
|
||||||
|
const loadServers = useCallback(async (showToast = false) => {
|
||||||
|
try {
|
||||||
|
const result = await listMCPServers();
|
||||||
|
if (result.success) {
|
||||||
|
setServers(result.data);
|
||||||
|
if (showToast) {
|
||||||
|
toast.success('Servers refreshed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || 'Failed to load servers');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to load servers');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始加载
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
loadServers().finally(() => setLoading(false));
|
||||||
|
}, [loadServers]);
|
||||||
|
|
||||||
|
// 刷新
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await loadServers(true);
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 连接服务器
|
||||||
|
const handleConnect = async (name: string) => {
|
||||||
|
setActionLoading(name);
|
||||||
|
try {
|
||||||
|
const result = await connectMCPServer(name);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Server "${name}" connected`);
|
||||||
|
await loadServers();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || 'Connection failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Connection failed');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 断开服务器
|
||||||
|
const handleDisconnect = async (name: string) => {
|
||||||
|
setActionLoading(name);
|
||||||
|
try {
|
||||||
|
const result = await disconnectMCPServer(name);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Server "${name}" disconnected`);
|
||||||
|
await loadServers();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || 'Disconnect failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Disconnect failed');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启用服务器
|
||||||
|
const handleEnable = async (name: string) => {
|
||||||
|
setActionLoading(name);
|
||||||
|
try {
|
||||||
|
const result = await enableMCPServer(name);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Server "${name}" enabled`);
|
||||||
|
await loadServers();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || 'Enable failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Enable failed');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 禁用服务器
|
||||||
|
const handleDisable = async (name: string) => {
|
||||||
|
setActionLoading(name);
|
||||||
|
try {
|
||||||
|
const result = await disableMCPServer(name);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Server "${name}" disabled`);
|
||||||
|
await loadServers();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || 'Disable failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Disable failed');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换展开
|
||||||
|
const toggleExpanded = (name: string) => {
|
||||||
|
const newExpanded = new Set(expandedServers);
|
||||||
|
if (newExpanded.has(name)) {
|
||||||
|
newExpanded.delete(name);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(name);
|
||||||
|
}
|
||||||
|
setExpandedServers(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
const connectedCount = servers.filter((s) => s.status === 'connected').length;
|
||||||
|
const totalToolCount = servers.reduce((sum, s) => sum + s.toolCount, 0);
|
||||||
|
|
||||||
|
// Loading 骨架屏
|
||||||
|
const LoadingSkeleton = () => (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg">
|
||||||
|
<Skeleton className="h-3 w-3 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-3 w-48" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
variants={modalOverlay}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 bg-black/50 flex z-50',
|
||||||
|
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
||||||
|
)}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
variants={modalContent}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={smoothTransition}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
'bg-gray-800 max-h-[90vh] overflow-hidden flex flex-col',
|
||||||
|
responsive
|
||||||
|
? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg'
|
||||||
|
: 'rounded-lg w-full max-w-2xl mx-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between border-b border-gray-700',
|
||||||
|
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{responsive && (
|
||||||
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-gray-600 rounded-full md:hidden" />
|
||||||
|
)}
|
||||||
|
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Plug size={20} className="text-primary-400" />
|
||||||
|
MCP Servers
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{servers.length} servers ({connectedCount} connected, {totalToolCount} tools)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
title="Refresh"
|
||||||
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server List */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSkeleton />
|
||||||
|
) : servers.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||||
|
<Plug size={48} className="mb-4 opacity-50" />
|
||||||
|
<p className="text-center">No MCP servers configured</p>
|
||||||
|
<p className="text-xs text-gray-600 mt-2 text-center max-w-xs">
|
||||||
|
Configure MCP servers in your .ai-assist/config.json file
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className={cn('space-y-2', responsive ? 'p-4' : 'p-4')}
|
||||||
|
>
|
||||||
|
{servers.map((server) => {
|
||||||
|
const isExpanded = expandedServers.has(server.name);
|
||||||
|
const isLoading = actionLoading === server.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={server.name}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-gray-900/50 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Server Header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 p-3',
|
||||||
|
'hover:bg-gray-900/80 transition-colors cursor-pointer'
|
||||||
|
)}
|
||||||
|
onClick={() => toggleExpanded(server.name)}
|
||||||
|
>
|
||||||
|
{/* Expand Icon */}
|
||||||
|
<button className="text-gray-500 hover:text-gray-300">
|
||||||
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Status Indicator */}
|
||||||
|
<div className={cn('w-2.5 h-2.5 rounded-full', getStatusColor(server.status))} />
|
||||||
|
|
||||||
|
{/* Type Icon */}
|
||||||
|
{getTypeIcon(server.type)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-200">{server.name}</span>
|
||||||
|
<span className="text-xs text-gray-500">{server.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span>{getStatusText(server.status)}</span>
|
||||||
|
{server.toolCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Wrench size={10} />
|
||||||
|
{server.toolCount} tools
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-primary-500" />
|
||||||
|
) : server.status === 'disabled' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEnable(server.name)}
|
||||||
|
className="text-green-400 hover:text-green-300"
|
||||||
|
>
|
||||||
|
<Power size={14} className="mr-1" />
|
||||||
|
Enable
|
||||||
|
</Button>
|
||||||
|
) : server.status === 'connected' ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDisconnect(server.name)}
|
||||||
|
className="text-yellow-400 hover:text-yellow-300"
|
||||||
|
>
|
||||||
|
<PlugZap size={14} className="mr-1" />
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDisable(server.name)}
|
||||||
|
className="text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
<PowerOff size={14} className="mr-1" />
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : server.status === 'disconnected' || server.status === 'error' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleConnect(server.name)}
|
||||||
|
className="text-blue-400 hover:text-blue-300"
|
||||||
|
>
|
||||||
|
<PlugZap size={14} className="mr-1" />
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-4 pb-3 pt-1 border-t border-gray-700/50">
|
||||||
|
{/* Error Message */}
|
||||||
|
{server.error && (
|
||||||
|
<div className="flex items-start gap-2 p-2 bg-red-500/10 rounded text-red-400 text-xs mb-2">
|
||||||
|
<AlertCircle size={14} className="mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{server.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Config Info */}
|
||||||
|
{server.config && (
|
||||||
|
<div className="space-y-1 text-xs text-gray-500">
|
||||||
|
{server.config.command && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Command:</span>{' '}
|
||||||
|
<code className="font-mono bg-gray-800 px-1 rounded">
|
||||||
|
{server.config.command.join(' ')}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{server.config.url && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">URL:</span>{' '}
|
||||||
|
<code className="font-mono bg-gray-800 px-1 rounded">
|
||||||
|
{server.config.url}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{server.config.timeout && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Timeout:</span>{' '}
|
||||||
|
{server.config.timeout}ms
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tools List */}
|
||||||
|
{server.tools && server.tools.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="text-xs text-gray-400 mb-1">Tools:</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{server.tools.map((tool) => (
|
||||||
|
<span
|
||||||
|
key={tool.name}
|
||||||
|
className="px-2 py-0.5 bg-gray-800 rounded text-xs text-gray-300"
|
||||||
|
title={tool.description}
|
||||||
|
>
|
||||||
|
{tool.originalName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Info */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-t border-gray-700 text-xs text-gray-500 text-center',
|
||||||
|
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Configure servers in <code className="font-mono bg-gray-900 px-1 rounded">.ai-assist/config.json</code>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,16 @@ export {
|
|||||||
getCommandContent,
|
getCommandContent,
|
||||||
updateCommand,
|
updateCommand,
|
||||||
deleteCommand,
|
deleteCommand,
|
||||||
|
// MCP API
|
||||||
|
listMCPServers,
|
||||||
|
getMCPServer,
|
||||||
|
connectMCPServer,
|
||||||
|
disconnectMCPServer,
|
||||||
|
enableMCPServer,
|
||||||
|
disableMCPServer,
|
||||||
|
listMCPTools,
|
||||||
|
getMCPTool,
|
||||||
|
getMCPConfig,
|
||||||
} from './api/client.js';
|
} from './api/client.js';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -54,6 +64,12 @@ export type {
|
|||||||
CreateCommandInput,
|
CreateCommandInput,
|
||||||
UpdateCommandInput,
|
UpdateCommandInput,
|
||||||
CommandContent,
|
CommandContent,
|
||||||
|
// MCP types
|
||||||
|
MCPServerStatus,
|
||||||
|
MCPServerStatusType,
|
||||||
|
MCPToolInfo,
|
||||||
|
MCPConfig,
|
||||||
|
MCPServerConfigInfo,
|
||||||
} from './api/client.js';
|
} from './api/client.js';
|
||||||
|
|
||||||
// Primitives (shadcn/ui style)
|
// Primitives (shadcn/ui style)
|
||||||
@@ -69,6 +85,7 @@ export { ChatInput } from './components/ChatInput.js';
|
|||||||
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
|
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
|
||||||
export { CommandPanel } from './components/CommandPanel.js';
|
export { CommandPanel } from './components/CommandPanel.js';
|
||||||
export { CommandEditor } from './components/CommandEditor.js';
|
export { CommandEditor } from './components/CommandEditor.js';
|
||||||
|
export { MCPPanel } from './components/MCPPanel.js';
|
||||||
export { Sidebar } from './components/Sidebar.js';
|
export { Sidebar } from './components/Sidebar.js';
|
||||||
export { FileBrowser } from './components/FileBrowser.js';
|
export { FileBrowser } from './components/FileBrowser.js';
|
||||||
export { ConfigPanel } from './components/ConfigPanel.js';
|
export { ConfigPanel } from './components/ConfigPanel.js';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
FileBrowser,
|
FileBrowser,
|
||||||
ConfigPanel,
|
ConfigPanel,
|
||||||
CommandPanel,
|
CommandPanel,
|
||||||
|
MCPPanel,
|
||||||
Toaster,
|
Toaster,
|
||||||
listSessions,
|
listSessions,
|
||||||
createSession,
|
createSession,
|
||||||
@@ -23,6 +24,7 @@ export function App() {
|
|||||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [showCommands, setShowCommands] = useState(false);
|
const [showCommands, setShowCommands] = useState(false);
|
||||||
|
const [showMCP, setShowMCP] = useState(false);
|
||||||
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
||||||
|
|
||||||
// 初始化:加载或创建会话
|
// 初始化:加载或创建会话
|
||||||
@@ -108,6 +110,7 @@ export function App() {
|
|||||||
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
|
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
|
||||||
onOpenConfig={() => setShowConfig(true)}
|
onOpenConfig={() => setShowConfig(true)}
|
||||||
onOpenCommands={() => setShowCommands(true)}
|
onOpenCommands={() => setShowCommands(true)}
|
||||||
|
onOpenMCP={() => setShowMCP(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center h-full">
|
<div className="flex-1 flex items-center justify-center h-full">
|
||||||
@@ -159,6 +162,9 @@ export function App() {
|
|||||||
{/* 命令面板 */}
|
{/* 命令面板 */}
|
||||||
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
|
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
|
||||||
|
|
||||||
|
{/* MCP 面板 */}
|
||||||
|
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} responsive />}
|
||||||
|
|
||||||
{/* 移动端底部文件按钮 */}
|
{/* 移动端底部文件按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFileBrowser(true)}
|
onClick={() => setShowFileBrowser(true)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal } from 'lucide-react';
|
import { WifiOff, MessageSquare, Settings, FolderOpen, Terminal, Plug } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
@@ -23,6 +23,7 @@ interface ChatPageProps {
|
|||||||
onToggleFileBrowser?: () => void;
|
onToggleFileBrowser?: () => void;
|
||||||
onOpenConfig?: () => void;
|
onOpenConfig?: () => void;
|
||||||
onOpenCommands?: () => void;
|
onOpenCommands?: () => void;
|
||||||
|
onOpenMCP?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPage({
|
export function ChatPage({
|
||||||
@@ -34,6 +35,7 @@ export function ChatPage({
|
|||||||
onToggleFileBrowser,
|
onToggleFileBrowser,
|
||||||
onOpenConfig,
|
onOpenConfig,
|
||||||
onOpenCommands,
|
onOpenCommands,
|
||||||
|
onOpenMCP,
|
||||||
}: ChatPageProps) {
|
}: ChatPageProps) {
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -128,8 +130,21 @@ export function ChatPage({
|
|||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
|
|
||||||
{/* 工具栏按钮 */}
|
{/* 工具栏按钮 */}
|
||||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands) && (
|
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP) && (
|
||||||
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
<div className="flex items-center gap-1.5 border-l border-gray-600 pl-3">
|
||||||
|
{/* MCP 按钮 */}
|
||||||
|
{onOpenMCP && (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
onClick={onOpenMCP}
|
||||||
|
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
|
||||||
|
title="MCP Servers"
|
||||||
|
>
|
||||||
|
<Plug size={20} />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 命令按钮 */}
|
{/* 命令按钮 */}
|
||||||
{onOpenCommands && (
|
{onOpenCommands && (
|
||||||
<motion.button
|
<motion.button
|
||||||
|
|||||||
Reference in New Issue
Block a user