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,
|
||||
ConfigPanel,
|
||||
CommandPanel,
|
||||
MCPPanel,
|
||||
Toaster,
|
||||
listSessions,
|
||||
createSession,
|
||||
@@ -21,6 +22,7 @@ export function App() {
|
||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showCommands, setShowCommands] = useState(false);
|
||||
const [showMCP, setShowMCP] = useState(false);
|
||||
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
||||
|
||||
// 初始化:加载或创建会话
|
||||
@@ -92,6 +94,7 @@ export function App() {
|
||||
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
|
||||
onOpenConfig={() => setShowConfig(true)}
|
||||
onOpenCommands={() => setShowCommands(true)}
|
||||
onOpenMCP={() => setShowMCP(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
@@ -118,6 +121,9 @@ export function App() {
|
||||
{/* 命令面板 */}
|
||||
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} />}
|
||||
|
||||
{/* MCP 面板 */}
|
||||
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} />}
|
||||
|
||||
{/* Toast 通知 */}
|
||||
<Toaster />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
useChat,
|
||||
@@ -21,6 +21,7 @@ interface ChatPageProps {
|
||||
onToggleFileBrowser?: () => void;
|
||||
onOpenConfig?: () => void;
|
||||
onOpenCommands?: () => void;
|
||||
onOpenMCP?: () => void;
|
||||
}
|
||||
|
||||
export function ChatPage({
|
||||
@@ -30,6 +31,7 @@ export function ChatPage({
|
||||
onToggleFileBrowser,
|
||||
onOpenConfig,
|
||||
onOpenCommands,
|
||||
onOpenMCP,
|
||||
}: ChatPageProps) {
|
||||
const {
|
||||
messages,
|
||||
@@ -123,8 +125,21 @@ export function ChatPage({
|
||||
<ConnectionStatus />
|
||||
|
||||
{/* 工具栏按钮 */}
|
||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands) && (
|
||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP) && (
|
||||
<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 && (
|
||||
<motion.button
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
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 {
|
||||
handleWebSocket,
|
||||
handleWebSocketMessage,
|
||||
@@ -83,6 +83,7 @@ api.route('/tools', toolsRouter);
|
||||
api.route('/config', configRouter);
|
||||
api.route('/files', filesRouter);
|
||||
api.route('/commands', commandsRouter);
|
||||
api.route('/mcp', mcpRouter);
|
||||
|
||||
// SSE 事件流
|
||||
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 { filesRouter, setWorkingDirectory, getWorkingDirectory } from './files.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,
|
||||
UpdateCommandInput,
|
||||
CommandContent,
|
||||
MCPServerStatus,
|
||||
MCPToolInfo,
|
||||
MCPConfig,
|
||||
} from './types.js';
|
||||
|
||||
// Re-export types
|
||||
@@ -37,6 +40,11 @@ export type {
|
||||
CreateCommandInput,
|
||||
UpdateCommandInput,
|
||||
CommandContent,
|
||||
MCPServerStatus,
|
||||
MCPServerStatusType,
|
||||
MCPToolInfo,
|
||||
MCPConfig,
|
||||
MCPServerConfigInfo,
|
||||
} from './types.js';
|
||||
|
||||
// API Configuration
|
||||
@@ -235,3 +243,104 @@ export async function deleteCommand(
|
||||
): Promise<{ success: boolean; data?: { name: string; path: string }; error?: string }> {
|
||||
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;
|
||||
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,
|
||||
updateCommand,
|
||||
deleteCommand,
|
||||
// MCP API
|
||||
listMCPServers,
|
||||
getMCPServer,
|
||||
connectMCPServer,
|
||||
disconnectMCPServer,
|
||||
enableMCPServer,
|
||||
disableMCPServer,
|
||||
listMCPTools,
|
||||
getMCPTool,
|
||||
getMCPConfig,
|
||||
} from './api/client.js';
|
||||
|
||||
// Types
|
||||
@@ -54,6 +64,12 @@ export type {
|
||||
CreateCommandInput,
|
||||
UpdateCommandInput,
|
||||
CommandContent,
|
||||
// MCP types
|
||||
MCPServerStatus,
|
||||
MCPServerStatusType,
|
||||
MCPToolInfo,
|
||||
MCPConfig,
|
||||
MCPServerConfigInfo,
|
||||
} from './api/client.js';
|
||||
|
||||
// Primitives (shadcn/ui style)
|
||||
@@ -69,6 +85,7 @@ export { ChatInput } from './components/ChatInput.js';
|
||||
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
|
||||
export { CommandPanel } from './components/CommandPanel.js';
|
||||
export { CommandEditor } from './components/CommandEditor.js';
|
||||
export { MCPPanel } from './components/MCPPanel.js';
|
||||
export { Sidebar } from './components/Sidebar.js';
|
||||
export { FileBrowser } from './components/FileBrowser.js';
|
||||
export { ConfigPanel } from './components/ConfigPanel.js';
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FileBrowser,
|
||||
ConfigPanel,
|
||||
CommandPanel,
|
||||
MCPPanel,
|
||||
Toaster,
|
||||
listSessions,
|
||||
createSession,
|
||||
@@ -23,6 +24,7 @@ export function App() {
|
||||
const [showFileBrowser, setShowFileBrowser] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showCommands, setShowCommands] = useState(false);
|
||||
const [showMCP, setShowMCP] = useState(false);
|
||||
const [sessionTitleUpdate, setSessionTitleUpdate] = useState<{ sessionId: string; name: string } | null>(null);
|
||||
|
||||
// 初始化:加载或创建会话
|
||||
@@ -108,6 +110,7 @@ export function App() {
|
||||
onToggleFileBrowser={() => setShowFileBrowser(!showFileBrowser)}
|
||||
onOpenConfig={() => setShowConfig(true)}
|
||||
onOpenCommands={() => setShowCommands(true)}
|
||||
onOpenMCP={() => setShowMCP(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
@@ -159,6 +162,9 @@ export function App() {
|
||||
{/* 命令面板 */}
|
||||
{showCommands && <CommandPanel onClose={() => setShowCommands(false)} responsive />}
|
||||
|
||||
{/* MCP 面板 */}
|
||||
{showMCP && <MCPPanel onClose={() => setShowMCP(false)} responsive />}
|
||||
|
||||
{/* 移动端底部文件按钮 */}
|
||||
<button
|
||||
onClick={() => setShowFileBrowser(true)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
useChat,
|
||||
@@ -23,6 +23,7 @@ interface ChatPageProps {
|
||||
onToggleFileBrowser?: () => void;
|
||||
onOpenConfig?: () => void;
|
||||
onOpenCommands?: () => void;
|
||||
onOpenMCP?: () => void;
|
||||
}
|
||||
|
||||
export function ChatPage({
|
||||
@@ -34,6 +35,7 @@ export function ChatPage({
|
||||
onToggleFileBrowser,
|
||||
onOpenConfig,
|
||||
onOpenCommands,
|
||||
onOpenMCP,
|
||||
}: ChatPageProps) {
|
||||
const {
|
||||
messages,
|
||||
@@ -128,8 +130,21 @@ export function ChatPage({
|
||||
<ConnectionStatus />
|
||||
|
||||
{/* 工具栏按钮 */}
|
||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands) && (
|
||||
{(onOpenConfig || onToggleFileBrowser || onOpenCommands || onOpenMCP) && (
|
||||
<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 && (
|
||||
<motion.button
|
||||
|
||||
Reference in New Issue
Block a user