mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 20:50:34 +00:00
feat: add mcp dialog
This commit is contained in:
parent
51fdf3c16a
commit
f64f08d8a1
17 changed files with 1453 additions and 9 deletions
412
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
Normal file
412
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import type {
|
||||
MCPManagementDialogProps,
|
||||
MCPServerDisplayInfo,
|
||||
MCPToolDisplayInfo,
|
||||
} from './types.js';
|
||||
import { MCP_MANAGEMENT_STEPS } from './types.js';
|
||||
import { ServerListStep } from './steps/ServerListStep.js';
|
||||
import { ServerDetailStep } from './steps/ServerDetailStep.js';
|
||||
import { ToolListStep } from './steps/ToolListStep.js';
|
||||
import { ToolDetailStep } from './steps/ToolDetailStep.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import {
|
||||
getMCPServerStatus,
|
||||
DiscoveredMCPTool,
|
||||
type MCPServerConfig,
|
||||
type AnyDeclarativeTool,
|
||||
type DiscoveredMCPPrompt,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
|
||||
const [servers, setServers] = useState<MCPServerDisplayInfo[]>([]);
|
||||
const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1);
|
||||
const [selectedTool, setSelectedTool] = useState<MCPToolDisplayInfo | null>(
|
||||
null,
|
||||
);
|
||||
const [navigationStack, setNavigationStack] = useState<string[]>([
|
||||
MCP_MANAGEMENT_STEPS.SERVER_LIST,
|
||||
]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 加载MCP服务器数据
|
||||
useEffect(() => {
|
||||
const loadServers = async () => {
|
||||
if (!config) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const promptRegistry = await config.getPromptRegistry();
|
||||
|
||||
const serverInfos: MCPServerDisplayInfo[] = [];
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(mcpServers) as Array<
|
||||
[string, MCPServerConfig]
|
||||
>) {
|
||||
const status = getMCPServerStatus(name);
|
||||
|
||||
// 获取该服务器的工具
|
||||
const allTools: AnyDeclarativeTool[] =
|
||||
toolRegistry?.getAllTools() || [];
|
||||
const serverTools = allTools.filter(
|
||||
(t): t is DiscoveredMCPTool =>
|
||||
t instanceof DiscoveredMCPTool && t.serverName === name,
|
||||
);
|
||||
|
||||
// 获取该服务器的prompts
|
||||
const allPrompts: DiscoveredMCPPrompt[] =
|
||||
promptRegistry?.getAllPrompts() || [];
|
||||
const serverPrompts = allPrompts.filter(
|
||||
(p) => 'serverName' in p && p.serverName === name,
|
||||
);
|
||||
|
||||
// 确定来源
|
||||
let source: 'user' | 'project' | 'extension' = 'user';
|
||||
if (serverConfig.extensionName) {
|
||||
source = 'extension';
|
||||
}
|
||||
// TODO: 区分user和project来源需要更详细的配置信息
|
||||
|
||||
serverInfos.push({
|
||||
name,
|
||||
status,
|
||||
source,
|
||||
config: serverConfig,
|
||||
toolCount: serverTools.length,
|
||||
promptCount: serverPrompts.length,
|
||||
});
|
||||
}
|
||||
|
||||
setServers(serverInfos);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadServers();
|
||||
}, [config]);
|
||||
|
||||
// 选中的服务器
|
||||
const selectedServer = useMemo(() => {
|
||||
if (selectedServerIndex >= 0 && selectedServerIndex < servers.length) {
|
||||
return servers[selectedServerIndex];
|
||||
}
|
||||
return null;
|
||||
}, [servers, selectedServerIndex]);
|
||||
|
||||
// 当前步骤
|
||||
const getCurrentStep = useCallback(
|
||||
() =>
|
||||
navigationStack[navigationStack.length - 1] ||
|
||||
MCP_MANAGEMENT_STEPS.SERVER_LIST,
|
||||
[navigationStack],
|
||||
);
|
||||
|
||||
// 导航处理
|
||||
const handleNavigateToStep = useCallback((step: string) => {
|
||||
setNavigationStack((prev) => [...prev, step]);
|
||||
}, []);
|
||||
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
setNavigationStack((prev) => {
|
||||
if (prev.length <= 1) return prev;
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 选择服务器
|
||||
const handleSelectServer = useCallback(
|
||||
(index: number) => {
|
||||
setSelectedServerIndex(index);
|
||||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL);
|
||||
},
|
||||
[handleNavigateToStep],
|
||||
);
|
||||
|
||||
// 获取服务器工具列表
|
||||
const getServerTools = useCallback((): MCPToolDisplayInfo[] => {
|
||||
if (!config || !selectedServer) return [];
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) return [];
|
||||
|
||||
const allTools: AnyDeclarativeTool[] = toolRegistry.getAllTools();
|
||||
const mcpTools: DiscoveredMCPTool[] = [];
|
||||
for (const tool of allTools) {
|
||||
if (
|
||||
tool instanceof DiscoveredMCPTool &&
|
||||
tool.serverName === selectedServer.name
|
||||
) {
|
||||
mcpTools.push(tool);
|
||||
}
|
||||
}
|
||||
return mcpTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
serverName: tool.serverName,
|
||||
schema: tool.parameterSchema as object | undefined,
|
||||
}));
|
||||
}, [config, selectedServer]);
|
||||
|
||||
// 查看工具列表
|
||||
const handleViewTools = useCallback(() => {
|
||||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
|
||||
}, [handleNavigateToStep]);
|
||||
|
||||
// 选择工具
|
||||
const handleSelectTool = useCallback(
|
||||
(tool: MCPToolDisplayInfo) => {
|
||||
setSelectedTool(tool);
|
||||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL);
|
||||
},
|
||||
[handleNavigateToStep],
|
||||
);
|
||||
|
||||
// 重新连接服务器
|
||||
const handleReconnect = useCallback(async () => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
||||
try {
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (toolRegistry) {
|
||||
await toolRegistry.discoverToolsForServer(selectedServer.name);
|
||||
}
|
||||
// 重新加载服务器数据以更新状态
|
||||
const loadServers = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const promptRegistry = await config.getPromptRegistry();
|
||||
|
||||
const serverInfos: MCPServerDisplayInfo[] = [];
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(
|
||||
mcpServers,
|
||||
) as Array<[string, MCPServerConfig]>) {
|
||||
const status = getMCPServerStatus(name);
|
||||
|
||||
const allTools: AnyDeclarativeTool[] =
|
||||
toolRegistry?.getAllTools() || [];
|
||||
const serverTools = allTools.filter(
|
||||
(t): t is DiscoveredMCPTool =>
|
||||
t instanceof DiscoveredMCPTool && t.serverName === name,
|
||||
);
|
||||
|
||||
const allPrompts: DiscoveredMCPPrompt[] =
|
||||
promptRegistry?.getAllPrompts() || [];
|
||||
const serverPrompts = allPrompts.filter(
|
||||
(p) => 'serverName' in p && p.serverName === name,
|
||||
);
|
||||
|
||||
let source: 'user' | 'project' | 'extension' = 'user';
|
||||
if (serverConfig.extensionName) {
|
||||
source = 'extension';
|
||||
}
|
||||
|
||||
serverInfos.push({
|
||||
name,
|
||||
status,
|
||||
source,
|
||||
config: serverConfig,
|
||||
toolCount: serverTools.length,
|
||||
promptCount: serverPrompts.length,
|
||||
});
|
||||
}
|
||||
|
||||
setServers(serverInfos);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
await loadServers();
|
||||
} catch (_error) {
|
||||
// 错误处理 - 静默失败
|
||||
}
|
||||
}, [config, selectedServer]);
|
||||
|
||||
// 禁用服务器
|
||||
const handleDisable = useCallback(async () => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
||||
// TODO: 实现禁用服务器的逻辑
|
||||
// 这需要修改配置文件,暂时返回到服务器列表
|
||||
handleNavigateBack();
|
||||
}, [config, selectedServer, handleNavigateBack]);
|
||||
|
||||
// 渲染步骤头部
|
||||
const renderStepHeader = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
let headerText = '';
|
||||
|
||||
switch (currentStep) {
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
|
||||
headerText = t('Manage MCP servers');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
|
||||
headerText = selectedServer?.name || t('Server Detail');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
|
||||
headerText = t('Tools');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
|
||||
headerText = selectedTool?.name || t('Tool Detail');
|
||||
break;
|
||||
default:
|
||||
headerText = t('MCP Management');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text bold>{headerText}</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, selectedServer, selectedTool]);
|
||||
|
||||
// 渲染步骤内容
|
||||
const renderStepContent = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>{t('Loading...')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const currentStep = getCurrentStep();
|
||||
|
||||
switch (currentStep) {
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
|
||||
return (
|
||||
<ServerListStep servers={servers} onSelect={handleSelectServer} />
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
|
||||
return (
|
||||
<ServerDetailStep
|
||||
server={selectedServer}
|
||||
onViewTools={handleViewTools}
|
||||
onReconnect={handleReconnect}
|
||||
onDisable={handleDisable}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
|
||||
return (
|
||||
<ToolListStep
|
||||
tools={getServerTools()}
|
||||
serverName={selectedServer?.name || ''}
|
||||
onSelect={handleSelectTool}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
|
||||
return (
|
||||
<ToolDetailStep tool={selectedTool} onBack={handleNavigateBack} />
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('Unknown step')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
getCurrentStep,
|
||||
servers,
|
||||
selectedServer,
|
||||
selectedTool,
|
||||
handleSelectServer,
|
||||
handleViewTools,
|
||||
handleReconnect,
|
||||
handleDisable,
|
||||
handleNavigateBack,
|
||||
handleSelectTool,
|
||||
getServerTools,
|
||||
]);
|
||||
|
||||
// 渲染步骤底部
|
||||
const renderStepFooter = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
let footerText = '';
|
||||
|
||||
switch (currentStep) {
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
|
||||
if (servers.length === 0) {
|
||||
footerText = t('Esc to close');
|
||||
} else {
|
||||
footerText = t('↑↓ to navigate · Enter to select · Esc to close');
|
||||
}
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
|
||||
footerText = t('↑↓ to navigate · Enter to select · Esc to back');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
|
||||
footerText = t('↑↓ to navigate · Enter to select · Esc to back');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
|
||||
footerText = t('Esc to back');
|
||||
break;
|
||||
default:
|
||||
footerText = t('Esc to close');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>{footerText}</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, servers.length]);
|
||||
|
||||
// ESC键处理
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
const currentStep = getCurrentStep();
|
||||
if (currentStep === MCP_MANAGEMENT_STEPS.SERVER_LIST) {
|
||||
onClose();
|
||||
} else {
|
||||
handleNavigateBack();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
gap={1}
|
||||
>
|
||||
{renderStepHeader()}
|
||||
{renderStepContent()}
|
||||
{renderStepFooter()}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
37
packages/cli/src/ui/components/mcp/constants.ts
Normal file
37
packages/cli/src/ui/components/mcp/constants.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP管理相关常量
|
||||
*/
|
||||
|
||||
/**
|
||||
* 最大显示工具数量
|
||||
*/
|
||||
export const MAX_DISPLAY_TOOLS = 10;
|
||||
|
||||
/**
|
||||
* 最大显示prompt数量
|
||||
*/
|
||||
export const MAX_DISPLAY_PROMPTS = 10;
|
||||
|
||||
/**
|
||||
* 分组显示名称映射
|
||||
*/
|
||||
export const SOURCE_DISPLAY_NAMES: Record<string, string> = {
|
||||
user: 'User MCPs',
|
||||
project: 'Project MCPs',
|
||||
extension: 'Extension MCPs',
|
||||
};
|
||||
|
||||
/**
|
||||
* 状态显示文本
|
||||
*/
|
||||
export const STATUS_TEXT: Record<string, string> = {
|
||||
connected: 'connected',
|
||||
connecting: 'connecting',
|
||||
disconnected: 'failed',
|
||||
};
|
||||
30
packages/cli/src/ui/components/mcp/index.ts
Normal file
30
packages/cli/src/ui/components/mcp/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Main Dialog
|
||||
export { MCPManagementDialog } from './MCPManagementDialog.js';
|
||||
|
||||
// Steps
|
||||
export { ServerListStep } from './steps/ServerListStep.js';
|
||||
export { ServerDetailStep } from './steps/ServerDetailStep.js';
|
||||
export { ToolListStep } from './steps/ToolListStep.js';
|
||||
export { ToolDetailStep } from './steps/ToolDetailStep.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
MCPManagementDialogProps,
|
||||
MCPServerDisplayInfo,
|
||||
MCPToolDisplayInfo,
|
||||
MCPPromptDisplayInfo,
|
||||
ServerListStepProps,
|
||||
ServerDetailStepProps,
|
||||
ToolListStepProps,
|
||||
ToolDetailStepProps,
|
||||
MCPManagementStep,
|
||||
} from './types.js';
|
||||
|
||||
// Constants
|
||||
export { MCP_MANAGEMENT_STEPS } from './types.js';
|
||||
183
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
Normal file
183
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import type { ServerDetailStepProps } from '../types.js';
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusIcon,
|
||||
formatServerCommand,
|
||||
} from '../utils.js';
|
||||
|
||||
type ServerAction = 'view-tools' | 'reconnect' | 'disable';
|
||||
|
||||
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
||||
server,
|
||||
onViewTools,
|
||||
onReconnect,
|
||||
onDisable,
|
||||
onBack,
|
||||
}) => {
|
||||
const [selectedAction, setSelectedAction] =
|
||||
useState<ServerAction>('view-tools');
|
||||
|
||||
const statusColor = server ? getStatusColor(server.status) : 'gray';
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: 'view-tools',
|
||||
get label() {
|
||||
return t('View tools');
|
||||
},
|
||||
value: 'view-tools' as const,
|
||||
},
|
||||
{
|
||||
key: 'reconnect',
|
||||
get label() {
|
||||
return t('Reconnect');
|
||||
},
|
||||
value: 'reconnect' as const,
|
||||
},
|
||||
{
|
||||
key: 'disable',
|
||||
get label() {
|
||||
return t('Disable');
|
||||
},
|
||||
value: 'disable' as const,
|
||||
},
|
||||
];
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
} else if (key.name === 'return') {
|
||||
switch (selectedAction) {
|
||||
case 'view-tools':
|
||||
onViewTools();
|
||||
break;
|
||||
case 'reconnect':
|
||||
onReconnect?.();
|
||||
break;
|
||||
case 'disable':
|
||||
onDisable?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (!server) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No server selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* 服务器详情 */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{t('Status:')}</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text
|
||||
color={
|
||||
statusColor === 'green'
|
||||
? theme.status.success
|
||||
: statusColor === 'yellow'
|
||||
? theme.status.warning
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{getStatusIcon(server.status)} {t(server.status)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.primary}>{t('Command:')}</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="truncate">{formatServerCommand(server)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{server.config.cwd && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.primary}>{t('Working Directory:')}</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text wrap="truncate">{server.config.cwd}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.primary}>{t('Capabilities:')}</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text>
|
||||
{server.toolCount > 0 ? t('tools') : ''}
|
||||
{server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''}
|
||||
{server.promptCount > 0 ? t('prompts') : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.primary}>{t('Tools:')}</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text>
|
||||
{server.toolCount}{' '}
|
||||
{server.toolCount === 1 ? t('tool') : t('tools')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{server.errorMessage && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.error}>{t('Error:')}</Text>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.status.error} wrap="wrap">
|
||||
{server.errorMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 操作列表 */}
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect<ServerAction>
|
||||
items={actions}
|
||||
onHighlight={(value: ServerAction) => setSelectedAction(value)}
|
||||
onSelect={(value: ServerAction) => {
|
||||
switch (value) {
|
||||
case 'view-tools':
|
||||
onViewTools();
|
||||
break;
|
||||
case 'reconnect':
|
||||
onReconnect?.();
|
||||
break;
|
||||
case 'disable':
|
||||
onDisable?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
169
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx
Normal file
169
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import type { ServerListStepProps, MCPServerDisplayInfo } from '../types.js';
|
||||
import {
|
||||
groupServersBySource,
|
||||
getStatusIcon,
|
||||
getStatusColor,
|
||||
} from '../utils.js';
|
||||
|
||||
export const ServerListStep: React.FC<ServerListStepProps> = ({
|
||||
servers,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const groupedServers = useMemo(
|
||||
() => groupServersBySource(servers),
|
||||
[servers],
|
||||
);
|
||||
|
||||
// 计算扁平化的服务器列表用于导航
|
||||
const flatServers = useMemo(() => {
|
||||
const result: MCPServerDisplayInfo[] = [];
|
||||
for (const group of groupedServers) {
|
||||
result.push(...group.servers);
|
||||
}
|
||||
return result;
|
||||
}, [groupedServers]);
|
||||
|
||||
// 键盘导航
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'up') {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.name === 'down') {
|
||||
setSelectedIndex((prev) => Math.min(flatServers.length - 1, prev + 1));
|
||||
} else if (key.name === 'return') {
|
||||
onSelect(selectedIndex);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No MCP servers configured.')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Add MCP servers to your settings to get started.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 计算当前选中项在分组中的位置
|
||||
const getSelectionPosition = (globalIndex: number) => {
|
||||
let currentIndex = 0;
|
||||
for (const group of groupedServers) {
|
||||
if (globalIndex < currentIndex + group.servers.length) {
|
||||
return {
|
||||
groupIndex: groupedServers.indexOf(group),
|
||||
itemIndex: globalIndex - currentIndex,
|
||||
};
|
||||
}
|
||||
currentIndex += group.servers.length;
|
||||
}
|
||||
return { groupIndex: 0, itemIndex: 0 };
|
||||
};
|
||||
|
||||
const currentPosition = getSelectionPosition(selectedIndex);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* 服务器统计 */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{servers.length} {servers.length === 1 ? t('server') : t('servers')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 分组服务器列表 */}
|
||||
{groupedServers.map((group, groupIndex) => (
|
||||
<Box key={group.source} flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{group.displayName}
|
||||
{group.servers[0]?.configPath && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
({group.servers[0].configPath})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{group.servers.map((server, itemIndex) => {
|
||||
const isSelected =
|
||||
groupIndex === currentPosition.groupIndex &&
|
||||
itemIndex === currentPosition.itemIndex;
|
||||
const statusColor = getStatusColor(server.status);
|
||||
|
||||
return (
|
||||
<Box key={server.name}>
|
||||
<Box minWidth={2}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
wrap="truncate"
|
||||
>
|
||||
{server.name}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> · </Text>
|
||||
<Text
|
||||
color={
|
||||
statusColor === 'green'
|
||||
? theme.status.success
|
||||
: statusColor === 'yellow'
|
||||
? theme.status.warning
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{getStatusIcon(server.status)}
|
||||
</Text>
|
||||
<Text
|
||||
color={
|
||||
statusColor === 'green'
|
||||
? theme.status.success
|
||||
: statusColor === 'yellow'
|
||||
? theme.status.warning
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
{t(server.status)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* 提示信息 */}
|
||||
{servers.some((s) => s.status === 'disconnected') && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
※ {t('Run qwen --debug to see error logs')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
116
packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx
Normal file
116
packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import type { ToolDetailStepProps } from '../types.js';
|
||||
|
||||
export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
|
||||
tool,
|
||||
onBack,
|
||||
}) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (!tool) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No tool selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 格式化schema显示
|
||||
const formatSchema = (schema: object | undefined): string => {
|
||||
if (!schema) return t('No schema available');
|
||||
return JSON.stringify(schema, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* 工具名称 */}
|
||||
<Box>
|
||||
<Text bold>{tool.name}</Text>
|
||||
</Box>
|
||||
|
||||
{/* 工具描述 */}
|
||||
{tool.description && (
|
||||
<Box marginTop={1}>
|
||||
<Text wrap="wrap">{tool.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 工具注解 */}
|
||||
{tool.annotations && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>{t('Annotations:')}</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{tool.annotations.title && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Title')}: {tool.annotations.title}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.readOnlyHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Read Only')}:{' '}
|
||||
{tool.annotations.readOnlyHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.destructiveHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Destructive')}:{' '}
|
||||
{tool.annotations.destructiveHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.idempotentHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Idempotent')}:{' '}
|
||||
{tool.annotations.idempotentHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.openWorldHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Open World')}:{' '}
|
||||
{tool.annotations.openWorldHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Schema */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>{t('Schema:')}</Text>
|
||||
<Box
|
||||
marginLeft={2}
|
||||
marginTop={1}
|
||||
padding={1}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{formatSchema(tool.schema)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 所属服务器 */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Server')}: {tool.serverName}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
142
packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx
Normal file
142
packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import type { ToolListStepProps, MCPToolDisplayInfo } from '../types.js';
|
||||
|
||||
// 可视区域最大显示工具数量
|
||||
const VISIBLE_TOOLS_COUNT = 10;
|
||||
|
||||
export const ToolListStep: React.FC<ToolListStepProps> = ({
|
||||
tools,
|
||||
serverName,
|
||||
onSelect,
|
||||
onBack,
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// 计算可视区域的起始索引(滚动窗口)
|
||||
const scrollOffset = useMemo(() => {
|
||||
if (tools.length <= VISIBLE_TOOLS_COUNT) {
|
||||
return 0;
|
||||
}
|
||||
// 确保选中项在可视区域内
|
||||
if (selectedIndex < VISIBLE_TOOLS_COUNT - 1) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(
|
||||
selectedIndex - VISIBLE_TOOLS_COUNT + 1,
|
||||
tools.length - VISIBLE_TOOLS_COUNT,
|
||||
);
|
||||
}, [selectedIndex, tools.length]);
|
||||
|
||||
// 当前可视的工具列表
|
||||
const displayTools = useMemo(
|
||||
() => tools.slice(scrollOffset, scrollOffset + VISIBLE_TOOLS_COUNT),
|
||||
[tools, scrollOffset],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
} else if (key.name === 'up') {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.name === 'down') {
|
||||
setSelectedIndex((prev) => Math.min(tools.length - 1, prev + 1));
|
||||
} else if (key.name === 'return') {
|
||||
if (tools[selectedIndex]) {
|
||||
onSelect(tools[selectedIndex]);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (tools.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No tools available for this server.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const getToolAnnotations = (tool: MCPToolDisplayInfo): string => {
|
||||
const hints: string[] = [];
|
||||
if (tool.annotations?.destructiveHint) hints.push(t('destructive'));
|
||||
if (tool.annotations?.readOnlyHint) hints.push(t('read-only'));
|
||||
if (tool.annotations?.openWorldHint) hints.push(t('open-world'));
|
||||
if (tool.annotations?.idempotentHint) hints.push(t('idempotent'));
|
||||
return hints.join(', ');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* 标题 */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>{t('Tools for {{name}}', { name: serverName })}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
({tools.length} {tools.length === 1 ? t('tool') : t('tools')})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 工具列表 */}
|
||||
<Box flexDirection="column">
|
||||
{displayTools.map((tool, index) => {
|
||||
const actualIndex = scrollOffset + index;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
const annotations = getToolAnnotations(tool);
|
||||
|
||||
return (
|
||||
<Box key={tool.name}>
|
||||
<Box minWidth={4}>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{actualIndex + 1}.</Text>
|
||||
</Box>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
wrap="truncate"
|
||||
>
|
||||
{tool.name}
|
||||
</Text>
|
||||
{annotations && (
|
||||
<>
|
||||
<Text color={theme.text.secondary}> </Text>
|
||||
<Text color={theme.text.secondary}>{annotations}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* 滚动提示 */}
|
||||
{tools.length > VISIBLE_TOOLS_COUNT && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{scrollOffset > 0 ? '↑ ' : ' '}
|
||||
{t('{{current}}/{{total}}', {
|
||||
current: (scrollOffset + 1).toString(),
|
||||
total: tools.length.toString(),
|
||||
})}
|
||||
{scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
183
packages/cli/src/ui/components/mcp/types.ts
Normal file
183
packages/cli/src/ui/components/mcp/types.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
MCPServerStatus,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* MCP管理步骤定义
|
||||
*/
|
||||
export const MCP_MANAGEMENT_STEPS = {
|
||||
SERVER_LIST: 'server-list',
|
||||
SERVER_DETAIL: 'server-detail',
|
||||
TOOL_LIST: 'tool-list',
|
||||
TOOL_DETAIL: 'tool-detail',
|
||||
PROMPT_LIST: 'prompt-list',
|
||||
PROMPT_DETAIL: 'prompt-detail',
|
||||
} as const;
|
||||
|
||||
export type MCPManagementStep =
|
||||
(typeof MCP_MANAGEMENT_STEPS)[keyof typeof MCP_MANAGEMENT_STEPS];
|
||||
|
||||
/**
|
||||
* MCP服务器显示信息
|
||||
*/
|
||||
export interface MCPServerDisplayInfo {
|
||||
/** 服务器名称 */
|
||||
name: string;
|
||||
/** 连接状态 */
|
||||
status: MCPServerStatus;
|
||||
/** 来源 */
|
||||
source: 'user' | 'project' | 'extension';
|
||||
/** 配置文件路径 */
|
||||
configPath?: string;
|
||||
/** 服务器配置 */
|
||||
config: MCPServerConfig;
|
||||
/** 工具数量 */
|
||||
toolCount: number;
|
||||
/** Prompt数量 */
|
||||
promptCount: number;
|
||||
/** 错误信息 */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP工具显示信息
|
||||
*/
|
||||
export interface MCPToolDisplayInfo {
|
||||
/** 工具名称 */
|
||||
name: string;
|
||||
/** 工具描述 */
|
||||
description?: string;
|
||||
/** 所属服务器 */
|
||||
serverName: string;
|
||||
/** 工具schema */
|
||||
schema?: object;
|
||||
/** 工具注解 */
|
||||
annotations?: {
|
||||
title?: string;
|
||||
readOnlyHint?: boolean;
|
||||
destructiveHint?: boolean;
|
||||
idempotentHint?: boolean;
|
||||
openWorldHint?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Prompt显示信息
|
||||
*/
|
||||
export interface MCPPromptDisplayInfo {
|
||||
/** Prompt名称 */
|
||||
name: string;
|
||||
/** Prompt描述 */
|
||||
description?: string;
|
||||
/** 所属服务器 */
|
||||
serverName: string;
|
||||
/** 参数定义 */
|
||||
arguments?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组后的服务器列表
|
||||
*/
|
||||
export interface GroupedServers {
|
||||
/** 来源标识 */
|
||||
source: string;
|
||||
/** 来源显示名称 */
|
||||
displayName: string;
|
||||
/** 配置文件路径 */
|
||||
configPath?: string;
|
||||
/** 服务器列表 */
|
||||
servers: MCPServerDisplayInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ServerListStep组件属性
|
||||
*/
|
||||
export interface ServerListStepProps {
|
||||
/** 服务器列表 */
|
||||
servers: MCPServerDisplayInfo[];
|
||||
/** 选择回调 */
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServerDetailStep组件属性
|
||||
*/
|
||||
export interface ServerDetailStepProps {
|
||||
/** 选中的服务器 */
|
||||
server: MCPServerDisplayInfo | null;
|
||||
/** 查看工具列表回调 */
|
||||
onViewTools: () => void;
|
||||
/** 重新连接回调 */
|
||||
onReconnect?: () => void;
|
||||
/** 禁用服务器回调 */
|
||||
onDisable?: () => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolListStep组件属性
|
||||
*/
|
||||
export interface ToolListStepProps {
|
||||
/** 工具列表 */
|
||||
tools: MCPToolDisplayInfo[];
|
||||
/** 服务器名称 */
|
||||
serverName: string;
|
||||
/** 选择回调 */
|
||||
onSelect: (tool: MCPToolDisplayInfo) => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolDetailStep组件属性
|
||||
*/
|
||||
export interface ToolDetailStepProps {
|
||||
/** 工具信息 */
|
||||
tool: MCPToolDisplayInfo | null;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PromptListStep组件属性
|
||||
*/
|
||||
export interface PromptListStepProps {
|
||||
/** Prompt列表 */
|
||||
prompts: MCPPromptDisplayInfo[];
|
||||
/** 服务器名称 */
|
||||
serverName: string;
|
||||
/** 选择回调 */
|
||||
onSelect: (prompt: MCPPromptDisplayInfo) => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PromptDetailStep组件属性
|
||||
*/
|
||||
export interface PromptDetailStepProps {
|
||||
/** Prompt信息 */
|
||||
prompt: MCPPromptDisplayInfo | null;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP管理对话框属性
|
||||
*/
|
||||
export interface MCPManagementDialogProps {
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
103
packages/cli/src/ui/components/mcp/utils.ts
Normal file
103
packages/cli/src/ui/components/mcp/utils.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { MCPServerDisplayInfo, GroupedServers } from './types.js';
|
||||
import { SOURCE_DISPLAY_NAMES } from './constants.js';
|
||||
|
||||
/**
|
||||
* 按来源分组服务器
|
||||
*/
|
||||
export function groupServersBySource(
|
||||
servers: MCPServerDisplayInfo[],
|
||||
): GroupedServers[] {
|
||||
const groups = new Map<string, MCPServerDisplayInfo[]>();
|
||||
|
||||
for (const server of servers) {
|
||||
const existing = groups.get(server.source);
|
||||
if (existing) {
|
||||
existing.push(server);
|
||||
} else {
|
||||
groups.set(server.source, [server]);
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级排序: user > project > extension
|
||||
const sourceOrder = ['user', 'project', 'extension'];
|
||||
const result: GroupedServers[] = [];
|
||||
|
||||
for (const source of sourceOrder) {
|
||||
const servers = groups.get(source);
|
||||
if (servers && servers.length > 0) {
|
||||
result.push({
|
||||
source,
|
||||
displayName: SOURCE_DISPLAY_NAMES[source] || source,
|
||||
servers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态颜色
|
||||
*/
|
||||
export function getStatusColor(
|
||||
status: string,
|
||||
): 'green' | 'yellow' | 'red' | 'gray' {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'green';
|
||||
case 'connecting':
|
||||
return 'yellow';
|
||||
case 'disconnected':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态图标
|
||||
*/
|
||||
export function getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return '✓';
|
||||
case 'connecting':
|
||||
return '…';
|
||||
case 'disconnected':
|
||||
return '✗';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化服务器命令显示
|
||||
*/
|
||||
export function formatServerCommand(server: MCPServerDisplayInfo): string {
|
||||
const config = server.config;
|
||||
if (config.httpUrl) {
|
||||
return `${config.httpUrl} (http)`;
|
||||
}
|
||||
if (config.url) {
|
||||
return `${config.url} (sse)`;
|
||||
}
|
||||
if (config.command) {
|
||||
const args = config.args?.join(' ') || '';
|
||||
return `${config.command} ${args} (stdio)`.trim();
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue