mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
feat: add CLI Command Node and Prompt Node components for orchestrator
- Implemented CliCommandNode component for executing CLI tools with AI models. - Implemented PromptNode component for constructing AI prompts with context. - Added styling for mode and tool badges in both components. - Enhanced user experience with command and argument previews, execution status, and error handling. test: add comprehensive tests for ask_question tool - Created direct test for ask_question tool execution. - Developed end-to-end tests to validate ask_question tool integration with WebSocket and A2UI surfaces. - Implemented simple and integrated WebSocket tests to ensure proper message handling and surface reception. - Added tool registration test to verify ask_question tool is correctly registered. chore: add WebSocket listener and simulation tests - Added WebSocket listener for A2UI surfaces to facilitate testing. - Implemented frontend simulation test to validate complete flow from backend to frontend. - Created various test scripts to ensure robust testing of ask_question tool functionality.
This commit is contained in:
@@ -69,8 +69,9 @@ export function QueueActions({
|
||||
const [mergeTargetId, setMergeTargetId] = useState('');
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
|
||||
|
||||
// Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key
|
||||
const queueId = (queue.tasks?.join(',') || queue.solutions?.join(',') || 'default');
|
||||
// Use "current" as the queue ID for single-queue model
|
||||
// This matches the API pattern where deactivate works on the current queue
|
||||
const queueId = 'current';
|
||||
|
||||
// Get all items from grouped_items for split dialog
|
||||
const allItems: QueueItem[] = Object.values(queue.grouped_items || {}).flat();
|
||||
|
||||
@@ -51,8 +51,8 @@ export function QueueCard({
|
||||
}: QueueCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key
|
||||
const queueId = (queue.tasks || []).join(',') || (queue.solutions || []).join(',') || 'unknown';
|
||||
// Use "current" for queue ID display
|
||||
const queueId = 'current';
|
||||
|
||||
// Calculate item counts
|
||||
const taskCount = queue.tasks?.length || 0;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { NotificationPanel } from '@/components/notification';
|
||||
import { AskQuestionDialog } from '@/components/a2ui/AskQuestionDialog';
|
||||
import { useNotificationStore, selectCurrentQuestion } from '@/stores';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
import { useWebSocketNotifications } from '@/hooks';
|
||||
import { useWebSocketNotifications, useWebSocket } from '@/hooks';
|
||||
|
||||
export interface AppShellProps {
|
||||
/** Initial sidebar collapsed state */
|
||||
@@ -103,7 +103,8 @@ export function AppShell({
|
||||
const currentQuestion = useNotificationStore(selectCurrentQuestion);
|
||||
const setCurrentQuestion = useNotificationStore((state) => state.setCurrentQuestion);
|
||||
|
||||
// Initialize WebSocket notifications handler
|
||||
// Initialize WebSocket connection and notifications handler
|
||||
useWebSocket();
|
||||
useWebSocketNotifications();
|
||||
|
||||
// Load persistent notifications from localStorage on mount
|
||||
|
||||
@@ -117,6 +117,7 @@ const navGroupDefinitions: NavGroupDef[] = [
|
||||
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 },
|
||||
|
||||
267
ccw/frontend/src/components/mcp/AllProjectsTable.tsx
Normal file
267
ccw/frontend/src/components/mcp/AllProjectsTable.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
// ========================================
|
||||
// All Projects Table Component
|
||||
// ========================================
|
||||
// Table component displaying all recent projects with MCP server statistics
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Folder, Clock, Database, ExternalLink } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useProjectOperations } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface ProjectServerStats {
|
||||
name: string;
|
||||
path: string;
|
||||
serverCount: number;
|
||||
enabledCount: number;
|
||||
lastModified?: string;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
export interface AllProjectsTableProps {
|
||||
/** Callback when a project is clicked */
|
||||
onProjectClick?: (projectPath: string) => void;
|
||||
/** Callback when open in new window is clicked */
|
||||
onOpenNewWindow?: (projectPath: string) => void;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
/** Maximum number of projects to display */
|
||||
maxProjects?: number;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function AllProjectsTable({
|
||||
onProjectClick,
|
||||
onOpenNewWindow,
|
||||
className,
|
||||
maxProjects,
|
||||
}: AllProjectsTableProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [sortField, setSortField] = useState<'name' | 'serverCount' | 'lastModified'>('lastModified');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const { projects, currentProject, isLoading } = useProjectOperations();
|
||||
|
||||
// Mock server counts since backend doesn't provide per-project stats
|
||||
// In production, this would come from a dedicated API endpoint
|
||||
const projectStats: ProjectServerStats[] = projects.slice(0, maxProjects).map((path) => {
|
||||
const isCurrent = path === currentProject;
|
||||
// Extract name from path (last segment)
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || path;
|
||||
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
serverCount: Math.floor(Math.random() * 10), // Mock data
|
||||
enabledCount: Math.floor(Math.random() * 8), // Mock data
|
||||
lastModified: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
isCurrent,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort projects
|
||||
const sortedProjects = [...projectStats].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'serverCount':
|
||||
comparison = a.serverCount - b.serverCount;
|
||||
break;
|
||||
case 'lastModified':
|
||||
comparison = a.lastModified && b.lastModified
|
||||
? new Date(a.lastModified).getTime() - new Date(b.lastModified).getTime()
|
||||
: 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
// Handle sort
|
||||
const handleSort = (field: typeof sortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle project click
|
||||
const handleProjectClick = (projectPath: string) => {
|
||||
onProjectClick?.(projectPath);
|
||||
};
|
||||
|
||||
// Handle open in new window
|
||||
const handleOpenNewWindow = (e: React.MouseEvent, projectPath: string) => {
|
||||
e.stopPropagation();
|
||||
onOpenNewWindow?.(projectPath);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn('p-8', className)}>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin text-muted-foreground">-</div>
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'common.actions.loading' })}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (sortedProjects.length === 0) {
|
||||
return (
|
||||
<Card className={cn('p-8', className)}>
|
||||
<div className="text-center">
|
||||
<Folder className="w-12 h-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.allProjects.empty' })}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('overflow-hidden', className)}>
|
||||
{/* Table Header */}
|
||||
<div className="grid grid-cols-12 gap-2 px-4 py-3 bg-muted/50 border-b border-border text-xs font-medium text-muted-foreground uppercase">
|
||||
<div className="col-span-4">
|
||||
<button
|
||||
onClick={() => handleSort('name')}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
{formatMessage({ id: 'mcp.allProjects.name' })}
|
||||
{sortField === 'name' && (
|
||||
<span className="text-foreground">{sortDirection === 'asc' ? '↑' : '↓'}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<button
|
||||
onClick={() => handleSort('serverCount')}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
{formatMessage({ id: 'mcp.allProjects.servers' })}
|
||||
{sortField === 'serverCount' && (
|
||||
<span className="text-foreground">{sortDirection === 'asc' ? '↑' : '↓'}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<button
|
||||
onClick={() => handleSort('lastModified')}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
{formatMessage({ id: 'mcp.allProjects.lastModified' })}
|
||||
{sortField === 'lastModified' && (
|
||||
<span className="text-foreground">{sortDirection === 'asc' ? '↑' : '↓'}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
{formatMessage({ id: 'mcp.allProjects.actions' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Rows */}
|
||||
<div className="divide-y divide-border">
|
||||
{sortedProjects.map((project) => (
|
||||
<div
|
||||
key={project.path}
|
||||
onClick={() => handleProjectClick(project.path)}
|
||||
className={cn(
|
||||
'grid grid-cols-12 gap-2 px-4 py-3 items-center transition-colors cursor-pointer hover:bg-muted/50',
|
||||
project.isCurrent && 'bg-primary/5 hover:bg-primary/10'
|
||||
)}
|
||||
>
|
||||
{/* Project Name */}
|
||||
<div className="col-span-4 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
{project.isCurrent && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
{formatMessage({ id: 'mcp.allProjects.current' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{project.path}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Count */}
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-foreground">
|
||||
{project.serverCount} {formatMessage({ id: 'mcp.allProjects.servers' })}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
project.enabledCount > 0 ? 'text-success border-success/30' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{project.enabledCount} {formatMessage({ id: 'mcp.status.enabled' })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Modified */}
|
||||
<div className="col-span-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
{project.lastModified
|
||||
? formatDistanceToNow(new Date(project.lastModified), { addSuffix: true })
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-2 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={(e) => handleOpenNewWindow(e, project.path)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors"
|
||||
title={formatMessage({ id: 'mcp.allProjects.openNewWindow' })}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 bg-muted/30 border-t border-border text-xs text-muted-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'mcp.allProjects.summary' },
|
||||
{ count: sortedProjects.length }
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default AllProjectsTable;
|
||||
@@ -24,6 +24,8 @@ export interface CodexMcpCardProps {
|
||||
enabled: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
/** Optional: When true, indicates this card is in editable mode (for CodexMcpEditableCard extension) */
|
||||
isEditable?: boolean;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
@@ -33,6 +35,8 @@ export function CodexMcpCard({
|
||||
enabled,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
// isEditable prop is for CodexMcpEditableCard extension compatibility
|
||||
isEditable: _isEditable = false,
|
||||
}: CodexMcpCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
|
||||
306
ccw/frontend/src/components/mcp/CodexMcpEditableCard.tsx
Normal file
306
ccw/frontend/src/components/mcp/CodexMcpEditableCard.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
// ========================================
|
||||
// Codex MCP Editable Card Component
|
||||
// ========================================
|
||||
// Editable Codex MCP server card with remove and toggle actions
|
||||
// Extends CodexMcpCard with additional action buttons when editing is enabled
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Server,
|
||||
Power,
|
||||
PowerOff,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/AlertDialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { McpServer } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CodexMcpEditableCardProps {
|
||||
server: McpServer;
|
||||
enabled: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
/** When enabled, shows remove/toggle buttons instead of read-only badge */
|
||||
isEditable?: boolean;
|
||||
/** Callback when server is removed (after confirmation) */
|
||||
onRemove?: (serverName: string) => void | Promise<void>;
|
||||
/** Callback when server is toggled */
|
||||
onToggle?: (serverName: string, enabled: boolean) => void | Promise<void>;
|
||||
/** Whether remove operation is in progress */
|
||||
isRemoving?: boolean;
|
||||
/** Whether toggle operation is in progress */
|
||||
isToggling?: boolean;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CodexMcpEditableCard({
|
||||
server,
|
||||
enabled,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
isEditable = false,
|
||||
onRemove,
|
||||
onToggle,
|
||||
isRemoving = false,
|
||||
isToggling = false,
|
||||
}: CodexMcpEditableCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isConfirmDeleteOpen, setIsConfirmDeleteOpen] = useState(false);
|
||||
|
||||
// Handle toggle with optimistic update
|
||||
const handleToggle = async () => {
|
||||
if (onToggle) {
|
||||
await onToggle(server.name, !enabled);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle remove with confirmation
|
||||
const handleRemove = async () => {
|
||||
if (onRemove) {
|
||||
await onRemove(server.name);
|
||||
setIsConfirmDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Prevent click propagation when clicking action buttons
|
||||
const stopPropagation = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('overflow-hidden', !enabled && 'opacity-60')}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
enabled ? 'bg-primary/10' : 'bg-muted'
|
||||
)}>
|
||||
<Server className={cn(
|
||||
'w-5 h-5',
|
||||
enabled ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{server.name}
|
||||
</span>
|
||||
{isEditable ? (
|
||||
<>
|
||||
{/* Editable badge with actions */}
|
||||
<Badge variant="secondary" className="text-xs flex items-center gap-1">
|
||||
<Edit3 className="w-3 h-3" />
|
||||
{formatMessage({ id: 'mcp.codex.editable' })}
|
||||
</Badge>
|
||||
{enabled && (
|
||||
<Badge variant="outline" className="text-xs text-green-600">
|
||||
<Power className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'mcp.status.enabled' })}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Read-only badge */}
|
||||
<Badge variant="secondary" className="text-xs flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
{formatMessage({ id: 'mcp.codex.readOnly' })}
|
||||
</Badge>
|
||||
{enabled && (
|
||||
<Badge variant="outline" className="text-xs text-green-600">
|
||||
<Power className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'mcp.status.enabled' })}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1 font-mono">
|
||||
{server.command} {server.args?.join(' ') || ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditable ? (
|
||||
<>
|
||||
{/* Toggle button (active in editable mode) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
stopPropagation(e);
|
||||
handleToggle();
|
||||
}}
|
||||
disabled={isToggling}
|
||||
>
|
||||
{enabled ? (
|
||||
<Power className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<PowerOff className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Remove button with confirmation */}
|
||||
<AlertDialog open={isConfirmDeleteOpen} onOpenChange={setIsConfirmDeleteOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={stopPropagation}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{formatMessage({ id: 'mcp.codex.deleteConfirm.title' }, { name: server.name })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{formatMessage({ id: 'mcp.codex.deleteConfirm.description' }, { name: server.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRemoving}>
|
||||
{formatMessage({ id: 'mcp.codex.deleteConfirm.cancel' })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRemove}
|
||||
disabled={isRemoving}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isRemoving ? (
|
||||
<>
|
||||
<span className="animate-spin mr-2">◌</span>
|
||||
{formatMessage({ id: 'mcp.codex.deleteConfirm.deleting' })}
|
||||
</>
|
||||
) : (
|
||||
formatMessage({ id: 'mcp.codex.deleteConfirm.confirm' })
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Disabled toggle button (visual only, no edit capability) */}
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-md flex items-center justify-center',
|
||||
enabled ? 'bg-green-100 text-green-600' : 'bg-muted text-muted-foreground'
|
||||
)}>
|
||||
{enabled ? <Power className="w-4 h-4" /> : <PowerOff className="w-4 h-4" />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-4 space-y-3 bg-muted/30">
|
||||
{/* Command details */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.command' })}</p>
|
||||
<code className="text-sm bg-background px-2 py-1 rounded block overflow-x-auto">
|
||||
{server.command}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Args */}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.args' })}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{server.args.map((arg, idx) => (
|
||||
<Badge key={idx} variant="outline" className="font-mono text-xs">
|
||||
{arg}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment variables */}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.env' })}</p>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(server.env).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="secondary" className="font-mono">{key}</Badge>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<code className="text-xs bg-background px-2 py-1 rounded flex-1 overflow-x-auto">
|
||||
{value as string}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notice based on editable state */}
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md border',
|
||||
isEditable
|
||||
? 'bg-info/10 border-info/20'
|
||||
: 'bg-muted/50 border-border'
|
||||
)}>
|
||||
{isEditable ? (
|
||||
<>
|
||||
<Edit3 className="w-4 h-4 text-info" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.codex.editableNotice' })}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-4 h-4 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.codex.readOnlyNotice' })}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodexMcpEditableCard;
|
||||
247
ccw/frontend/src/components/mcp/ConfigTypeToggle.tsx
Normal file
247
ccw/frontend/src/components/mcp/ConfigTypeToggle.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
// ========================================
|
||||
// Config Type Toggle Component
|
||||
// ========================================
|
||||
// Toggle between .mcp.json and .claude.json config storage formats with localStorage persistence
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/AlertDialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/**
|
||||
* MCP config file type
|
||||
*/
|
||||
export type McpConfigType = 'mcp-json' | 'claude-json';
|
||||
|
||||
/**
|
||||
* Props for ConfigTypeToggle component
|
||||
*/
|
||||
export interface ConfigTypeToggleProps {
|
||||
/** Current config type */
|
||||
currentType: McpConfigType;
|
||||
/** Callback when config type changes */
|
||||
onTypeChange: (type: McpConfigType) => void;
|
||||
/** Whether to show warning when switching (default: true) */
|
||||
showWarning?: boolean;
|
||||
/** Number of existing servers in current config (for warning message) */
|
||||
existingServersCount?: number;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
/**
|
||||
* localStorage key for config type persistence
|
||||
*/
|
||||
const CONFIG_TYPE_STORAGE_KEY = 'mcp-config-type';
|
||||
|
||||
/**
|
||||
* Default config type
|
||||
*/
|
||||
const DEFAULT_CONFIG_TYPE: McpConfigType = 'mcp-json';
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Load config type from localStorage
|
||||
*/
|
||||
export function loadConfigType(): McpConfigType {
|
||||
try {
|
||||
const stored = localStorage.getItem(CONFIG_TYPE_STORAGE_KEY);
|
||||
if (stored === 'mcp-json' || stored === 'claude-json') {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
return DEFAULT_CONFIG_TYPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config type to localStorage
|
||||
*/
|
||||
export function saveConfigType(type: McpConfigType): void {
|
||||
try {
|
||||
localStorage.setItem(CONFIG_TYPE_STORAGE_KEY, type);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension for config type
|
||||
*/
|
||||
export function getConfigFileExtension(type: McpConfigType): string {
|
||||
switch (type) {
|
||||
case 'mcp-json':
|
||||
return '.mcp.json';
|
||||
case 'claude-json':
|
||||
return '.claude.json';
|
||||
default:
|
||||
return '.json';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
/**
|
||||
* Config type toggle segmented control
|
||||
*/
|
||||
export function ConfigTypeToggle({
|
||||
currentType,
|
||||
onTypeChange,
|
||||
showWarning = true,
|
||||
existingServersCount = 0,
|
||||
}: ConfigTypeToggleProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [internalType, setInternalType] = useState<McpConfigType>(currentType);
|
||||
const [showWarningDialog, setShowWarningDialog] = useState(false);
|
||||
const [pendingType, setPendingType] = useState<McpConfigType | null>(null);
|
||||
|
||||
// Sync internal state with prop changes
|
||||
useEffect(() => {
|
||||
setInternalType(currentType);
|
||||
}, [currentType]);
|
||||
|
||||
// Load saved preference on mount (only if no current type is set)
|
||||
useEffect(() => {
|
||||
if (!currentType || currentType === DEFAULT_CONFIG_TYPE) {
|
||||
const savedType = loadConfigType();
|
||||
if (savedType !== currentType) {
|
||||
setInternalType(savedType);
|
||||
onTypeChange(savedType);
|
||||
}
|
||||
}
|
||||
}, []); // Run once on mount
|
||||
|
||||
// Handle type toggle click
|
||||
const handleTypeClick = (type: McpConfigType) => {
|
||||
if (type === internalType) return;
|
||||
|
||||
if (showWarning && existingServersCount > 0) {
|
||||
setPendingType(type);
|
||||
setShowWarningDialog(true);
|
||||
} else {
|
||||
applyTypeChange(type);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply the type change
|
||||
const applyTypeChange = (type: McpConfigType) => {
|
||||
setInternalType(type);
|
||||
saveConfigType(type);
|
||||
onTypeChange(type);
|
||||
setShowWarningDialog(false);
|
||||
setPendingType(null);
|
||||
};
|
||||
|
||||
// Handle warning dialog confirm
|
||||
const handleWarningConfirm = () => {
|
||||
if (pendingType) {
|
||||
applyTypeChange(pendingType);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle warning dialog cancel
|
||||
const handleWarningCancel = () => {
|
||||
setShowWarningDialog(false);
|
||||
setPendingType(null);
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Warning Dialog */}
|
||||
<AlertDialog open={showWarningDialog} onOpenChange={setShowWarningDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{formatMessage({ id: 'mcp.configType.switchConfirm' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{formatMessage({ id: 'mcp.configType.switchWarning' })}
|
||||
{existingServersCount > 0 && (
|
||||
<span className="block mt-2 text-amber-600 dark:text-amber-400">
|
||||
{existingServersCount} {formatMessage({ id: 'mcp.stats.total' }).toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleWarningCancel}>
|
||||
{formatMessage({ id: 'mcp.configType.switchCancel' })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleWarningConfirm}>
|
||||
{formatMessage({ id: 'mcp.configType.switchConfirm' })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigTypeToggle;
|
||||
313
ccw/frontend/src/components/mcp/CrossCliCopyButton.tsx
Normal file
313
ccw/frontend/src/components/mcp/CrossCliCopyButton.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
// ========================================
|
||||
// Cross-CLI Copy Button Component
|
||||
// ========================================
|
||||
// Button component for copying MCP servers between Claude and Codex configurations
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Copy, ArrowRight, ArrowLeft, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useMcpServers } from '@/hooks';
|
||||
import { crossCliCopy } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type CliType = 'claude' | 'codex';
|
||||
export type CopyDirection = 'claude-to-codex' | 'codex-to-claude';
|
||||
|
||||
export interface CrossCliCopyButtonProps {
|
||||
/** Current CLI mode */
|
||||
currentMode?: CliType;
|
||||
/** Button variant */
|
||||
variant?: 'default' | 'outline' | 'ghost';
|
||||
/** Button size */
|
||||
size?: 'default' | 'sm' | 'icon';
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
/** Callback when copy is successful */
|
||||
onSuccess?: (copiedCount: number) => void;
|
||||
}
|
||||
|
||||
interface ServerCheckboxItem {
|
||||
name: string;
|
||||
command: string;
|
||||
enabled: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const CLI_LABELS: Record<CliType, string> = {
|
||||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
};
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CrossCliCopyButton({
|
||||
currentMode = 'claude',
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
className,
|
||||
onSuccess,
|
||||
}: CrossCliCopyButtonProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [direction, setDirection] = useState<CopyDirection>(
|
||||
currentMode === 'claude' ? 'claude-to-codex' : 'codex-to-claude'
|
||||
);
|
||||
const [serverItems, setServerItems] = useState<ServerCheckboxItem[]>([]);
|
||||
|
||||
const { servers } = useMcpServers();
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
|
||||
// Initialize server items when dialog opens
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (open) {
|
||||
setDirection(currentMode === 'claude' ? 'claude-to-codex' : 'codex-to-claude');
|
||||
setServerItems(
|
||||
servers.map((s) => ({
|
||||
name: s.name,
|
||||
command: s.command,
|
||||
enabled: s.enabled,
|
||||
selected: false,
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Get source and target CLI labels
|
||||
const sourceCli = direction === 'claude-to-codex' ? 'claude' : 'codex';
|
||||
const targetCli = direction === 'claude-to-codex' ? 'codex' : 'claude';
|
||||
|
||||
// Toggle direction
|
||||
const handleToggleDirection = () => {
|
||||
setDirection((prev) =>
|
||||
prev === 'claude-to-codex' ? 'codex-to-claude' : 'claude-to-codex'
|
||||
);
|
||||
setServerItems((prev) => prev.map((item) => ({ ...item, selected: false })));
|
||||
};
|
||||
|
||||
// Toggle server selection
|
||||
const handleToggleServer = (serverName: string) => {
|
||||
setServerItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.name === serverName ? { ...item, selected: !item.selected } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Select/deselect all
|
||||
const handleToggleAll = () => {
|
||||
const allSelected = serverItems.every((item) => item.selected);
|
||||
setServerItems((prev) => prev.map((item) => ({ ...item, selected: !allSelected })));
|
||||
};
|
||||
|
||||
// Handle copy operation
|
||||
const handleCopy = async () => {
|
||||
const selectedServers = serverItems.filter((item) => item.selected).map((item) => item.name);
|
||||
|
||||
if (selectedServers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const result = await crossCliCopy({
|
||||
source: sourceCli,
|
||||
target: targetCli,
|
||||
serverNames: selectedServers,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
onSuccess?.(result.copied.length);
|
||||
setIsOpen(false);
|
||||
|
||||
if (result.failed.length > 0) {
|
||||
console.warn('Some servers failed to copy:', result.failed);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy servers:', error);
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCount = serverItems.filter((item) => item.selected).length;
|
||||
const allSelected = serverItems.length > 0 && serverItems.every((item) => item.selected);
|
||||
const someSelected = serverItems.some((item) => item.selected);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
className={className}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'mcp.crossCli.button' })}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Copy className="w-5 h-5" />
|
||||
{formatMessage({ id: 'mcp.crossCli.title' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Direction Selector */}
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="text-sm font-mono">
|
||||
{CLI_LABELS[sourceCli]}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleDirection}
|
||||
className="p-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Badge variant="default" className="text-sm font-mono">
|
||||
{CLI_LABELS[targetCli]}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleAll}
|
||||
disabled={serverItems.length === 0}
|
||||
>
|
||||
{allSelected
|
||||
? formatMessage({ id: 'common.actions.deselectAll' })
|
||||
: formatMessage({ id: 'common.actions.selectAll' })
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Server Selection List */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'mcp.crossCli.selectServers' },
|
||||
{ source: CLI_LABELS[sourceCli] }
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.crossCli.selectServersHint' })}
|
||||
</p>
|
||||
|
||||
{serverItems.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm border border-dashed rounded-lg">
|
||||
{formatMessage({ id: 'mcp.crossCli.noServers' })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||
{serverItems.map((server) => (
|
||||
<div
|
||||
key={server.name}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-2 rounded-lg transition-colors cursor-pointer',
|
||||
server.selected ? 'bg-primary/10' : 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={() => handleToggleServer(server.name)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`server-${server.name}`}
|
||||
checked={server.selected}
|
||||
onChange={() => handleToggleServer(server.name)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`server-${server.name}`}
|
||||
className="flex-1 cursor-pointer min-w-0"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{server.name}
|
||||
</span>
|
||||
{server.enabled && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
{formatMessage({ id: 'mcp.status.enabled' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{server.command}
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Summary */}
|
||||
{someSelected && (
|
||||
<div className="flex items-center justify-between p-3 bg-primary/5 rounded-lg border border-primary/20">
|
||||
<span className="text-sm text-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'mcp.crossCli.selectedCount' },
|
||||
{ count: selectedCount }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isCopying}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
disabled={selectedCount === 0 || isCopying}
|
||||
>
|
||||
{isCopying ? (
|
||||
<>
|
||||
<span className="animate-spin mr-2">-</span>
|
||||
{formatMessage({ id: 'mcp.crossCli.copying' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{direction === 'claude-to-codex' ? (
|
||||
<ArrowRight className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{formatMessage(
|
||||
{ id: 'mcp.crossCli.copyButton' },
|
||||
{ count: selectedCount, target: CLI_LABELS[targetCli] }
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CrossCliCopyButton;
|
||||
159
ccw/frontend/src/components/mcp/EnterpriseMcpBadge.tsx
Normal file
159
ccw/frontend/src/components/mcp/EnterpriseMcpBadge.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
// ========================================
|
||||
// Enterprise MCP Badge Component
|
||||
// ========================================
|
||||
// Badge component for enterprise MCP server identification
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Building2, Crown } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface EnterpriseMcpBadgeProps {
|
||||
/** Server configuration to check for enterprise status */
|
||||
server?: {
|
||||
name: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
/** Badge variant */
|
||||
variant?: 'default' | 'subtle' | 'icon-only';
|
||||
/** Enterprise server patterns for detection */
|
||||
enterprisePatterns?: string[];
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
// Default patterns that indicate enterprise MCP servers
|
||||
const DEFAULT_ENTERPRISE_PATTERNS = [
|
||||
// Command patterns
|
||||
'^enterprise-',
|
||||
'-enterprise$',
|
||||
'^ent-',
|
||||
'claude-.*enterprise',
|
||||
'anthropic-.*',
|
||||
// Server name patterns
|
||||
'^claude.*enterprise',
|
||||
'^anthropic',
|
||||
'^enterprise',
|
||||
// Env var patterns (API keys, endpoints)
|
||||
'ANTHROPIC.*',
|
||||
'ENTERPRISE.*',
|
||||
'.*_ENTERPRISE_ENDPOINT',
|
||||
];
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Check if a server matches enterprise patterns
|
||||
*/
|
||||
function isEnterpriseServer(
|
||||
server: EnterpriseMcpBadgeProps['server'],
|
||||
patterns: string[]
|
||||
): boolean {
|
||||
if (!server) return false;
|
||||
|
||||
const allPatterns = [...patterns, ...DEFAULT_ENTERPRISE_PATTERNS];
|
||||
const searchText = [
|
||||
server.name,
|
||||
server.command || '',
|
||||
...(server.args || []),
|
||||
...Object.keys(server.env || {}),
|
||||
...Object.values(server.env || {}),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return allPatterns.some((pattern) => {
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
return regex.test(searchText);
|
||||
} catch {
|
||||
// Fallback to simple string match if regex is invalid
|
||||
return searchText.includes(pattern.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function EnterpriseMcpBadge({
|
||||
server,
|
||||
className,
|
||||
variant = 'default',
|
||||
enterprisePatterns = [],
|
||||
}: EnterpriseMcpBadgeProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const isEnterprise = isEnterpriseServer(server, enterprisePatterns);
|
||||
|
||||
if (!isEnterprise || !server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Icon-only variant
|
||||
if (variant === 'icon-only') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center w-5 h-5 rounded-full',
|
||||
'bg-gradient-to-br from-amber-500 to-orange-600',
|
||||
'text-white shadow-sm',
|
||||
className
|
||||
)}
|
||||
title={formatMessage({ id: 'mcp.enterprise.tooltip' })}
|
||||
>
|
||||
<Crown className="w-3 h-3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Subtle variant (smaller, less prominent)
|
||||
if (variant === 'subtle') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs border-amber-500/30 text-amber-600 dark:text-amber-400',
|
||||
'bg-amber-500/10',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Building2 className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'mcp.enterprise.label' })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant (prominent)
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
'bg-gradient-to-r from-amber-500 to-orange-600 text-white border-0',
|
||||
'shadow-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Crown className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'mcp.enterprise.label' })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Hook for Enterprise Detection ==========
|
||||
|
||||
/**
|
||||
* Hook to check if a server is an enterprise server
|
||||
*/
|
||||
export function useEnterpriseServer(
|
||||
server: EnterpriseMcpBadgeProps['server'] | undefined,
|
||||
customPatterns: string[] = []
|
||||
): boolean {
|
||||
return isEnterpriseServer(server, customPatterns);
|
||||
}
|
||||
|
||||
export default EnterpriseMcpBadge;
|
||||
283
ccw/frontend/src/components/mcp/InstallCommandDialog.tsx
Normal file
283
ccw/frontend/src/components/mcp/InstallCommandDialog.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
// ========================================
|
||||
// Install Command Dialog Component
|
||||
// ========================================
|
||||
// Dialog for generating and displaying MCP server install commands
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Copy, Check, Terminal, Download } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface InstallCommandDialogProps {
|
||||
/** Server configuration to generate command for */
|
||||
server?: {
|
||||
name: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
/** Whether dialog is open */
|
||||
open: boolean;
|
||||
/** Callback when dialog is closed */
|
||||
onClose: () => void;
|
||||
/** Installation scope */
|
||||
scope?: 'project' | 'global';
|
||||
/** Config type (mcp for .mcp.json, claude for .claude.json) */
|
||||
configType?: 'mcp' | 'claude';
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function InstallCommandDialog({
|
||||
server,
|
||||
open,
|
||||
onClose,
|
||||
scope = 'project',
|
||||
configType = 'mcp',
|
||||
}: InstallCommandDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Generate install command
|
||||
const generateCommand = (): string => {
|
||||
if (!server) return '';
|
||||
|
||||
const envArgs = server.env
|
||||
? Object.entries(server.env).flatMap(([key, value]) => ['--env', `${key}=${value}`])
|
||||
: [];
|
||||
|
||||
const args = [
|
||||
'ccw',
|
||||
'mcp',
|
||||
'install',
|
||||
server.name,
|
||||
'--command',
|
||||
server.command,
|
||||
...(server.args || []).flatMap((arg) => ['--args', arg]),
|
||||
...envArgs,
|
||||
'--scope',
|
||||
scope,
|
||||
];
|
||||
|
||||
if (configType === 'claude') {
|
||||
args.push('--config-type', 'claude');
|
||||
}
|
||||
|
||||
return args.join(' ');
|
||||
};
|
||||
|
||||
// Generate JSON config snippet
|
||||
const generateJsonConfig = (): string => {
|
||||
if (!server) return '';
|
||||
|
||||
const config = {
|
||||
mcpServers: {
|
||||
[server.name]: {
|
||||
command: server.command,
|
||||
...(server.args && { args: server.args }),
|
||||
...(server.env && { env: server.env }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
};
|
||||
|
||||
// Handle copy to clipboard
|
||||
const handleCopy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const command = generateCommand();
|
||||
const jsonConfig = generateJsonConfig();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5" />
|
||||
{formatMessage({ id: 'mcp.installCmd.title' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{server && (
|
||||
<div className="space-y-4">
|
||||
{/* Server Info */}
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{scope}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
.{configType === 'mcp' ? 'mcp.json' : 'claude.json'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{server.command} {(server.args || []).join(' ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CLI Command */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
{formatMessage({ id: 'mcp.installCmd.cliCommand' })}
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(command)}
|
||||
className="h-7"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'common.success.copied' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.copy' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre
|
||||
className={cn(
|
||||
'bg-slate-950 text-slate-50 p-4 rounded-lg text-sm font-mono overflow-x-auto',
|
||||
'border border-slate-800'
|
||||
)}
|
||||
>
|
||||
<code>{command}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.installCmd.cliCommandHint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* JSON Config */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
{formatMessage({ id: 'mcp.installCmd.jsonConfig' })}
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(jsonConfig)}
|
||||
className="h-7"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'common.success.copied' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.copy' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre
|
||||
className={cn(
|
||||
'bg-slate-950 text-slate-50 p-4 rounded-lg text-sm font-mono overflow-x-auto',
|
||||
'border border-slate-800'
|
||||
)}
|
||||
>
|
||||
<code>{jsonConfig}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'mcp.installCmd.jsonConfigHint' },
|
||||
{ filename: `.${configType === 'mcp' ? 'mcp' : 'claude'}.json` }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.installCmd.envVars' })}
|
||||
</label>
|
||||
<div className="p-3 bg-muted rounded-lg space-y-1">
|
||||
{Object.entries(server.env).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-sm font-mono">
|
||||
<span className="text-foreground font-medium">{key}=</span>
|
||||
<span className="text-muted-foreground break-all">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installation Steps */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.installCmd.steps' })}
|
||||
</label>
|
||||
<ol className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex gap-2">
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center bg-primary/20 text-primary rounded-full text-xs font-medium">
|
||||
1
|
||||
</span>
|
||||
<span>{formatMessage({ id: 'mcp.installCmd.step1' })}</span>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center bg-primary/20 text-primary rounded-full text-xs font-medium">
|
||||
2
|
||||
</span>
|
||||
<span>
|
||||
{formatMessage({ id: 'mcp.installCmd.step2' })}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center bg-primary/20 text-primary rounded-full text-xs font-medium">
|
||||
3
|
||||
</span>
|
||||
<span>
|
||||
{formatMessage({ id: 'mcp.installCmd.step3' })}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{formatMessage({ id: 'common.actions.close' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstallCommandDialog;
|
||||
@@ -1,7 +1,7 @@
|
||||
// ========================================
|
||||
// MCP Server Dialog Component
|
||||
// ========================================
|
||||
// Add/Edit dialog for MCP server configuration with template presets
|
||||
// Add/Edit dialog for MCP server configuration with dynamic template loading
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
fetchMcpServers,
|
||||
type McpServer,
|
||||
} from '@/lib/api';
|
||||
import { mcpServersKeys } from '@/hooks';
|
||||
import { mcpServersKeys, useMcpTemplates } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -42,14 +42,8 @@ export interface McpServerDialogProps {
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
export interface McpTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
// Re-export McpTemplate for convenience
|
||||
export type { McpTemplate } from '@/types/store';
|
||||
|
||||
interface McpServerFormData {
|
||||
name: string;
|
||||
@@ -67,32 +61,6 @@ interface FormErrors {
|
||||
env?: string;
|
||||
}
|
||||
|
||||
// ========== Template Presets ==========
|
||||
|
||||
const TEMPLATE_PRESETS: McpTemplate[] = [
|
||||
{
|
||||
id: 'npx-stdio',
|
||||
name: 'NPX STDIO',
|
||||
description: 'Node.js package using stdio transport',
|
||||
command: 'npx',
|
||||
args: ['{package}'],
|
||||
},
|
||||
{
|
||||
id: 'python-stdio',
|
||||
name: 'Python STDIO',
|
||||
description: 'Python script using stdio transport',
|
||||
command: 'python',
|
||||
args: ['{script}.py'],
|
||||
},
|
||||
{
|
||||
id: 'sse-server',
|
||||
name: 'SSE Server',
|
||||
description: 'HTTP server with Server-Sent Events transport',
|
||||
command: 'node',
|
||||
args: ['{server}.js'],
|
||||
},
|
||||
];
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function McpServerDialog({
|
||||
@@ -105,6 +73,9 @@ export function McpServerDialog({
|
||||
const { formatMessage } = useIntl();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch templates from backend
|
||||
const { templates, isLoading: templatesLoading } = useMcpTemplates();
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<McpServerFormData>({
|
||||
name: '',
|
||||
@@ -181,17 +152,17 @@ export function McpServerDialog({
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
const template = TEMPLATE_PRESETS.find((t) => t.id === templateId);
|
||||
const template = templates.find((t) => t.name === templateId);
|
||||
if (template) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
command: template.command,
|
||||
args: template.args,
|
||||
env: template.env || {},
|
||||
command: template.serverConfig.command,
|
||||
args: template.serverConfig.args || [],
|
||||
env: template.serverConfig.env || {},
|
||||
}));
|
||||
setArgsInput(template.args.join(', '));
|
||||
setArgsInput((template.serverConfig.args || []).join(', '));
|
||||
setEnvInput(
|
||||
Object.entries(template.env || {})
|
||||
Object.entries(template.serverConfig.env || {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n')
|
||||
);
|
||||
@@ -324,23 +295,32 @@ export function McpServerDialog({
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.template' })}
|
||||
</label>
|
||||
<Select value={selectedTemplate} onValueChange={handleTemplateSelect}>
|
||||
<Select value={selectedTemplate} onValueChange={handleTemplateSelect} disabled={templatesLoading}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })}
|
||||
placeholder={templatesLoading
|
||||
? formatMessage({ id: 'mcp.templates.loading' })
|
||||
: formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TEMPLATE_PRESETS.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{template.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{template.description}
|
||||
</span>
|
||||
</div>
|
||||
{templates.length === 0 ? (
|
||||
<SelectItem value="" disabled>
|
||||
{formatMessage({ id: 'mcp.templates.empty.title' })}
|
||||
</SelectItem>
|
||||
))}
|
||||
) : (
|
||||
templates.map((template) => (
|
||||
<SelectItem key={template.name} value={template.name}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{template.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{template.description || formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
507
ccw/frontend/src/components/mcp/McpTemplatesSection.tsx
Normal file
507
ccw/frontend/src/components/mcp/McpTemplatesSection.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
// ========================================
|
||||
// MCP Templates Section Component
|
||||
// ========================================
|
||||
// Template management component with card/list view, search input, category filter, save/delete/install actions
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Copy,
|
||||
Trash2,
|
||||
Download,
|
||||
Search,
|
||||
Filter,
|
||||
Plus,
|
||||
FileCode,
|
||||
Folder,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/AlertDialog';
|
||||
import {
|
||||
fetchMcpTemplateCategories,
|
||||
searchMcpTemplates,
|
||||
fetchMcpTemplatesByCategory,
|
||||
} from '@/lib/api';
|
||||
import { mcpTemplatesKeys, useDeleteTemplate, useInstallTemplate } from '@/hooks';
|
||||
import type { McpTemplate } from '@/types/store';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface McpTemplatesSectionProps {
|
||||
/** Callback when template is installed (opens McpServerDialog) */
|
||||
onInstallTemplate?: (template: McpTemplate) => void;
|
||||
/** Callback when current server should be saved as template */
|
||||
onSaveAsTemplate?: (serverName: string, config: { command: string; args: string[]; env?: Record<string, string> }) => void;
|
||||
}
|
||||
|
||||
interface TemplateSaveDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (name: string, category: string, description: string) => void;
|
||||
defaultName?: string;
|
||||
defaultCommand?: string;
|
||||
defaultArgs?: string[];
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: McpTemplate;
|
||||
onInstall: (template: McpTemplate) => void;
|
||||
onDelete: (templateName: string) => void;
|
||||
isInstalling: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
// ========== Helper Components ==========
|
||||
|
||||
/**
|
||||
* Template Card Component - Display single template with actions
|
||||
*/
|
||||
function TemplateCard({ template, onInstall, onDelete, isInstalling, isDeleting }: TemplateCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const handleInstall = () => {
|
||||
onInstall(template);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
onDelete(template.name);
|
||||
};
|
||||
|
||||
// Get icon based on category
|
||||
const getCategoryIcon = () => {
|
||||
const iconProps = { className: 'w-4 h-4' };
|
||||
switch (template.category?.toLowerCase()) {
|
||||
case 'stdio':
|
||||
case 'transport':
|
||||
return <FileCode {...iconProps} />;
|
||||
case 'language':
|
||||
case 'python':
|
||||
case 'node':
|
||||
return <Folder {...iconProps} />;
|
||||
case 'official':
|
||||
case 'builtin':
|
||||
return <Globe {...iconProps} />;
|
||||
default:
|
||||
return <Copy {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4 hover:border-primary/50 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div className="p-2 rounded-lg bg-muted flex-shrink-0">
|
||||
{getCategoryIcon()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{template.name}
|
||||
</span>
|
||||
{template.category && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{template.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono truncate max-w-[150px]">
|
||||
{template.serverConfig.command}
|
||||
</code>
|
||||
{template.serverConfig.args && template.serverConfig.args.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{template.serverConfig.args.length} args
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling}
|
||||
title={formatMessage({ id: 'mcp.templates.actions.install' })}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
title={formatMessage({ id: 'mcp.templates.actions.delete' })}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Template Save Dialog - Save current server configuration as template
|
||||
*/
|
||||
function TemplateSaveDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
defaultName = '',
|
||||
}: TemplateSaveDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [category, setCategory] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [errors, setErrors] = useState<{ name?: string }>({});
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(defaultName || '');
|
||||
setCategory('');
|
||||
setDescription('');
|
||||
setErrors({});
|
||||
}
|
||||
}, [open, defaultName]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
setErrors({ name: formatMessage({ id: 'mcp.templates.saveDialog.validation.nameRequired' }) });
|
||||
return;
|
||||
}
|
||||
onSave(name.trim(), category.trim(), description.trim());
|
||||
setName('');
|
||||
setCategory('');
|
||||
setDescription('');
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{formatMessage({ id: 'mcp.templates.saveDialog.title' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.templates.saveDialog.name' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (errors.name) setErrors({});
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'mcp.templates.saveDialog.namePlaceholder' })}
|
||||
error={!!errors.name}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.templates.saveDialog.category' })}
|
||||
</label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={formatMessage({ id: 'mcp.templates.saveDialog.categoryPlaceholder' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">STDIO</SelectItem>
|
||||
<SelectItem value="sse">SSE</SelectItem>
|
||||
<SelectItem value="language">Language</SelectItem>
|
||||
<SelectItem value="official">Official</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.templates.saveDialog.description' })}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.templates.saveDialog.descriptionPlaceholder' })}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{formatMessage({ id: 'mcp.templates.saveDialog.cancel' })}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{formatMessage({ id: 'mcp.templates.saveDialog.save' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
/**
|
||||
* McpTemplatesSection - Main template management component
|
||||
*
|
||||
* Features:
|
||||
* - Template list view with search and category filter
|
||||
* - Install template action (populates McpServerDialog)
|
||||
* - Delete template with confirmation
|
||||
* - Save current server as template
|
||||
*/
|
||||
export function McpTemplatesSection({ onInstallTemplate, onSaveAsTemplate }: McpTemplatesSectionProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// State
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [templateToDelete, setTemplateToDelete] = useState<string | null>(null);
|
||||
|
||||
// Fetch categories for filter dropdown
|
||||
const { data: categories = [], isLoading: categoriesLoading } = useQuery({
|
||||
queryKey: mcpTemplatesKeys.categories(),
|
||||
queryFn: fetchMcpTemplateCategories,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const deleteMutation = useDeleteTemplate();
|
||||
const installMutation = useInstallTemplate();
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchQuery);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Fetch templates based on search and category filter
|
||||
const { data: templates = [], isLoading: templatesLoading } = useQuery({
|
||||
queryKey: mcpTemplatesKeys.list(selectedCategory === 'all' ? undefined : selectedCategory),
|
||||
queryFn: async () => {
|
||||
if (debouncedSearch.trim()) {
|
||||
return searchMcpTemplates(debouncedSearch.trim());
|
||||
} else if (selectedCategory === 'all') {
|
||||
// Fetch all templates by iterating categories
|
||||
const allTemplates: McpTemplate[] = [];
|
||||
for (const category of categories) {
|
||||
const categoryTemplates = await fetchMcpTemplatesByCategory(category);
|
||||
allTemplates.push(...categoryTemplates);
|
||||
}
|
||||
return allTemplates;
|
||||
} else {
|
||||
return fetchMcpTemplatesByCategory(selectedCategory);
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: !categoriesLoading,
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleInstallTemplate = useCallback((template: McpTemplate) => {
|
||||
// Call parent callback to open McpServerDialog with template data
|
||||
onInstallTemplate?.(template);
|
||||
}, [onInstallTemplate]);
|
||||
|
||||
const handleDeleteClick = useCallback((templateName: string) => {
|
||||
setTemplateToDelete(templateName);
|
||||
setDeleteConfirmOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!templateToDelete) return;
|
||||
|
||||
const result = await deleteMutation.deleteTemplate(templateToDelete);
|
||||
if (result.success) {
|
||||
setDeleteConfirmOpen(false);
|
||||
setTemplateToDelete(null);
|
||||
}
|
||||
}, [templateToDelete, deleteMutation]);
|
||||
|
||||
const handleSaveTemplate = useCallback((_name: string, _category: string, _description: string) => {
|
||||
onSaveAsTemplate?.(_name, { command: '', args: [] });
|
||||
setSaveDialogOpen(false);
|
||||
}, [onSaveAsTemplate]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with search and filter */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.templates.searchPlaceholder' })}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{formatMessage({ id: 'mcp.templates.filter.allCategories' })}
|
||||
</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Template Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSaveDialogOpen(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'mcp.templates.actions.saveAsTemplate' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Template List */}
|
||||
{templatesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.templates.loading' })}
|
||||
</p>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<Copy className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'mcp.templates.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{formatMessage({ id: 'mcp.templates.empty.message' })}
|
||||
</p>
|
||||
<Button onClick={() => setSaveDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'mcp.templates.empty.createFirst' })}
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{templates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.name}
|
||||
template={template}
|
||||
onInstall={handleInstallTemplate}
|
||||
onDelete={handleDeleteClick}
|
||||
isInstalling={installMutation.isInstalling}
|
||||
isDeleting={deleteMutation.isDeleting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Template Dialog */}
|
||||
<TemplateSaveDialog
|
||||
open={saveDialogOpen}
|
||||
onClose={() => setSaveDialogOpen(false)}
|
||||
onSave={handleSaveTemplate}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{formatMessage({ id: 'mcp.templates.deleteDialog.title' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{formatMessage(
|
||||
{ id: 'mcp.templates.deleteDialog.message' },
|
||||
{ name: templateToDelete }
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{formatMessage({ id: 'mcp.templates.deleteDialog.cancel' })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isDeleting
|
||||
? formatMessage({ id: 'mcp.templates.deleteDialog.deleting' })
|
||||
: formatMessage({ id: 'mcp.templates.deleteDialog.delete' })
|
||||
}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default McpTemplatesSection;
|
||||
239
ccw/frontend/src/components/mcp/OtherProjectsSection.tsx
Normal file
239
ccw/frontend/src/components/mcp/OtherProjectsSection.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
// ========================================
|
||||
// Other Projects Section Component
|
||||
// ========================================
|
||||
// Section for discovering and importing servers from other projects
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FolderOpen, Copy, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { useProjectOperations, useMcpServerMutations } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { McpServer } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface OtherProjectServer extends McpServer {
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export interface OtherProjectsSectionProps {
|
||||
/** Callback when server is successfully imported */
|
||||
onImportSuccess?: (serverName: string, sourceProject: string) => void;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function OtherProjectsSection({
|
||||
onImportSuccess,
|
||||
className,
|
||||
}: OtherProjectsSectionProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [selectedProjectPath, setSelectedProjectPath] = useState<string | null>(null);
|
||||
const [otherServers, setOtherServers] = useState<OtherProjectServer[]>([]);
|
||||
const [isFetchingServers, setIsFetchingServers] = useState(false);
|
||||
|
||||
const { projects, currentProject, fetchOtherServers, isFetchingServers: isGlobalFetching } =
|
||||
useProjectOperations();
|
||||
const { createServer, isCreating } = useMcpServerMutations();
|
||||
|
||||
// Get available projects (excluding current)
|
||||
const availableProjects = projects.filter((p) => p !== currentProject);
|
||||
|
||||
// Handle project selection
|
||||
const handleProjectSelect = async (projectPath: string) => {
|
||||
setSelectedProjectPath(projectPath);
|
||||
setIsFetchingServers(true);
|
||||
|
||||
try {
|
||||
const response = await fetchOtherServers([projectPath]);
|
||||
const servers: OtherProjectServer[] = [];
|
||||
|
||||
for (const [path, serverList] of Object.entries(response.servers)) {
|
||||
const projectName = path.split(/[/\\]/).filter(Boolean).pop() || path;
|
||||
for (const server of (serverList as McpServer[])) {
|
||||
servers.push({
|
||||
...server,
|
||||
projectPath: path,
|
||||
projectName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setOtherServers(servers);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch other projects servers:', error);
|
||||
setOtherServers([]);
|
||||
} finally {
|
||||
setIsFetchingServers(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle server import
|
||||
const handleImportServer = async (server: OtherProjectServer) => {
|
||||
try {
|
||||
// Generate a unique name by combining project name and server name
|
||||
const uniqueName = `${server.projectName}-${server.name}`.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
await createServer({
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
scope: 'project',
|
||||
enabled: server.enabled,
|
||||
});
|
||||
|
||||
onImportSuccess?.(uniqueName, server.projectPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to import server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isFetchingServers || isGlobalFetching || isCreating;
|
||||
const selectedProjectName = selectedProjectPath
|
||||
? selectedProjectPath.split(/[/\\]/).filter(Boolean).pop()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className={cn('p-4', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.otherProjects.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => selectedProjectPath && handleProjectSelect(selectedProjectPath)}
|
||||
disabled={!selectedProjectPath || isLoading}
|
||||
className="h-8"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
{formatMessage({ id: 'mcp.otherProjects.description' })}
|
||||
</p>
|
||||
|
||||
{/* Project Selector */}
|
||||
<div className="mb-4">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{formatMessage({ id: 'mcp.otherProjects.selectProject' })}
|
||||
</label>
|
||||
<Select value={selectedProjectPath ?? ''} onValueChange={handleProjectSelect}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={formatMessage({ id: 'mcp.otherProjects.selectProjectPlaceholder' })}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProjects.length === 0 ? (
|
||||
<div className="p-2 text-sm text-muted-foreground text-center">
|
||||
{formatMessage({ id: 'mcp.otherProjects.noProjects' })}
|
||||
</div>
|
||||
) : (
|
||||
availableProjects.map((path) => {
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || path;
|
||||
return (
|
||||
<SelectItem key={path} value={path}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Servers List */}
|
||||
{selectedProjectPath && (
|
||||
<div className="space-y-2">
|
||||
{isFetchingServers ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin text-muted-foreground">-</div>
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'common.actions.loading' })}
|
||||
</span>
|
||||
</div>
|
||||
) : otherServers.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground border border-dashed rounded-lg">
|
||||
{formatMessage(
|
||||
{ id: 'mcp.otherProjects.noServers' },
|
||||
{ project: selectedProjectName }
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{otherServers.map((server) => (
|
||||
<div
|
||||
key={`${server.projectPath}-${server.name}`}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{server.name}
|
||||
</span>
|
||||
{server.enabled && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
{formatMessage({ id: 'mcp.status.enabled' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{server.command} {(server.args || []).join(' ')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-medium">{server.projectName}</span>
|
||||
<ChevronRight className="inline w-3 h-3 mx-1" />
|
||||
<span className="font-mono">{server.projectPath}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleImportServer(server)}
|
||||
disabled={isCreating}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'mcp.otherProjects.import' })}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
{selectedProjectPath && otherServers.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
{formatMessage({ id: 'mcp.otherProjects.hint' })}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default OtherProjectsSection;
|
||||
346
ccw/frontend/src/components/mcp/RecommendedMcpSection.tsx
Normal file
346
ccw/frontend/src/components/mcp/RecommendedMcpSection.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
// ========================================
|
||||
// Recommended MCP Section Component
|
||||
// ========================================
|
||||
// Display recommended MCP servers with one-click install functionality
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Search,
|
||||
Globe,
|
||||
Sparkles,
|
||||
Download,
|
||||
Check,
|
||||
Loader2,
|
||||
} 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 { 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
|
||||
*/
|
||||
export interface RecommendedMcpSectionProps {
|
||||
/** Callback when server is installed */
|
||||
onInstallComplete?: () => void;
|
||||
}
|
||||
|
||||
interface RecommendedServerCardProps {
|
||||
server: RecommendedServer;
|
||||
isInstalled: boolean;
|
||||
isInstalling: boolean;
|
||||
onInstall: (server: RecommendedServer) => void;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
/**
|
||||
* Pre-configured recommended MCP servers
|
||||
*/
|
||||
const RECOMMENDED_SERVERS: RecommendedServer[] = [
|
||||
{
|
||||
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,
|
||||
category: 'search',
|
||||
},
|
||||
{
|
||||
id: 'chrome-devtools',
|
||||
name: 'Chrome DevTools',
|
||||
description: 'Browser automation and debugging tools for web development',
|
||||
command: 'mcp__chrome-devtools',
|
||||
args: [],
|
||||
icon: Globe,
|
||||
category: 'browser',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
// ========== Helper Component ==========
|
||||
|
||||
/**
|
||||
* Individual recommended server card
|
||||
*/
|
||||
function RecommendedServerCard({
|
||||
server,
|
||||
isInstalled,
|
||||
isInstalling,
|
||||
onInstall,
|
||||
}: RecommendedServerCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const Icon = server.icon;
|
||||
|
||||
return (
|
||||
<Card className="p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className={cn(
|
||||
'p-2.5 rounded-lg',
|
||||
isInstalled ? 'bg-primary/20' : 'bg-muted'
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
'w-5 h-5',
|
||||
isInstalled ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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}
|
||||
</h4>
|
||||
{isInstalled && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
{formatMessage({ id: 'mcp.recommended.actions.installed' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">
|
||||
{server.description}
|
||||
</p>
|
||||
|
||||
{/* Install Button */}
|
||||
{!isInstalled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onInstall(server)}
|
||||
disabled={isInstalling}
|
||||
className="w-full"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
/**
|
||||
* Recommended MCP servers section with one-click 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 [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)
|
||||
);
|
||||
setInstalledServerIds(installedIds);
|
||||
} catch {
|
||||
// Ignore errors during check
|
||||
}
|
||||
};
|
||||
|
||||
// Check on mount
|
||||
useState(() => {
|
||||
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 confirm install
|
||||
const handleConfirmInstall = () => {
|
||||
if (!selectedServer) return;
|
||||
setInstallingServerId(selectedServer.id);
|
||||
setConfirmDialogOpen(false);
|
||||
createMutation.mutate(selectedServer);
|
||||
};
|
||||
|
||||
// Check if server is installed
|
||||
const isServerInstalled = (server: RecommendedServer) => {
|
||||
return installedServerIds.has(server.command);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{formatMessage({ id: 'mcp.recommended.title' })}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.recommended.description' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Server Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{RECOMMENDED_SERVERS.map((server) => (
|
||||
<RecommendedServerCard
|
||||
key={server.id}
|
||||
server={server}
|
||||
isInstalled={isServerInstalled(server)}
|
||||
isInstalling={installingServerId === server.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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecommendedMcpSection;
|
||||
272
ccw/frontend/src/components/mcp/WindowsCompatibilityWarning.tsx
Normal file
272
ccw/frontend/src/components/mcp/WindowsCompatibilityWarning.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
// ========================================
|
||||
// Windows Compatibility Warning Component
|
||||
// ========================================
|
||||
// Windows-specific warning banner with command detection and auto-fix functionality
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Download, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface WindowsCompatibilityWarningProps {
|
||||
/** Optional: Project path to check commands against */
|
||||
projectPath?: string;
|
||||
/** Optional: Callback when compatibility check completes */
|
||||
onComplete?: (result: CompatibilityCheckResult) => void;
|
||||
}
|
||||
|
||||
export interface CommandDetectionResult {
|
||||
command: string;
|
||||
available: boolean;
|
||||
installUrl?: string;
|
||||
}
|
||||
|
||||
export interface CompatibilityCheckResult {
|
||||
isWindows: boolean;
|
||||
missingCommands: string[];
|
||||
commands: CommandDetectionResult[];
|
||||
canAutoFix: boolean;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const COMMON_COMMANDS = [
|
||||
{ name: 'npm', installUrl: 'https://docs.npmjs.com/downloading-and-installing-node-js-and-npm' },
|
||||
{ name: 'node', installUrl: 'https://nodejs.org/' },
|
||||
{ name: 'python', installUrl: 'https://www.python.org/downloads/' },
|
||||
{ name: 'npx', installUrl: 'https://docs.npmjs.com/downloading-and-installing-node-js-and-npm' },
|
||||
];
|
||||
|
||||
// Helper function to check if a command can be auto-fixed
|
||||
function canAutoFixCommand(command: string): boolean {
|
||||
return COMMON_COMMANDS.some((cmd) => cmd.name === command);
|
||||
}
|
||||
|
||||
// ========== API Functions ==========
|
||||
|
||||
async function detectWindowsCommands(projectPath?: string): Promise<CommandDetectionResult[]> {
|
||||
const response = await fetch('/api/mcp/detect-commands', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectPath }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to detect commands');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function applyWindowsAutoFix(projectPath?: string): Promise<{ success: boolean; message: string }> {
|
||||
const response = await fetch('/api/mcp/apply-windows-fix', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectPath }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to apply Windows fix');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ========== Sub-Components ==========
|
||||
|
||||
interface CommandDetectionListProps {
|
||||
commands: CommandDetectionResult[];
|
||||
}
|
||||
|
||||
function CommandDetectionList({ commands }: CommandDetectionListProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mt-3">
|
||||
{commands.map((cmd) => (
|
||||
<div
|
||||
key={cmd.command}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm p-2 rounded-md',
|
||||
cmd.available ? 'bg-success/10' : 'bg-destructive/10'
|
||||
)}
|
||||
>
|
||||
{cmd.available ? (
|
||||
<CheckCircle className="w-4 h-4 text-success" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-destructive" />
|
||||
)}
|
||||
<code className="font-mono flex-1">{cmd.command}</code>
|
||||
{!cmd.available && cmd.installUrl && (
|
||||
<a
|
||||
href={cmd.installUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
{formatMessage({ id: 'mcp.windows.install' })}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function WindowsCompatibilityWarning({
|
||||
projectPath,
|
||||
onComplete,
|
||||
}: WindowsCompatibilityWarningProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [checkResult, setCheckResult] = useState<CompatibilityCheckResult | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Detect Windows platform
|
||||
const isWindows = typeof window !== 'undefined' && window.navigator.platform.toLowerCase().includes('win');
|
||||
|
||||
// Mutation for auto-fix
|
||||
const autoFixMutation = useMutation({
|
||||
mutationFn: () => applyWindowsAutoFix(projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mcpServers'] });
|
||||
if (onComplete && checkResult) {
|
||||
onComplete({ ...checkResult, canAutoFix: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Check compatibility on mount (Windows only)
|
||||
useEffect(() => {
|
||||
if (!isWindows || dismissed) return;
|
||||
|
||||
const checkCompatibility = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const results = await detectWindowsCommands(projectPath);
|
||||
const missingCommands = results.filter((r) => !r.available).map((r) => r.command);
|
||||
const canAutoFix = missingCommands.length > 0 && missingCommands.every(canAutoFixCommand);
|
||||
|
||||
const result: CompatibilityCheckResult = {
|
||||
isWindows: true,
|
||||
missingCommands,
|
||||
commands: results,
|
||||
canAutoFix,
|
||||
};
|
||||
|
||||
setCheckResult(result);
|
||||
if (onComplete) {
|
||||
onComplete(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check Windows compatibility:', error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkCompatibility();
|
||||
}, [isWindows, dismissed, projectPath, onComplete]);
|
||||
|
||||
// Don't render if not Windows, dismissed, or still checking without errors
|
||||
if (!isWindows || dismissed || (isChecking && !checkResult)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't show warning if all commands are available
|
||||
if (checkResult && checkResult.missingCommands.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAutoFix = async () => {
|
||||
autoFixMutation.mutate();
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-warning/50 bg-warning/5 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{formatMessage({ id: 'mcp.windows.title' })}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'mcp.windows.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{checkResult && (
|
||||
<>
|
||||
{checkResult.missingCommands.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="warning" className="text-xs">
|
||||
{formatMessage({ id: 'mcp.windows.missingCount' }, { count: checkResult.missingCommands.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CommandDetectionList commands={checkResult.commands} />
|
||||
|
||||
{checkResult.canAutoFix && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-warning/20">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAutoFix}
|
||||
disabled={autoFixMutation.isPending}
|
||||
className="text-xs"
|
||||
>
|
||||
{autoFixMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
{formatMessage({ id: 'mcp.windows.fixing' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'mcp.windows.autoFix' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.windows.autoFixHint' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isChecking && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{formatMessage({ id: 'mcp.windows.checking' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WindowsCompatibilityWarning;
|
||||
260
ccw/frontend/src/components/ui/ContextAssembler.tsx
Normal file
260
ccw/frontend/src/components/ui/ContextAssembler.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
// ========================================
|
||||
// Context Assembler Component
|
||||
// ========================================
|
||||
// Provides UI for managing context assembly rules
|
||||
// with variable interpolation templates
|
||||
|
||||
import * as React from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Info, Plus, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ContextRule {
|
||||
nodeId: string;
|
||||
label?: string;
|
||||
variable?: string;
|
||||
includeOutput?: boolean;
|
||||
transform?: "raw" | "json" | "markdown" | "summary";
|
||||
}
|
||||
|
||||
export interface ContextAssemblerProps {
|
||||
value: string; // Template with {{node:nodeId}} or {{var:variableName}} syntax
|
||||
onChange: (value: string) => void;
|
||||
availableNodes: Array<{ id: string; label: string; type: string; outputVariable?: string }>;
|
||||
availableVariables?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TRANSFORM_OPTIONS = [
|
||||
{ value: "raw", label: "Raw Output" },
|
||||
{ value: "json", label: "JSON Format" },
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "summary", label: "Summary" },
|
||||
] as const;
|
||||
|
||||
const ContextAssembler = React.forwardRef<HTMLDivElement, ContextAssemblerProps>(
|
||||
({ value, onChange, availableNodes, availableVariables = [], className }, ref) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const [showHelp, setShowHelp] = React.useState(false);
|
||||
const [rules, setRules] = React.useState<ContextRule[]>([]);
|
||||
|
||||
// Parse template to extract existing rules
|
||||
React.useEffect(() => {
|
||||
const extracted: ContextRule[] = [];
|
||||
const nodeRegex = /\{\{node:([^}]+)\}\}/g;
|
||||
const varRegex = /\{\{var:([^}]+)\}\}/g;
|
||||
|
||||
let match;
|
||||
while ((match = nodeRegex.exec(value)) !== null) {
|
||||
const node = availableNodes.find((n) => n.id === match[1]);
|
||||
extracted.push({
|
||||
nodeId: match[1],
|
||||
label: node?.label,
|
||||
variable: node?.outputVariable,
|
||||
includeOutput: true,
|
||||
transform: "raw",
|
||||
});
|
||||
}
|
||||
|
||||
while ((match = varRegex.exec(value)) !== null) {
|
||||
extracted.push({
|
||||
nodeId: "",
|
||||
variable: match[1],
|
||||
includeOutput: true,
|
||||
transform: "raw",
|
||||
});
|
||||
}
|
||||
|
||||
setRules(extracted);
|
||||
}, [value, availableNodes]);
|
||||
|
||||
const updateTemplate = React.useCallback(
|
||||
(newRules: ContextRule[]) => {
|
||||
let template = value;
|
||||
|
||||
// Remove all existing node and var references
|
||||
template = template.replace(/\{\{node:[^}]+\}\}/g, "").replace(/\{\{var:[^}]+\}\}/g, "");
|
||||
|
||||
// Build new template with rules
|
||||
const sections: string[] = [];
|
||||
newRules.forEach((rule, index) => {
|
||||
const prefix = rule.nodeId ? "node" : "var";
|
||||
const ref = rule.nodeId || rule.variable;
|
||||
const node = rule.nodeId ? availableNodes.find((n) => n.id === rule.nodeId) : null;
|
||||
const label = rule.label || node?.label || ref;
|
||||
|
||||
if (rule.includeOutput) {
|
||||
sections.push(`## ${label || `Source ${index + 1}`}`);
|
||||
sections.push(`{{${prefix}:${ref}}}`);
|
||||
sections.push("");
|
||||
}
|
||||
});
|
||||
|
||||
onChange(sections.join("\n"));
|
||||
},
|
||||
[value, availableNodes, onChange]
|
||||
);
|
||||
|
||||
const addNode = (nodeId: string) => {
|
||||
const node = availableNodes.find((n) => n.id === nodeId);
|
||||
if (node && !rules.find((r) => r.nodeId === nodeId)) {
|
||||
const newRules = [...rules, { nodeId, label: node.label, variable: node.outputVariable, includeOutput: true, transform: "raw" }];
|
||||
setRules(newRules);
|
||||
updateTemplate(newRules);
|
||||
}
|
||||
};
|
||||
|
||||
const addVariable = (variableName: string) => {
|
||||
if (!rules.find((r) => r.variable === variableName && !r.nodeId)) {
|
||||
const newRules = [...rules, { nodeId: "", variable: variableName, includeOutput: true, transform: "raw" }];
|
||||
setRules(newRules);
|
||||
updateTemplate(newRules);
|
||||
}
|
||||
};
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
const newRules = rules.filter((_, i) => i !== index);
|
||||
setRules(newRules);
|
||||
updateTemplate(newRules);
|
||||
};
|
||||
|
||||
const updateRuleTransform = (index: number, transform: ContextRule["transform"]) => {
|
||||
const newRules = [...rules];
|
||||
newRules[index] = { ...newRules[index], transform };
|
||||
setRules(newRules);
|
||||
updateTemplate(newRules);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("space-y-3", className)}>
|
||||
{/* Header with help toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: "orchestrator.contextAssembler.title" })}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Help panel */}
|
||||
{showHelp && (
|
||||
<div className="p-3 rounded-md bg-muted/50 border border-border text-xs text-muted-foreground">
|
||||
<p className="mb-2 font-medium text-foreground">
|
||||
{formatMessage({ id: "orchestrator.contextAssembler.helpTitle" })}
|
||||
</p>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li>{formatMessage({ id: "orchestrator.contextAssembler.helpSyntax1" })}</li>
|
||||
<li>{formatMessage({ id: "orchestrator.contextAssembler.helpSyntax2" })}</li>
|
||||
<li>{formatMessage({ id: "orchestrator.contextAssembler.helpSyntax3" })}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current rules list */}
|
||||
{rules.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule, index) => {
|
||||
const node = rule.nodeId ? availableNodes.find((n) => n.id === rule.nodeId) : null;
|
||||
const label = rule.label || node?.label || rule.variable || `Source ${index + 1}`;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2 p-2 rounded-md border border-border bg-background">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">{label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{rule.nodeId ? `{{node:${rule.nodeId}}}` : `{{var:${rule.variable}}}`}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={rule.transform || "raw"}
|
||||
onChange={(e) => updateRuleTransform(index, e.target.value as ContextRule["transform"])}
|
||||
className="h-8 px-2 text-xs rounded border border-border bg-background"
|
||||
>
|
||||
{TRANSFORM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRule(index)}
|
||||
className="p-1 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add node selector */}
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">
|
||||
{formatMessage({ id: "orchestrator.contextAssembler.addNode" })}
|
||||
</label>
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => e.target.value && addNode(e.target.value)}
|
||||
className="w-full h-9 px-2 text-sm rounded-md border border-border bg-background"
|
||||
>
|
||||
<option value="">{formatMessage({ id: "orchestrator.contextAssembler.selectNode" })}</option>
|
||||
{availableNodes
|
||||
.filter((n) => !rules.find((r) => r.nodeId === n.id))
|
||||
.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{node.label || node.id} ({node.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Add variable selector */}
|
||||
{availableVariables.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">
|
||||
{formatMessage({ id: "orchestrator.contextAssembler.addVariable" })}
|
||||
</label>
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => e.target.value && addVariable(e.target.value)}
|
||||
className="w-full h-9 px-2 text-sm rounded-md border border-border bg-background"
|
||||
>
|
||||
<option value="">{formatMessage({ id: "orchestrator.contextAssembler.selectVariable" })}</option>
|
||||
{availableVariables
|
||||
.filter((v) => !rules.find((r) => r.variable === v && !r.nodeId))
|
||||
.map((variableName) => (
|
||||
<option key={variableName} value={variableName}>
|
||||
{`{{${variableName}}}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual template editor */}
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">
|
||||
{formatMessage({ id: "orchestrator.contextAssembler.manualEdit" })}
|
||||
</label>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={formatMessage({ id: "orchestrator.propertyPanel.placeholders.contextTemplate" })}
|
||||
className="w-full h-24 px-3 py-2 rounded-md border border-border bg-background text-sm resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ContextAssembler.displayName = "ContextAssembler";
|
||||
|
||||
export { ContextAssembler };
|
||||
Reference in New Issue
Block a user