feat: add quick install templates and index status to CLI hooks and home locales

feat: enhance MCP manager with interactive question feature and update locales

feat: implement tags and available models management in settings page

fix: improve process termination logic in stop command for React frontend

fix: update view command to default to 'js' frontend

feat: add Recommended MCP Wizard component for dynamic server configuration
This commit is contained in:
catlog22
2026-02-04 15:24:34 +08:00
parent 341331325c
commit 8454ae4f41
24 changed files with 1186 additions and 727 deletions

View File

@@ -13,6 +13,7 @@ import { Sparkline } from '@/components/charts/Sparkline';
import { useWorkflowStatusCounts, generateMockWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
import { useDashboardStats } from '@/hooks/useDashboardStats';
import { useProjectOverview } from '@/hooks/useProjectOverview';
import { useIndexStatus } from '@/hooks/useIndex';
import { cn } from '@/lib/utils';
import {
ListChecks,
@@ -37,6 +38,7 @@ import {
Sparkles,
BarChart3,
PieChart as PieChartIcon,
Database,
} from 'lucide-react';
export interface WorkflowTaskWidgetProps {
@@ -210,6 +212,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
const { data, isLoading } = useWorkflowStatusCounts();
const { stats, isLoading: statsLoading } = useDashboardStats({ refetchInterval: 60000 });
const { projectOverview, isLoading: projectLoading } = useProjectOverview();
const { status: indexStatus } = useIndexStatus({ refetchInterval: 30000 });
const chartData = data || generateMockWorkflowStatusCounts();
const total = chartData.reduce((sum, item) => sum + item.count, 0);
@@ -320,6 +323,35 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
<span className="font-semibold">{projectOverview?.developmentIndex?.enhancement?.length || 0}</span>
<span className="text-muted-foreground">{formatMessage({ id: 'projectOverview.devIndex.category.enhancements' })}</span>
</div>
{/* Index Status Indicator */}
<div className="flex items-center gap-2">
<div className="relative">
<Database className={cn(
"h-3.5 w-3.5",
indexStatus?.status === 'building' && "text-blue-600 animate-pulse",
indexStatus?.status === 'completed' && "text-emerald-600",
indexStatus?.status === 'idle' && "text-slate-500",
indexStatus?.status === 'failed' && "text-red-600"
)} />
{indexStatus?.status === 'building' && (
<span className="absolute -top-0.5 -right-0.5 flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
</span>
)}
</div>
<span className={cn(
"font-semibold",
indexStatus?.status === 'building' && "text-blue-600",
indexStatus?.status === 'completed' && "text-emerald-600",
indexStatus?.status === 'idle' && "text-slate-500",
indexStatus?.status === 'failed' && "text-red-600"
)}>
{indexStatus?.totalFiles || 0}
</span>
<span className="text-muted-foreground">{formatMessage({ id: 'home.indexStatus.label' })}</span>
</div>
</div>
{/* Date + Expand Button */}

View File

@@ -1,7 +1,7 @@
// ========================================
// Sidebar Component
// ========================================
// Collapsible navigation sidebar with 6-group accordion structure
// Collapsible navigation sidebar with 5-group accordion structure
import { useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
@@ -9,7 +9,6 @@ import {
Home,
FolderKanban,
Workflow,
RefreshCw,
AlertCircle,
Sparkles,
Terminal,
@@ -60,7 +59,7 @@ interface NavGroupDef {
}>;
}
// Define the 6 navigation groups with their items
// Define the 5 navigation groups with their items
const navGroupDefinitions: NavGroupDef[] = [
{
id: 'overview',
@@ -80,8 +79,8 @@ const navGroupDefinitions: NavGroupDef[] = [
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
{ path: '/coordinator', labelKey: 'navigation.main.coordinator', icon: GitFork },
{ path: '/loops', labelKey: 'navigation.main.loops', icon: RefreshCw },
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
],
},
{
@@ -93,14 +92,7 @@ const navGroupDefinitions: NavGroupDef[] = [
{ path: '/prompts', labelKey: 'navigation.main.prompts', icon: History },
{ path: '/skills', labelKey: 'navigation.main.skills', icon: Sparkles },
{ path: '/commands', labelKey: 'navigation.main.commands', icon: Terminal },
],
},
{
id: 'issues',
titleKey: 'navigation.groups.issues',
icon: AlertCircle,
items: [
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
{ path: '/settings/rules', labelKey: 'navigation.main.rules', icon: Shield },
],
},
{
@@ -109,6 +101,7 @@ const navGroupDefinitions: NavGroupDef[] = [
icon: Wrench,
items: [
{ path: '/hooks', labelKey: 'navigation.main.hooks', icon: GitFork },
{ path: '/settings/mcp', labelKey: 'navigation.main.mcp', icon: Server },
],
},
{
@@ -116,11 +109,9 @@ const navGroupDefinitions: NavGroupDef[] = [
titleKey: 'navigation.groups.configuration',
icon: Cog,
items: [
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings },
{ path: '/settings/mcp', labelKey: 'navigation.main.mcp', icon: Server },
{ path: '/settings/rules', labelKey: 'navigation.main.rules', icon: Shield },
{ path: '/settings/codexlens', labelKey: 'navigation.main.codexlens', icon: Sparkles },
{ path: '/api-settings', labelKey: 'navigation.main.apiSettings', icon: Server },
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings },
{ path: '/help', labelKey: 'navigation.main.help', icon: HelpCircle },
],
},

View File

@@ -15,6 +15,9 @@ import {
Database,
FileText,
HardDrive,
MessageCircleQuestion,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -83,6 +86,7 @@ export const CCW_MCP_TOOLS: CcwTool[] = [
{ name: 'edit_file', desc: 'Edit/replace content', core: true },
{ name: 'read_file', desc: 'Read file contents', core: true },
{ name: 'core_memory', desc: 'Core memory management', core: true },
{ name: 'ask_question', desc: 'Interactive questions (A2UI)', core: false },
];
// ========== Component ==========
@@ -211,13 +215,9 @@ export function CcwToolsMcpCard({
</div>
<div className="flex items-center gap-2">
{isExpanded ? (
<div className="w-5 h-5 flex items-center justify-center text-muted-foreground">
</div>
<ChevronDown className="w-5 h-5 text-muted-foreground" />
) : (
<div className="w-5 h-5 flex items-center justify-center text-muted-foreground">
</div>
<ChevronRight className="w-5 h-5 text-muted-foreground" />
)}
</div>
</div>
@@ -425,6 +425,8 @@ function getToolIcon(toolName: string): React.ReactElement {
return <Database {...iconProps} />;
case 'core_memory':
return <Settings {...iconProps} />;
case 'ask_question':
return <MessageCircleQuestion {...iconProps} />;
default:
return <Settings {...iconProps} />;
}

View File

@@ -162,56 +162,32 @@ export function ConfigTypeToggle({
return (
<>
<div className="space-y-3">
{/* Label */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.configType.label' })}
</span>
<Badge variant="outline" className="text-xs">
{getConfigFileExtension(internalType)}
</Badge>
</div>
{/* Toggle Buttons */}
<div className="flex gap-2 p-1 bg-muted rounded-lg">
<Button
variant={internalType === 'mcp-json' ? 'default' : 'ghost'}
size="sm"
onClick={() => handleTypeClick('mcp-json')}
className={cn(
'flex-1',
internalType === 'mcp-json' && 'shadow-sm'
)}
>
<span className="text-sm">
{formatMessage({ id: 'mcp.configType.claudeJson' })}
</span>
</Button>
<Button
variant={internalType === 'claude-json' ? 'default' : 'ghost'}
size="sm"
onClick={() => handleTypeClick('claude-json')}
className={cn(
'flex-1',
internalType === 'claude-json' && 'shadow-sm'
)}
>
<span className="text-sm">
{formatMessage({ id: 'mcp.configType.claudeJson' })}
</span>
</Button>
</div>
{/* Current Format Display */}
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md border border-border">
<code className="text-xs text-muted-foreground font-mono">
{getConfigFileExtension(internalType)}
</code>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.configType.' + internalType.replace('-', '') })}
</span>
</div>
{/* Compact inline toggle */}
<div className="flex items-center gap-1.5 p-0.5 bg-muted rounded-md h-9">
<button
type="button"
onClick={() => handleTypeClick('mcp-json')}
className={cn(
'px-2.5 py-1 text-xs font-medium rounded transition-all',
internalType === 'mcp-json'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
.mcp.json
</button>
<button
type="button"
onClick={() => handleTypeClick('claude-json')}
className={cn(
'px-2.5 py-1 text-xs font-medium rounded transition-all',
internalType === 'claude-json'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
.claude.json
</button>
</div>
{/* Warning Dialog */}

View File

@@ -31,6 +31,7 @@ import {
} from '@/lib/api';
import { mcpServersKeys, useMcpTemplates } from '@/hooks';
import { cn } from '@/lib/utils';
import { ConfigTypeToggle, type McpConfigType } from './ConfigTypeToggle';
// ========== Types ==========
@@ -90,6 +91,7 @@ export function McpServerDialog({
const [errors, setErrors] = useState<FormErrors>({});
const [argsInput, setArgsInput] = useState('');
const [envInput, setEnvInput] = useState('');
const [configType, setConfigType] = useState<McpConfigType>('mcp-json');
// Initialize form from server prop (edit mode)
useEffect(() => {
@@ -458,6 +460,20 @@ export function McpServerDialog({
</span>
</label>
</div>
{/* Config Type Toggle - Only for project scope */}
{formData.scope === 'project' && (
<div className="flex items-center gap-2 mt-2 pl-6">
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.configType.format' })}:
</span>
<ConfigTypeToggle
currentType={configType}
onTypeChange={setConfigType}
showWarning={false}
/>
</div>
)}
</div>
{/* Enabled */}

View File

@@ -1,52 +1,30 @@
// ========================================
// Recommended MCP Section Component
// ========================================
// Display recommended MCP servers with one-click install functionality
// Display recommended MCP servers with wizard-based install functionality
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Search,
Globe,
Sparkles,
Download,
Check,
Loader2,
Settings,
Key,
Zap,
Code2,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import {
createMcpServer,
fetchMcpServers,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import { RecommendedMcpWizard, RecommendedMcpDefinition } from './RecommendedMcpWizard';
import { fetchMcpConfig } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Types ==========
/**
* Recommended server configuration
*/
export interface RecommendedServer {
id: string;
name: string;
description: string;
command: string;
args: string[];
icon: React.ComponentType<{ className?: string }>;
category: 'search' | 'browser' | 'ai';
}
/**
* Props for RecommendedMcpSection component
*/
@@ -55,61 +33,135 @@ export interface RecommendedMcpSectionProps {
onInstallComplete?: () => void;
}
interface RecommendedServerCardProps {
server: RecommendedServer;
isInstalled: boolean;
isInstalling: boolean;
onInstall: (server: RecommendedServer) => void;
}
// ========== Constants ==========
// ========== Platform Detection ==========
const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
/**
* Pre-configured recommended MCP servers
* Build cross-platform MCP config
* On Windows, wraps npx/node/python commands with cmd /c for proper execution
*/
const RECOMMENDED_SERVERS: RecommendedServer[] = [
function buildCrossPlatformMcpConfig(
command: string,
args: string[] = [],
options: { env?: Record<string, string>; type?: string } = {}
) {
const { env, type } = options;
const windowsWrappedCommands = ['npx', 'npm', 'node', 'python', 'python3', 'pip', 'pip3', 'pnpm', 'yarn', 'bun'];
const needsWindowsWrapper = isWindows && windowsWrappedCommands.includes(command.toLowerCase());
const config: { command: string; args: string[]; env?: Record<string, string>; type?: string } = needsWindowsWrapper
? { command: 'cmd', args: ['/c', command, ...args] }
: { command, args };
if (type) config.type = type;
if (env && Object.keys(env).length > 0) config.env = env;
return config;
}
// ========== Recommended MCP Definitions ==========
/**
* Pre-configured recommended MCP servers with field definitions
* Matches original JS version structure for full wizard support
*/
const RECOMMENDED_MCP_DEFINITIONS: RecommendedMcpDefinition[] = [
{
id: 'ace-tool',
name: 'ACE Tool',
description: 'Advanced code search and context engine for intelligent code discovery',
command: 'mcp__ace-tool__search_context',
args: [],
icon: Search,
nameKey: 'mcp.ace-tool.name',
descKey: 'mcp.ace-tool.desc',
icon: 'search-code',
category: 'search',
fields: [
{
key: 'baseUrl',
labelKey: 'mcp.ace-tool.field.baseUrl',
type: 'text',
default: 'https://acemcp.heroman.wtf/relay/',
placeholder: 'https://acemcp.heroman.wtf/relay/',
required: true,
descKey: 'mcp.ace-tool.field.baseUrl.desc',
},
{
key: 'token',
labelKey: 'mcp.ace-tool.field.token',
type: 'password',
default: '',
placeholder: 'ace_xxxxxxxxxxxxxxxx',
required: true,
descKey: 'mcp.ace-tool.field.token.desc',
},
],
buildConfig: (values) => buildCrossPlatformMcpConfig('npx', [
'ace-tool',
'--base-url',
values.baseUrl || 'https://acemcp.heroman.wtf/relay/',
'--token',
values.token,
]),
},
{
id: 'chrome-devtools',
name: 'Chrome DevTools',
description: 'Browser automation and debugging tools for web development',
command: 'mcp__chrome-devtools',
args: [],
icon: Globe,
nameKey: 'mcp.chrome-devtools.name',
descKey: 'mcp.chrome-devtools.desc',
icon: 'chrome',
category: 'browser',
fields: [],
buildConfig: () => buildCrossPlatformMcpConfig('npx', ['chrome-devtools-mcp@latest'], { type: 'stdio' }),
},
{
id: 'exa-search',
name: 'Exa Search',
description: 'AI-powered web search with real-time crawling capabilities',
command: 'mcp__exa__search',
args: [],
icon: Sparkles,
category: 'ai',
id: 'exa',
nameKey: 'mcp.exa.name',
descKey: 'mcp.exa.desc',
icon: 'globe-2',
category: 'search',
fields: [
{
key: 'apiKey',
labelKey: 'mcp.exa.field.apiKey',
type: 'password',
default: '',
placeholder: 'your-exa-api-key',
required: false,
descKey: 'mcp.exa.field.apiKey.desc',
},
],
buildConfig: (values) => {
const env = values.apiKey ? { EXA_API_KEY: values.apiKey } : undefined;
return buildCrossPlatformMcpConfig('npx', ['-y', 'exa-mcp-server'], { env });
},
},
];
// ========== Icon Map ==========
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
'search-code': Search,
'chrome': Globe,
'globe-2': Sparkles,
'code-2': Code2,
};
// ========== Helper Component ==========
interface RecommendedServerCardProps {
definition: RecommendedMcpDefinition;
isInstalled: boolean;
onInstall: (definition: RecommendedMcpDefinition) => void;
}
/**
* Individual recommended server card
*/
function RecommendedServerCard({
server,
definition,
isInstalled,
isInstalling,
onInstall,
}: RecommendedServerCardProps) {
const { formatMessage } = useIntl();
const Icon = server.icon;
const Icon = ICON_MAP[definition.icon] || Settings;
const hasFields = definition.fields.length > 0;
return (
<Card className="p-4 hover:shadow-md transition-shadow">
@@ -129,7 +181,7 @@ function RecommendedServerCard({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium text-foreground truncate">
{server.name}
{formatMessage({ id: definition.nameKey })}
</h4>
{isInstalled && (
<Badge variant="default" className="text-xs">
@@ -138,39 +190,40 @@ function RecommendedServerCard({
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">
{server.description}
{formatMessage({ id: definition.descKey })}
</p>
{/* Install Button */}
{!isInstalled && (
{/* Config info + Install */}
<div className="flex items-center justify-between">
{hasFields ? (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Key className="w-3 h-3" />
{definition.fields.length} {formatMessage({ id: 'mcp.configRequired' })}
</span>
) : (
<span className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<Zap className="w-3 h-3" />
{formatMessage({ id: 'mcp.noConfigNeeded' })}
</span>
)}
<Button
variant="outline"
variant={isInstalled ? 'outline' : 'default'}
size="sm"
onClick={() => onInstall(server)}
disabled={isInstalling}
className="w-full"
onClick={() => onInstall(definition)}
>
{isInstalling ? (
{isInstalled ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{formatMessage({ id: 'mcp.recommended.actions.installing' })}
<Settings className="w-3.5 h-3.5 mr-1" />
{formatMessage({ id: 'mcp.reconfigure' })}
</>
) : (
<>
<Download className="w-4 h-4 mr-1" />
<Download className="w-3.5 h-3.5 mr-1" />
{formatMessage({ id: 'mcp.recommended.actions.install' })}
</>
)}
</Button>
)}
{/* Installed Indicator */}
{isInstalled && (
<div className="flex items-center gap-1 text-xs text-primary">
<Check className="w-4 h-4" />
<span>{formatMessage({ id: 'mcp.recommended.actions.installed' })}</span>
</div>
)}
</div>
</div>
</div>
</Card>
@@ -180,88 +233,64 @@ function RecommendedServerCard({
// ========== Main Component ==========
/**
* Recommended MCP servers section with one-click install
* Recommended MCP servers section with wizard-based install
*/
export function RecommendedMcpSection({
onInstallComplete,
}: RecommendedMcpSectionProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const { success, error } = useNotifications();
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [selectedServer, setSelectedServer] = useState<RecommendedServer | null>(null);
const [installingServerId, setInstallingServerId] = useState<string | null>(null);
const [wizardOpen, setWizardOpen] = useState(false);
const [selectedDefinition, setSelectedDefinition] = useState<RecommendedMcpDefinition | null>(null);
const [installedServerIds, setInstalledServerIds] = useState<Set<string>>(new Set());
// Check which servers are already installed
const checkInstalledServers = async () => {
try {
const data = await fetchMcpServers();
const allServers = [...data.project, ...data.global];
const installedIds = new Set(
allServers
.filter(s => s.command.startsWith('mcp__'))
.map(s => s.command)
);
const data = await fetchMcpConfig();
const installedIds = new Set<string>();
const globalServers = data.globalServers || {};
const userServers = data.userServers || {};
for (const name of Object.keys(globalServers)) installedIds.add(name);
for (const name of Object.keys(userServers)) installedIds.add(name);
const projects = data.projects || {};
for (const proj of Object.values(projects)) {
const servers = (proj as any).mcpServers || {};
for (const name of Object.keys(servers)) installedIds.add(name);
}
if ((data as any).codex?.servers) {
for (const name of Object.keys((data as any).codex.servers)) installedIds.add(name);
}
setInstalledServerIds(installedIds);
} catch {
// Ignore errors during check
}
};
// Check on mount
useState(() => {
useEffect(() => {
checkInstalledServers();
});
}, []);
// Create server mutation
const createMutation = useMutation({
mutationFn: (server: Omit<RecommendedServer, 'id' | 'icon' | 'category'>) =>
createMcpServer({
command: server.command,
args: server.args,
scope: 'global',
enabled: true,
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
setInstalledServerIds(prev => new Set(prev).add(variables.command));
setInstallingServerId(null);
setConfirmDialogOpen(false);
setSelectedServer(null);
success(
formatMessage({ id: 'mcp.recommended.actions.installed' }),
formatMessage({ id: 'mcp.recommended.servers.' + selectedServer?.id + '.name' })
);
onInstallComplete?.();
},
onError: () => {
setInstallingServerId(null);
error(
formatMessage({ id: 'mcp.dialog.validation.nameRequired' }),
formatMessage({ id: 'mcp.dialog.validation.commandRequired' })
);
},
});
// Handle install click
const handleInstallClick = (server: RecommendedServer) => {
setSelectedServer(server);
setConfirmDialogOpen(true);
// Handle install click - open wizard
const handleInstallClick = (definition: RecommendedMcpDefinition) => {
setSelectedDefinition(definition);
setWizardOpen(true);
};
// Handle confirm install
const handleConfirmInstall = () => {
if (!selectedServer) return;
setInstallingServerId(selectedServer.id);
setConfirmDialogOpen(false);
createMutation.mutate(selectedServer);
// Handle wizard close
const handleWizardClose = () => {
setWizardOpen(false);
setSelectedDefinition(null);
};
// Check if server is installed
const isServerInstalled = (server: RecommendedServer) => {
return installedServerIds.has(server.command);
// Handle install complete
const handleInstallComplete = () => {
checkInstalledServers();
onInstallComplete?.();
};
return (
@@ -279,66 +308,24 @@ export function RecommendedMcpSection({
{/* Server Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{RECOMMENDED_SERVERS.map((server) => (
{RECOMMENDED_MCP_DEFINITIONS.map((definition) => (
<RecommendedServerCard
key={server.id}
server={server}
isInstalled={isServerInstalled(server)}
isInstalling={installingServerId === server.id}
key={definition.id}
definition={definition}
isInstalled={installedServerIds.has(definition.id)}
onInstall={handleInstallClick}
/>
))}
</div>
</section>
{/* Confirmation Dialog */}
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'mcp.recommended.actions.install' })} {selectedServer?.name}
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
{formatMessage(
{ id: 'mcp.recommended.description' },
{ server: selectedServer?.name }
)}
</p>
<div className="mt-4 p-3 bg-muted rounded-lg">
<code className="text-xs font-mono">
{selectedServer?.command} {selectedServer?.args.join(' ')}
</code>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDialogOpen(false)}
disabled={createMutation.isPending}
>
{formatMessage({ id: 'mcp.dialog.actions.cancel' })}
</Button>
<Button
onClick={handleConfirmInstall}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{formatMessage({ id: 'mcp.recommended.actions.installing' })}
</>
) : (
<>
<Download className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.recommended.actions.install' })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Wizard Dialog */}
<RecommendedMcpWizard
open={wizardOpen}
onClose={handleWizardClose}
mcpDefinition={selectedDefinition}
onInstallComplete={handleInstallComplete}
/>
</>
);
}

View File

@@ -0,0 +1,363 @@
// ========================================
// Recommended MCP Wizard Component
// ========================================
// Dynamic configuration wizard for recommended MCP servers
// Supports text, password, and multi-select field types
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Loader2, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Badge } from '@/components/ui/Badge';
import {
addGlobalMcpServer,
addProjectMcpServer,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import { cn } from '@/lib/utils';
// ========== Types ==========
/**
* Field definition for wizard
*/
export interface WizardField {
key: string;
labelKey: string;
descKey?: string;
type: 'text' | 'password' | 'multi-select';
default?: string | string[];
placeholder?: string;
required?: boolean;
options?: Array<{
value: string;
label: string;
desc?: string;
}>;
}
/**
* Recommended MCP server definition
*/
export interface RecommendedMcpDefinition {
id: string;
nameKey: string;
descKey: string;
icon: string;
category: string;
fields: WizardField[];
buildConfig: (values: Record<string, any>) => {
command: string;
args: string[];
env?: Record<string, string>;
type?: string;
};
}
/**
* Props for RecommendedMcpWizard component
*/
export interface RecommendedMcpWizardProps {
open: boolean;
onClose: () => void;
mcpDefinition: RecommendedMcpDefinition | null;
onInstallComplete?: () => void;
}
// ========== Main Component ==========
/**
* Wizard for installing recommended MCP servers with configuration
*/
export function RecommendedMcpWizard({
open,
onClose,
mcpDefinition,
onInstallComplete,
}: RecommendedMcpWizardProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const { success: showSuccess, error: showError } = useNotifications();
// State for field values
const [fieldValues, setFieldValues] = useState<Record<string, any>>({});
const [selectedScope, setSelectedScope] = useState<'project' | 'global'>('global');
// Initialize field values when dialog opens
const initializeFieldValues = () => {
if (!mcpDefinition) return;
const initialValues: Record<string, any> = {};
for (const field of mcpDefinition.fields) {
if (field.default !== undefined) {
initialValues[field.key] = field.default;
} else if (field.type === 'multi-select') {
initialValues[field.key] = [];
} else {
initialValues[field.key] = '';
}
}
setFieldValues(initialValues);
};
// Reset on open/close
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && mcpDefinition) {
initializeFieldValues();
} else {
setFieldValues({});
onClose();
}
};
// Install mutation
const installMutation = useMutation({
mutationFn: async () => {
if (!mcpDefinition) throw new Error('No MCP definition');
const serverConfig = mcpDefinition.buildConfig(fieldValues);
if (selectedScope === 'global') {
return addGlobalMcpServer(mcpDefinition.id, serverConfig);
} else {
return addProjectMcpServer(mcpDefinition.id, serverConfig);
}
},
onSuccess: (result) => {
if (result.success) {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
showSuccess(
formatMessage({ id: 'mcp.wizard.installSuccess' }),
formatMessage({ id: mcpDefinition!.nameKey })
);
handleOpenChange(false);
onInstallComplete?.();
} else {
showError(
formatMessage({ id: 'mcp.wizard.installError' }),
result.error || 'Unknown error'
);
}
},
onError: (err: Error) => {
showError(
formatMessage({ id: 'mcp.wizard.installError' }),
err.message
);
},
});
// Handle field value change
const handleFieldChange = (key: string, value: any) => {
setFieldValues(prev => ({
...prev,
[key]: value,
}));
};
// Handle multi-select toggle
const handleMultiSelectToggle = (key: string, value: string) => {
const current = fieldValues[key] || [];
const newValue = current.includes(value)
? current.filter((v: string) => v !== value)
: [...current, value];
handleFieldChange(key, newValue);
};
// Validate required fields
const validateFields = (): boolean => {
if (!mcpDefinition) return false;
for (const field of mcpDefinition.fields) {
if (field.required) {
const value = fieldValues[field.key];
if (field.type === 'multi-select') {
if (!value || value.length === 0) return false;
} else {
if (!value || value.trim() === '') return false;
}
}
}
return true;
};
// Handle submit
const handleSubmit = () => {
if (!validateFields()) {
showError(
formatMessage({ id: 'mcp.wizard.validation' }),
formatMessage({ id: 'mcp.wizard.requiredFields' })
);
return;
}
installMutation.mutate();
};
if (!mcpDefinition) return null;
const hasFields = mcpDefinition.fields.length > 0;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
{/* Header */}
<DialogHeader>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<i className={cn('lucide w-5 h-5 text-primary', `lucide-${mcpDefinition.icon}`)} />
</div>
<div>
<DialogTitle>
{formatMessage({ id: 'mcp.wizard.install' })} {formatMessage({ id: mcpDefinition.nameKey })}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: mcpDefinition.descKey })}
</p>
</div>
</div>
</DialogHeader>
{/* Content */}
<div className="space-y-4 py-4">
{/* Fields */}
{hasFields && (
<div className="space-y-3">
{mcpDefinition.fields.map((field) => (
<div key={field.key} className="space-y-1.5">
<Label className="flex items-center gap-1.5 text-sm font-medium">
{formatMessage({ id: field.labelKey })}
{field.required && <span className="text-destructive">*</span>}
</Label>
{field.descKey && (
<p className="text-xs text-muted-foreground">
{formatMessage({ id: field.descKey })}
</p>
)}
{/* Text/Password Input */}
{(field.type === 'text' || field.type === 'password') && (
<Input
type={field.type}
value={fieldValues[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
placeholder={field.placeholder}
className="font-mono text-sm"
/>
)}
{/* Multi-Select */}
{field.type === 'multi-select' && field.options && (
<div className="space-y-2 p-2 bg-muted/30 border border-border rounded-lg max-h-48 overflow-y-auto">
{field.options.map((option) => {
const isSelected = (fieldValues[field.key] || []).includes(option.value);
return (
<div
key={option.value}
className={cn(
'flex items-start gap-2 p-2 rounded transition-colors cursor-pointer',
isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'
)}
onClick={() => handleMultiSelectToggle(field.key, option.value)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="mt-0.5 w-4 h-4"
/>
<div className="flex-1">
<div className="text-sm font-medium text-foreground">
{option.label}
</div>
{option.desc && (
<div className="text-xs text-muted-foreground">
{option.desc}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
))}
</div>
)}
{/* Scope Selection */}
<div className="space-y-2 pt-3 border-t border-border">
<Label className="text-sm font-medium">
{formatMessage({ id: 'mcp.wizard.scope' })}
</Label>
<div className="flex gap-2">
<Button
variant={selectedScope === 'global' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedScope('global')}
className="flex-1"
>
{formatMessage({ id: 'mcp.wizard.scope.global' })}
</Button>
<Button
variant={selectedScope === 'project' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedScope('project')}
className="flex-1"
>
{formatMessage({ id: 'mcp.wizard.scope.project' })}
</Button>
</div>
</div>
{/* No Configuration Needed Message */}
{!hasFields && (
<div className="p-3 bg-success/10 border border-success/20 rounded-lg text-sm text-success">
{formatMessage({ id: 'mcp.noConfigNeeded' })}
</div>
)}
</div>
{/* Footer */}
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={installMutation.isPending}
>
{formatMessage({ id: 'mcp.dialog.actions.cancel' })}
</Button>
<Button
onClick={handleSubmit}
disabled={installMutation.isPending}
>
{installMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{formatMessage({ id: 'mcp.wizard.installing' })}
</>
) : (
<>
<Download className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.wizard.install' })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default RecommendedMcpWizard;