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 };
|
||||
@@ -15,6 +15,9 @@ export type { UseConfigReturn } from './useConfig';
|
||||
export { useNotifications } from './useNotifications';
|
||||
export type { UseNotificationsReturn, ToastOptions } from './useNotifications';
|
||||
|
||||
export { useWebSocket } from './useWebSocket';
|
||||
export type { UseWebSocketOptions, UseWebSocketReturn } from './useWebSocket';
|
||||
|
||||
export { useWebSocketNotifications } from './useWebSocketNotifications';
|
||||
|
||||
export { useSystemNotifications } from './useSystemNotifications';
|
||||
@@ -140,7 +143,13 @@ export {
|
||||
useDeleteMcpServer,
|
||||
useToggleMcpServer,
|
||||
useMcpServerMutations,
|
||||
useMcpTemplates,
|
||||
useCreateTemplate,
|
||||
useDeleteTemplate,
|
||||
useInstallTemplate,
|
||||
useProjectOperations,
|
||||
mcpServersKeys,
|
||||
mcpTemplatesKeys,
|
||||
} from './useMcpServers';
|
||||
export type {
|
||||
UseMcpServersOptions,
|
||||
@@ -149,6 +158,12 @@ export type {
|
||||
UseCreateMcpServerReturn,
|
||||
UseDeleteMcpServerReturn,
|
||||
UseToggleMcpServerReturn,
|
||||
UseMcpTemplatesOptions,
|
||||
UseMcpTemplatesReturn,
|
||||
UseCreateTemplateReturn,
|
||||
UseDeleteTemplateReturn,
|
||||
UseInstallTemplateReturn,
|
||||
UseProjectOperationsReturn,
|
||||
} from './useMcpServers';
|
||||
|
||||
// ========== CLI ==========
|
||||
|
||||
@@ -10,8 +10,23 @@ import {
|
||||
createMcpServer,
|
||||
deleteMcpServer,
|
||||
toggleMcpServer,
|
||||
fetchMcpTemplates,
|
||||
saveMcpTemplate,
|
||||
deleteMcpTemplate,
|
||||
installMcpTemplate,
|
||||
codexRemoveServer,
|
||||
codexToggleServer,
|
||||
fetchAllProjects,
|
||||
fetchOtherProjectsServers,
|
||||
crossCliCopy,
|
||||
type McpServer,
|
||||
type McpServersResponse,
|
||||
type McpTemplate,
|
||||
type McpTemplateInstallRequest,
|
||||
type AllProjectsResponse,
|
||||
type OtherProjectsServersResponse,
|
||||
type CrossCliCopyRequest,
|
||||
type CrossCliCopyResponse,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
@@ -22,6 +37,22 @@ export const mcpServersKeys = {
|
||||
list: (scope?: 'project' | 'global') => [...mcpServersKeys.lists(), scope] as const,
|
||||
};
|
||||
|
||||
// Query key factory for MCP templates
|
||||
export const mcpTemplatesKeys = {
|
||||
all: ['mcpTemplates'] as const,
|
||||
lists: () => [...mcpTemplatesKeys.all, 'list'] as const,
|
||||
list: (category?: string) => [...mcpTemplatesKeys.lists(), category] as const,
|
||||
search: (query: string) => [...mcpTemplatesKeys.all, 'search', query] as const,
|
||||
categories: () => [...mcpTemplatesKeys.all, 'categories'] as const,
|
||||
};
|
||||
|
||||
// Query key factory for projects
|
||||
export const projectsKeys = {
|
||||
all: ['projects'] as const,
|
||||
list: () => [...projectsKeys.all, 'list'] as const,
|
||||
servers: (paths?: string[]) => [...projectsKeys.all, 'servers', ...(paths ?? [])] as const,
|
||||
};
|
||||
|
||||
// Default stale time: 2 minutes (MCP servers change occasionally)
|
||||
const STALE_TIME = 2 * 60 * 1000;
|
||||
|
||||
@@ -229,3 +260,267 @@ export function useMcpServerMutations() {
|
||||
isMutating: update.isUpdating || create.isCreating || remove.isDeleting || toggle.isToggling,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// MCP Template Hooks
|
||||
// ========================================
|
||||
|
||||
// Default stale time for templates: 5 minutes (templates change rarely)
|
||||
const TEMPLATES_STALE_TIME = 5 * 60 * 1000;
|
||||
|
||||
export interface UseMcpTemplatesOptions {
|
||||
category?: string;
|
||||
staleTime?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMcpTemplatesReturn {
|
||||
templates: McpTemplate[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
invalidate: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching MCP templates with optional category filter
|
||||
*/
|
||||
export function useMcpTemplates(options: UseMcpTemplatesOptions = {}): UseMcpTemplatesReturn {
|
||||
const { category, staleTime = TEMPLATES_STALE_TIME, enabled = true } = options;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: mcpTemplatesKeys.list(category),
|
||||
queryFn: () => fetchMcpTemplates(),
|
||||
staleTime,
|
||||
enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await query.refetch();
|
||||
};
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: mcpTemplatesKeys.all });
|
||||
};
|
||||
|
||||
return {
|
||||
templates: category
|
||||
? query.data?.filter((t) => t.category === category) ?? []
|
||||
: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error,
|
||||
refetch,
|
||||
invalidate,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCreateTemplateReturn {
|
||||
createTemplate: (template: Omit<McpTemplate, 'id' | 'createdAt' | 'updatedAt'>) => Promise<{ success: boolean; id?: number; error?: string }>;
|
||||
isCreating: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for creating or updating MCP templates
|
||||
*/
|
||||
export function useCreateTemplate(): UseCreateTemplateReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (template: Omit<McpTemplate, 'id' | 'createdAt' | 'updatedAt'>) =>
|
||||
saveMcpTemplate(template),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpTemplatesKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
createTemplate: mutation.mutateAsync,
|
||||
isCreating: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseDeleteTemplateReturn {
|
||||
deleteTemplate: (templateName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
isDeleting: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for deleting MCP templates
|
||||
*/
|
||||
export function useDeleteTemplate(): UseDeleteTemplateReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (templateName: string) => deleteMcpTemplate(templateName),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpTemplatesKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deleteTemplate: mutation.mutateAsync,
|
||||
isDeleting: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseInstallTemplateReturn {
|
||||
installTemplate: (request: McpTemplateInstallRequest) => Promise<{ success: boolean; serverName?: string; error?: string }>;
|
||||
isInstalling: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for installing MCP templates to project or global scope
|
||||
*/
|
||||
export function useInstallTemplate(): UseInstallTemplateReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (request: McpTemplateInstallRequest) => installMcpTemplate(request),
|
||||
onSettled: () => {
|
||||
// Invalidate both templates and servers since installation affects both
|
||||
queryClient.invalidateQueries({ queryKey: mcpTemplatesKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
installTemplate: mutation.mutateAsync,
|
||||
isInstalling: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Codex MCP Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseCodexMutationsReturn {
|
||||
removeServer: (serverName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
toggleServer: (serverName: string, enabled: boolean) => Promise<{ success: boolean; error?: string }>;
|
||||
isRemoving: boolean;
|
||||
isToggling: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hook for Codex MCP mutations (remove and toggle)
|
||||
*/
|
||||
export function useCodexMutations(): UseCodexMutationsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (serverName: string) => codexRemoveServer(serverName),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ serverName, enabled }: { serverName: string; enabled: boolean }) =>
|
||||
codexToggleServer(serverName, enabled),
|
||||
onMutate: async ({ serverName, enabled }) => {
|
||||
// Optimistic update could be added here if needed
|
||||
return { serverName, enabled };
|
||||
},
|
||||
onError: (_error, _vars, context) => {
|
||||
// Rollback on error
|
||||
console.error('Failed to toggle Codex MCP server:', _error);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeServer: removeMutation.mutateAsync,
|
||||
isRemoving: removeMutation.isPending,
|
||||
toggleServer: (serverName, enabled) => toggleMutation.mutateAsync({ serverName, enabled }),
|
||||
isToggling: toggleMutation.isPending,
|
||||
error: removeMutation.error || toggleMutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Project Operations Hooks
|
||||
// ========================================
|
||||
|
||||
export interface UseProjectOperationsReturn {
|
||||
projects: string[];
|
||||
currentProject?: string;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
copyToCodex: (request: CrossCliCopyRequest) => Promise<CrossCliCopyResponse>;
|
||||
copyFromCodex: (request: CrossCliCopyRequest) => Promise<CrossCliCopyResponse>;
|
||||
isCopying: boolean;
|
||||
fetchOtherServers: (projectPaths?: string[]) => Promise<OtherProjectsServersResponse>;
|
||||
isFetchingServers: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hook for project operations (all projects, cross-CLI copy, other projects' servers)
|
||||
*/
|
||||
export function useProjectOperations(): UseProjectOperationsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
// Fetch all projects
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: projectsKeys.list(),
|
||||
queryFn: () => fetchAllProjects(),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: true,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
// Cross-CLI copy mutation
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: (request: CrossCliCopyRequest) => crossCliCopy(request),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
// Other projects servers query
|
||||
const serversQuery = useQuery({
|
||||
queryKey: projectsKeys.servers(),
|
||||
queryFn: () => fetchOtherProjectsServers(),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: false, // Manual trigger only
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const refetch = async () => {
|
||||
await projectsQuery.refetch();
|
||||
};
|
||||
|
||||
const fetchOtherServers = async (projectPaths?: string[]) => {
|
||||
return await queryClient.fetchQuery({
|
||||
queryKey: projectsKeys.servers(projectPaths),
|
||||
queryFn: () => fetchOtherProjectsServers(projectPaths),
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
projects: projectsQuery.data?.projects ?? [],
|
||||
currentProject: projectsQuery.data?.currentProject ?? projectPath ?? undefined,
|
||||
isLoading: projectsQuery.isLoading,
|
||||
error: projectsQuery.error,
|
||||
refetch,
|
||||
copyToCodex: (request) => copyMutation.mutateAsync({ ...request, source: 'claude', target: 'codex' }),
|
||||
copyFromCodex: (request) => copyMutation.mutateAsync({ ...request, source: 'codex', target: 'claude' }),
|
||||
isCopying: copyMutation.isPending,
|
||||
fetchOtherServers,
|
||||
isFetchingServers: serversQuery.isFetching,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// Typed fetch functions for API communication with CSRF token handling
|
||||
|
||||
import type { SessionMetadata, TaskData, IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion } from '../types/store';
|
||||
import type { SessionMetadata, TaskData, IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion, McpTemplate, McpTemplateInstallRequest, AllProjectsResponse, OtherProjectsServersResponse, CrossCliCopyRequest, CrossCliCopyResponse } from '../types/store';
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
export type { IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion };
|
||||
@@ -2108,6 +2108,136 @@ export async function addCodexMcpServer(server: Omit<McpServer, 'name'>): Promis
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove MCP server from Codex config.toml
|
||||
*/
|
||||
export async function codexRemoveServer(serverName: string): Promise<{ success: boolean; error?: string }> {
|
||||
return fetchApi<{ success: boolean; error?: string }>('/api/codex-mcp-remove', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ serverName }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Codex MCP server enabled state
|
||||
*/
|
||||
export async function codexToggleServer(
|
||||
serverName: string,
|
||||
enabled: boolean
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return fetchApi<{ success: boolean; error?: string }>('/api/codex-mcp-toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ serverName, enabled }),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== MCP Templates API ==========
|
||||
|
||||
/**
|
||||
* Fetch all MCP templates from database
|
||||
*/
|
||||
export async function fetchMcpTemplates(): Promise<McpTemplate[]> {
|
||||
const data = await fetchApi<{ success: boolean; templates: McpTemplate[] }>('/api/mcp-templates');
|
||||
return data.templates ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update MCP template
|
||||
*/
|
||||
export async function saveMcpTemplate(
|
||||
template: Omit<McpTemplate, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<{ success: boolean; id?: number; error?: string }> {
|
||||
return fetchApi<{ success: boolean; id?: number; error?: string }>('/api/mcp-templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(template),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete MCP template by name
|
||||
*/
|
||||
export async function deleteMcpTemplate(templateName: string): Promise<{ success: boolean; error?: string }> {
|
||||
return fetchApi<{ success: boolean; error?: string }>(
|
||||
`/api/mcp-templates/${encodeURIComponent(templateName)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install MCP template to project or global scope
|
||||
*/
|
||||
export async function installMcpTemplate(
|
||||
request: McpTemplateInstallRequest
|
||||
): Promise<{ success: boolean; serverName?: string; error?: string }> {
|
||||
return fetchApi<{ success: boolean; serverName?: string; error?: string }>('/api/mcp-templates/install', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search MCP templates by keyword
|
||||
*/
|
||||
export async function searchMcpTemplates(keyword: string): Promise<McpTemplate[]> {
|
||||
const data = await fetchApi<{ success: boolean; templates: McpTemplate[] }>(
|
||||
`/api/mcp-templates/search?q=${encodeURIComponent(keyword)}`
|
||||
);
|
||||
return data.templates ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all MCP template categories
|
||||
*/
|
||||
export async function fetchMcpTemplateCategories(): Promise<string[]> {
|
||||
const data = await fetchApi<{ success: boolean; categories: string[] }>('/api/mcp-templates/categories');
|
||||
return data.categories ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP templates by category
|
||||
*/
|
||||
export async function fetchMcpTemplatesByCategory(category: string): Promise<McpTemplate[]> {
|
||||
const data = await fetchApi<{ success: boolean; templates: McpTemplate[] }>(
|
||||
`/api/mcp-templates/category/${encodeURIComponent(category)}`
|
||||
);
|
||||
return data.templates ?? [];
|
||||
}
|
||||
|
||||
// ========== Projects API ==========
|
||||
|
||||
/**
|
||||
* Fetch all projects for cross-project operations
|
||||
*/
|
||||
export async function fetchAllProjects(): Promise<AllProjectsResponse> {
|
||||
return fetchApi<AllProjectsResponse>('/api/projects/all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch MCP servers from other projects
|
||||
*/
|
||||
export async function fetchOtherProjectsServers(
|
||||
projectPaths?: string[]
|
||||
): Promise<OtherProjectsServersResponse> {
|
||||
const url = projectPaths
|
||||
? `/api/projects/other-servers?paths=${projectPaths.map(p => encodeURIComponent(p)).join(',')}`
|
||||
: '/api/projects/other-servers';
|
||||
return fetchApi<OtherProjectsServersResponse>(url);
|
||||
}
|
||||
|
||||
// ========== Cross-CLI Operations ==========
|
||||
|
||||
/**
|
||||
* Copy MCP servers between Claude and Codex CLIs
|
||||
*/
|
||||
export async function crossCliCopy(
|
||||
request: CrossCliCopyRequest
|
||||
): Promise<CrossCliCopyResponse> {
|
||||
return fetchApi<CrossCliCopyResponse>('/api/mcp/cross-cli-copy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== CLI Endpoints API ==========
|
||||
|
||||
export interface CliEndpoint {
|
||||
|
||||
@@ -61,29 +61,126 @@
|
||||
"updatedAt": "Updated",
|
||||
"solutions": "{count, plural, one {solution} other {solutions}}"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Issue Details",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"solutions": "Solutions",
|
||||
"history": "History",
|
||||
"json": "JSON"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Title",
|
||||
"status": "Status",
|
||||
"priority": "Priority",
|
||||
"createdAt": "Created At",
|
||||
"updatedAt": "Updated At",
|
||||
"context": "Context",
|
||||
"labels": "Labels",
|
||||
"assignee": "Assignee"
|
||||
},
|
||||
"solutions": {
|
||||
"title": "Solutions",
|
||||
"empty": "No solutions yet",
|
||||
"addSolution": "Add Solution",
|
||||
"boundSolution": "Bound Solution"
|
||||
},
|
||||
"history": {
|
||||
"title": "History",
|
||||
"empty": "No history yet"
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"title": "Queue",
|
||||
"pageTitle": "Issue Queue",
|
||||
"description": "Manage issue execution queue with execution groups",
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"ready": "Ready",
|
||||
"executing": "Executing",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"blocked": "Blocked",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"stats": {
|
||||
"totalItems": "Total Items",
|
||||
"groups": "Groups",
|
||||
"tasks": "Tasks",
|
||||
"solutions": "Solutions"
|
||||
"solutions": "Solutions",
|
||||
"items": "Items",
|
||||
"executionGroups": "Execution Groups"
|
||||
},
|
||||
"actions": {
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"delete": "Delete",
|
||||
"merge": "Merge",
|
||||
"split": "Split",
|
||||
"confirmDelete": "Are you sure you want to delete this queue?"
|
||||
},
|
||||
"executionGroup": "Execution Group",
|
||||
"executionGroups": "Execution Groups",
|
||||
"parallelGroup": "Parallel Group",
|
||||
"sequentialGroup": "Sequential Group",
|
||||
"items": "items",
|
||||
"itemCount": "{count} items",
|
||||
"groups": "groups",
|
||||
"parallel": "Parallel",
|
||||
"sequential": "Sequential",
|
||||
"emptyState": "No queue data available",
|
||||
"empty": "No data",
|
||||
"conflicts": "Conflicts detected in queue",
|
||||
"noQueueData": "No queue data"
|
||||
"noQueueData": "No queue data",
|
||||
"error": {
|
||||
"title": "Load Failed",
|
||||
"message": "Unable to load queue data, please try again later"
|
||||
},
|
||||
"conflicts": {
|
||||
"title": "Queue Conflicts",
|
||||
"description": "conflicts"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Queue",
|
||||
"description": "Are you sure you want to delete this queue? This action cannot be undone."
|
||||
},
|
||||
"mergeDialog": {
|
||||
"title": "Merge Queues",
|
||||
"targetQueueLabel": "Target Queue ID",
|
||||
"targetQueuePlaceholder": "Enter the queue ID to merge into"
|
||||
},
|
||||
"splitDialog": {
|
||||
"title": "Split Queue",
|
||||
"selected": "{count}/{total} selected",
|
||||
"selectAll": "Select All",
|
||||
"clearAll": "Clear All",
|
||||
"noSelection": "Please select items to split",
|
||||
"cannotSplitAll": "Cannot split all items, source queue must retain at least one item"
|
||||
}
|
||||
},
|
||||
"solution": {
|
||||
"issue": "Issue",
|
||||
"solution": "Solution",
|
||||
"shortIssue": "Issue",
|
||||
"shortSolution": "Sol",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"tasks": "Tasks",
|
||||
"json": "JSON"
|
||||
},
|
||||
"overview": {
|
||||
"executionInfo": "Execution Info",
|
||||
"executionOrder": "Execution Order",
|
||||
"semanticPriority": "Semantic Priority",
|
||||
"group": "Execution Group",
|
||||
"taskCount": "Task Count",
|
||||
"dependencies": "Dependencies",
|
||||
"filesTouched": "Files Touched"
|
||||
},
|
||||
"tasks": {
|
||||
"comingSoon": "Task list coming soon"
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Discovery",
|
||||
|
||||
196
ccw/frontend/src/locales/en/issues.json.bak
Normal file
196
ccw/frontend/src/locales/en/issues.json.bak
Normal file
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"title": "Issues",
|
||||
"description": "Track and manage issues",
|
||||
"status": {
|
||||
"open": "Open",
|
||||
"inProgress": "In Progress",
|
||||
"resolved": "Resolved",
|
||||
"closed": "Closed",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"critical": "Critical"
|
||||
},
|
||||
"actions": {
|
||||
"create": "New Issue",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"viewDetails": "View Details",
|
||||
"changeStatus": "Change Status",
|
||||
"changePriority": "Change Priority",
|
||||
"startProgress": "Start Progress",
|
||||
"markResolved": "Mark Resolved",
|
||||
"github": "Pull from GitHub"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"open": "Open",
|
||||
"inProgress": "In Progress",
|
||||
"resolved": "Resolved",
|
||||
"closed": "Closed",
|
||||
"byPriority": "By Priority"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "No Issues Found",
|
||||
"message": "No issues match your current filter.",
|
||||
"createFirst": "Create your first issue to get started"
|
||||
},
|
||||
"createDialog": {
|
||||
"title": "Create New Issue",
|
||||
"labels": {
|
||||
"title": "Title",
|
||||
"context": "Context",
|
||||
"priority": "Priority"
|
||||
},
|
||||
"placeholders": {
|
||||
"title": "Enter issue title...",
|
||||
"context": "Describe the issue context..."
|
||||
},
|
||||
"buttons": {
|
||||
"create": "Create",
|
||||
"cancel": "Cancel",
|
||||
"creating": "Creating..."
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"id": "ID",
|
||||
"createdAt": "Created",
|
||||
"updatedAt": "Updated",
|
||||
"solutions": "{count, plural, one {solution} other {solutions}}"
|
||||
},
|
||||
"queue": {
|
||||
"title": "Queue",
|
||||
"pageTitle": "Issue Queue",
|
||||
"description": "Manage issue execution queue with execution groups",
|
||||
"stats": {
|
||||
"totalItems": "Total Items",
|
||||
"groups": "Groups",
|
||||
"tasks": "Tasks",
|
||||
"solutions": "Solutions"
|
||||
},
|
||||
"actions": {
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"delete": "Delete",
|
||||
"merge": "Merge",
|
||||
"confirmDelete": "Are you sure you want to delete this queue?"
|
||||
},
|
||||
"executionGroup": "Execution Group",
|
||||
"parallel": "Parallel",
|
||||
"sequential": "Sequential",
|
||||
"emptyState": "No queue data available",
|
||||
"conflicts": "Conflicts detected in queue",
|
||||
"noQueueData": "No queue data"
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Discovery",
|
||||
"pageTitle": "Issue Discovery",
|
||||
"description": "View and manage issue discovery sessions",
|
||||
"totalSessions": "Total Sessions",
|
||||
"completedSessions": "Completed",
|
||||
"runningSessions": "Running",
|
||||
"totalFindings": "Findings",
|
||||
"sessionList": "Session List",
|
||||
"noSessions": "No sessions found",
|
||||
"noSessionsDescription": "Start a new discovery session to begin",
|
||||
"findingsDetail": "Findings Detail",
|
||||
"selectSession": "Select a session to view findings",
|
||||
"sessionId": "Session ID",
|
||||
"name": "Name",
|
||||
"status": "Status",
|
||||
"createdAt": "Created At",
|
||||
"completedAt": "Completed At",
|
||||
"progress": "Progress",
|
||||
"findingsCount": "Findings Count",
|
||||
"export": "Export JSON",
|
||||
"exportSelected": "Export Selected ({count})",
|
||||
"exporting": "Exporting...",
|
||||
"exportAsIssues": "Export as Issues",
|
||||
"severityBreakdown": "Severity Breakdown",
|
||||
"typeBreakdown": "Type Breakdown",
|
||||
"tabFindings": "Findings",
|
||||
"tabProgress": "Progress",
|
||||
"tabInfo": "Session Info",
|
||||
"stats": {
|
||||
"totalSessions": "Total Sessions",
|
||||
"completed": "Completed",
|
||||
"running": "Running",
|
||||
"findings": "Findings"
|
||||
},
|
||||
"session": {
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"findings": "{count} findings",
|
||||
"startedAt": "Started"
|
||||
},
|
||||
"findings": {
|
||||
"title": "Findings",
|
||||
"filters": {
|
||||
"severity": "Severity",
|
||||
"type": "Type",
|
||||
"search": "Search findings..."
|
||||
},
|
||||
"severity": {
|
||||
"all": "All Severities",
|
||||
"critical": "Critical",
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
},
|
||||
"type": {
|
||||
"all": "All Types"
|
||||
},
|
||||
"exportedStatus": {
|
||||
"all": "All Export Status",
|
||||
"exported": "Exported",
|
||||
"notExported": "Not Exported"
|
||||
},
|
||||
"issueStatus": {
|
||||
"all": "All Issue Status",
|
||||
"hasIssue": "Has Issue",
|
||||
"noIssue": "No Issue"
|
||||
},
|
||||
"noFindings": "No findings found",
|
||||
"noFindingsDescription": "No matching findings found",
|
||||
"searchPlaceholder": "Search findings...",
|
||||
"filterBySeverity": "Filter by severity",
|
||||
"filterByType": "Filter by type",
|
||||
"filterByExported": "Filter by export status",
|
||||
"filterByIssue": "Filter by issue link",
|
||||
"allSeverities": "All severities",
|
||||
"allTypes": "All types",
|
||||
"showingCount": "Showing {count} findings",
|
||||
"exported": "Exported",
|
||||
"hasIssue": "Linked",
|
||||
"export": "Export",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All"
|
||||
},
|
||||
"tabs": {
|
||||
"findings": "Findings",
|
||||
"progress": "Progress",
|
||||
"info": "Session Info"
|
||||
},
|
||||
"emptyState": "No discovery sessions found",
|
||||
"noSessionSelected": "Select a session to view findings",
|
||||
"actions": {
|
||||
"export": "Export Findings",
|
||||
"refresh": "Refresh"
|
||||
}
|
||||
},
|
||||
"hub": {
|
||||
"title": "Issue Hub",
|
||||
"description": "Unified management for issues, queues, and discoveries",
|
||||
"tabs": {
|
||||
"issues": "Issues",
|
||||
"queue": "Queue",
|
||||
"discovery": "Discovery"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"title": "MCP Servers",
|
||||
"description": "Manage Model Context Protocol (MCP) servers for cross-CLI integration",
|
||||
"tabs": {
|
||||
"templates": "Templates",
|
||||
"servers": "Servers",
|
||||
"crossCli": "Cross-CLI"
|
||||
},
|
||||
"mode": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex"
|
||||
@@ -25,7 +30,26 @@
|
||||
"codex": {
|
||||
"configPath": "Config Path",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyNotice": "Codex MCP servers are managed via config.toml and cannot be edited here."
|
||||
"readOnlyNotice": "Codex MCP servers are managed via config.toml and cannot be edited here.",
|
||||
"editable": "Editable",
|
||||
"editableNotice": "This server can be edited. Changes will be saved to config.toml.",
|
||||
"deleteConfirm": {
|
||||
"title": "Remove Server \"{name}\"?",
|
||||
"description": "This will remove \"{name}\" from your Codex config.toml file. This action cannot be undone.",
|
||||
"confirm": "Remove Server",
|
||||
"cancel": "Cancel",
|
||||
"deleting": "Removing..."
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"title": "Windows Compatibility",
|
||||
"description": "Some MCP server commands require Windows-specific configuration for proper execution.",
|
||||
"missingCount": "{count} missing command(s)",
|
||||
"checking": "Checking Windows compatibility...",
|
||||
"fixing": "Applying Windows compatibility fix...",
|
||||
"autoFix": "Auto-Fix Commands",
|
||||
"autoFixHint": "This will wrap commands with 'cmd /c' prefix for Windows compatibility.",
|
||||
"install": "Download"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
@@ -126,5 +150,148 @@
|
||||
"saveConfig": "Save Configuration",
|
||||
"saving": "Saving..."
|
||||
}
|
||||
},
|
||||
"recommended": {
|
||||
"title": "Recommended Servers",
|
||||
"description": "Quickly install popular MCP servers with one click",
|
||||
"servers": {
|
||||
"ace": {
|
||||
"name": "ACE Tool",
|
||||
"description": "Advanced code search and context engine for intelligent code discovery",
|
||||
"command": "mcp__ace-tool__search_context",
|
||||
"install": "Install ACE",
|
||||
"installing": "Installing..."
|
||||
},
|
||||
"chrome": {
|
||||
"name": "Chrome DevTools",
|
||||
"description": "Browser automation and debugging tools for web development",
|
||||
"command": "mcp__chrome-devtools",
|
||||
"install": "Install Chrome",
|
||||
"installing": "Installing..."
|
||||
},
|
||||
"exa": {
|
||||
"name": "Exa Search",
|
||||
"description": "AI-powered web search with real-time crawling capabilities",
|
||||
"command": "mcp__exa__search",
|
||||
"install": "Install Exa",
|
||||
"installing": "Installing..."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"installed": "Installed",
|
||||
"viewAll": "View All Servers"
|
||||
}
|
||||
},
|
||||
"configType": {
|
||||
"label": "Config Format",
|
||||
"mcpJson": ".mcp.json",
|
||||
"claudeJson": ".claude.json",
|
||||
"switchWarning": "Switching config format will not migrate existing servers. You'll need to reconfigure servers in the new format.",
|
||||
"switchConfirm": "Switch Config Format",
|
||||
"switchCancel": "Keep Current"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Templates",
|
||||
"description": "Reusable MCP server configuration templates",
|
||||
"searchPlaceholder": "Search templates by name or description...",
|
||||
"filter": {
|
||||
"allCategories": "All Categories"
|
||||
},
|
||||
"actions": {
|
||||
"install": "Install Template",
|
||||
"delete": "Delete Template",
|
||||
"saveAsTemplate": "Save as Template"
|
||||
},
|
||||
"loading": "Loading templates...",
|
||||
"empty": {
|
||||
"title": "No Templates Found",
|
||||
"message": "Create your first template or install templates from the community",
|
||||
"createFirst": "Create Your First Template"
|
||||
},
|
||||
"saveDialog": {
|
||||
"title": "Save as Template",
|
||||
"name": "Template Name",
|
||||
"namePlaceholder": "e.g., My Python MCP Server",
|
||||
"category": "Category",
|
||||
"categoryPlaceholder": "Select a category",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Brief description of what this template does...",
|
||||
"validation": {
|
||||
"nameRequired": "Template name is required"
|
||||
},
|
||||
"save": "Save Template",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Template",
|
||||
"message": "Are you sure you want to delete the template \"{name}\"? This action cannot be undone.",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting...",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"categories": {
|
||||
"stdio": "STDIO",
|
||||
"sse": "SSE",
|
||||
"language": "Language",
|
||||
"official": "Official",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"feedback": {
|
||||
"installSuccess": "Template installed successfully",
|
||||
"installError": "Failed to install template",
|
||||
"deleteSuccess": "Template deleted successfully",
|
||||
"deleteError": "Failed to delete template",
|
||||
"saveSuccess": "Template saved successfully",
|
||||
"saveError": "Failed to save template"
|
||||
}
|
||||
},
|
||||
"crossCli": {
|
||||
"button": "Cross-CLI Copy",
|
||||
"title": "Copy MCP Servers Between CLIs",
|
||||
"selectServers": "Select servers to copy from {source}",
|
||||
"selectServersHint": "Choose servers to copy. Configuration will be transformed for the target CLI format.",
|
||||
"noServers": "No servers available in current configuration",
|
||||
"selectedCount": "{count} server(s) selected",
|
||||
"copying": "Copying...",
|
||||
"copyButton": "Copy to {target}"
|
||||
},
|
||||
"allProjects": {
|
||||
"title": "All Projects",
|
||||
"name": "Project Name",
|
||||
"servers": "Servers",
|
||||
"lastModified": "Last Modified",
|
||||
"actions": "Actions",
|
||||
"current": "Current",
|
||||
"openNewWindow": "Open in new window",
|
||||
"empty": "No projects found",
|
||||
"summary": "Showing {count} project(s)"
|
||||
},
|
||||
"otherProjects": {
|
||||
"title": "Other Projects",
|
||||
"description": "Discover and import MCP servers from your other projects",
|
||||
"selectProject": "Select a project",
|
||||
"selectProjectPlaceholder": "Choose a project...",
|
||||
"noProjects": "No other projects available",
|
||||
"noServers": "No MCP servers found in {project}",
|
||||
"import": "Import",
|
||||
"hint": "Imported servers will be added to your current project configuration"
|
||||
},
|
||||
"installCmd": {
|
||||
"title": "Installation Command",
|
||||
"cliCommand": "CLI Command",
|
||||
"cliCommandHint": "Run this command in your terminal to install the server",
|
||||
"jsonConfig": "JSON Configuration",
|
||||
"jsonConfigHint": "Add this to your {filename} file",
|
||||
"envVars": "Environment Variables",
|
||||
"steps": "Installation Steps",
|
||||
"step1": "Run the CLI command above in your terminal",
|
||||
"step2": "Or manually add the JSON config to your configuration file",
|
||||
"step3": "Restart your CLI to load the new server"
|
||||
},
|
||||
"enterprise": {
|
||||
"label": "Enterprise",
|
||||
"tooltip": "Enterprise MCP server"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,18 @@
|
||||
"empty": "No nodes available",
|
||||
"clear": "Clear all"
|
||||
},
|
||||
"contextAssembler": {
|
||||
"title": "Context Template",
|
||||
"helpTitle": "Context Assembly Syntax",
|
||||
"helpSyntax1": "Reference node output: {{node:node-id}}",
|
||||
"helpSyntax2": "Reference variable: {{var:variableName}}",
|
||||
"helpSyntax3": "Combine multiple sources in custom format",
|
||||
"addNode": "Add Node Reference",
|
||||
"selectNode": "Select a node...",
|
||||
"addVariable": "Add Variable Reference",
|
||||
"selectVariable": "Select a variable...",
|
||||
"manualEdit": "Custom Template (use {{node:id}} or {{var:name}})"
|
||||
},
|
||||
"propertyPanel": {
|
||||
"title": "Properties",
|
||||
"open": "Open properties panel",
|
||||
@@ -160,7 +172,9 @@
|
||||
"variableName": "variableName",
|
||||
"condition": "e.g., result.success === true",
|
||||
"trueLabel": "True",
|
||||
"falseLabel": "False"
|
||||
"falseLabel": "False",
|
||||
"contextTemplate": "Template with {variable} placeholders",
|
||||
"promptText": "Enter your prompt here..."
|
||||
},
|
||||
"labels": {
|
||||
"label": "Label",
|
||||
@@ -179,7 +193,13 @@
|
||||
"trueLabel": "True Label",
|
||||
"falseLabel": "False Label",
|
||||
"joinMode": "Join Mode",
|
||||
"failFast": "Fail fast (stop all branches on first error)"
|
||||
"failFast": "Fail fast (stop all branches on first error)",
|
||||
"tool": "CLI Tool",
|
||||
"mode": "Mode",
|
||||
"promptType": "Prompt Type",
|
||||
"sourceNodes": "Source Nodes",
|
||||
"contextTemplate": "Context Template",
|
||||
"promptText": "Prompt Text"
|
||||
},
|
||||
"options": {
|
||||
"modeMainprocess": "Main Process",
|
||||
@@ -195,7 +215,18 @@
|
||||
"operationMove": "Move",
|
||||
"joinModeAll": "Wait for all branches",
|
||||
"joinModeAny": "Complete when any branch finishes",
|
||||
"joinModeNone": "No synchronization"
|
||||
"joinModeNone": "No synchronization",
|
||||
"toolGemini": "Gemini",
|
||||
"toolQwen": "Qwen",
|
||||
"toolCodex": "Codex",
|
||||
"modeAnalysis": "Analysis",
|
||||
"modeWrite": "Write",
|
||||
"modeReview": "Review",
|
||||
"promptTypeOrganize": "Organize",
|
||||
"promptTypeRefine": "Refine",
|
||||
"promptTypeSummarize": "Summarize",
|
||||
"promptTypeTransform": "Transform",
|
||||
"promptTypeCustom": "Custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"title": "MCP 服务器",
|
||||
"description": "管理模型上下文协议 (MCP) 服务器以实现跨 CLI 集成",
|
||||
"tabs": {
|
||||
"templates": "模板",
|
||||
"servers": "服务器",
|
||||
"crossCli": "跨 CLI"
|
||||
},
|
||||
"mode": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex"
|
||||
@@ -25,7 +30,26 @@
|
||||
"codex": {
|
||||
"configPath": "配置路径",
|
||||
"readOnly": "只读",
|
||||
"readOnlyNotice": "Codex MCP 服务器通过 config.toml 管理,无法在此处编辑。"
|
||||
"readOnlyNotice": "Codex MCP 服务器通过 config.toml 管理,无法在此处编辑。",
|
||||
"editable": "可编辑",
|
||||
"editableNotice": "此服务器可以编辑。更改将保存到 config.toml。",
|
||||
"deleteConfirm": {
|
||||
"title": "删除服务器 \"{name}\"?",
|
||||
"description": "这将从您的 Codex config.toml 文件中删除 \"{name}\"。此操作无法撤销。",
|
||||
"confirm": "删除服务器",
|
||||
"cancel": "取消",
|
||||
"deleting": "删除中..."
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"title": "Windows 兼容性",
|
||||
"description": "某些 MCP 服务器命令需要 Windows 特定配置才能正确执行。",
|
||||
"missingCount": "缺少 {count} 个命令",
|
||||
"checking": "正在检查 Windows 兼容性...",
|
||||
"fixing": "正在应用 Windows 兼容性修复...",
|
||||
"autoFix": "自动修复命令",
|
||||
"autoFixHint": "这将在命令前添加 'cmd /c' 前缀以提高 Windows 兼容性。",
|
||||
"install": "下载"
|
||||
},
|
||||
"filters": {
|
||||
"all": "全部",
|
||||
@@ -126,5 +150,148 @@
|
||||
"saveConfig": "保存配置",
|
||||
"saving": "保存中..."
|
||||
}
|
||||
},
|
||||
"recommended": {
|
||||
"title": "推荐服务器",
|
||||
"description": "一键快速安装热门 MCP 服务器",
|
||||
"servers": {
|
||||
"ace": {
|
||||
"name": "ACE 工具",
|
||||
"description": "高级代码搜索和上下文引擎,用于智能代码发现",
|
||||
"command": "mcp__ace-tool__search_context",
|
||||
"install": "安装 ACE",
|
||||
"installing": "安装中..."
|
||||
},
|
||||
"chrome": {
|
||||
"name": "Chrome 开发者工具",
|
||||
"description": "浏览器自动化和调试工具,用于 Web 开发",
|
||||
"command": "mcp__chrome-devtools",
|
||||
"install": "安装 Chrome",
|
||||
"installing": "安装中..."
|
||||
},
|
||||
"exa": {
|
||||
"name": "Exa 搜索",
|
||||
"description": "AI 驱动的网络搜索,支持实时抓取",
|
||||
"command": "mcp__exa__search",
|
||||
"install": "安装 Exa",
|
||||
"installing": "安装中..."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"install": "安装",
|
||||
"installing": "安装中...",
|
||||
"installed": "已安装",
|
||||
"viewAll": "查看全部服务器"
|
||||
}
|
||||
},
|
||||
"configType": {
|
||||
"label": "配置格式",
|
||||
"mcpJson": ".mcp.json",
|
||||
"claudeJson": ".claude.json",
|
||||
"switchWarning": "切换配置格式不会迁移现有服务器。您需要在新格式中重新配置服务器。",
|
||||
"switchConfirm": "切换配置格式",
|
||||
"switchCancel": "保持当前格式"
|
||||
},
|
||||
"templates": {
|
||||
"title": "模板",
|
||||
"description": "可重用的 MCP 服务器配置模板",
|
||||
"searchPlaceholder": "按名称或描述搜索模板...",
|
||||
"filter": {
|
||||
"allCategories": "所有类别"
|
||||
},
|
||||
"actions": {
|
||||
"install": "安装模板",
|
||||
"delete": "删除模板",
|
||||
"saveAsTemplate": "保存为模板"
|
||||
},
|
||||
"loading": "正在加载模板...",
|
||||
"empty": {
|
||||
"title": "未找到模板",
|
||||
"message": "创建您的第一个模板或从社区安装模板",
|
||||
"createFirst": "创建第一个模板"
|
||||
},
|
||||
"saveDialog": {
|
||||
"title": "保存为模板",
|
||||
"name": "模板名称",
|
||||
"namePlaceholder": "例如:我的 Python MCP 服务器",
|
||||
"category": "类别",
|
||||
"categoryPlaceholder": "选择一个类别",
|
||||
"description": "描述",
|
||||
"descriptionPlaceholder": "简要描述此模板的用途...",
|
||||
"validation": {
|
||||
"nameRequired": "模板名称不能为空"
|
||||
},
|
||||
"save": "保存模板",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "删除模板",
|
||||
"message": "确定要删除模板 \"{name}\" 吗?此操作无法撤销。",
|
||||
"delete": "删除",
|
||||
"deleting": "删除中...",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"categories": {
|
||||
"stdio": "STDIO",
|
||||
"sse": "SSE",
|
||||
"language": "语言",
|
||||
"official": "官方",
|
||||
"custom": "自定义"
|
||||
},
|
||||
"feedback": {
|
||||
"installSuccess": "模板安装成功",
|
||||
"installError": "模板安装失败",
|
||||
"deleteSuccess": "模板删除成功",
|
||||
"deleteError": "模板删除失败",
|
||||
"saveSuccess": "模板保存成功",
|
||||
"saveError": "模板保存失败"
|
||||
}
|
||||
},
|
||||
"crossCli": {
|
||||
"button": "跨 CLI 复制",
|
||||
"title": "在 CLI 之间复制 MCP 服务器",
|
||||
"selectServers": "选择要从 {source} 复制的服务器",
|
||||
"selectServersHint": "选择要复制的服务器。配置将转换为目标 CLI 格式。",
|
||||
"noServers": "当前配置中没有可用的服务器",
|
||||
"selectedCount": "已选择 {count} 个服务器",
|
||||
"copying": "复制中...",
|
||||
"copyButton": "复制到 {target}"
|
||||
},
|
||||
"allProjects": {
|
||||
"title": "所有项目",
|
||||
"name": "项目名称",
|
||||
"servers": "服务器",
|
||||
"lastModified": "最后修改",
|
||||
"actions": "操作",
|
||||
"current": "当前",
|
||||
"openNewWindow": "在新窗口中打开",
|
||||
"empty": "未找到项目",
|
||||
"summary": "显示 {count} 个项目"
|
||||
},
|
||||
"otherProjects": {
|
||||
"title": "其他项目",
|
||||
"description": "从您的其他项目发现并导入 MCP 服务器",
|
||||
"selectProject": "选择一个项目",
|
||||
"selectProjectPlaceholder": "选择一个项目...",
|
||||
"noProjects": "没有其他可用的项目",
|
||||
"noServers": "在 {project} 中未找到 MCP 服务器",
|
||||
"import": "导入",
|
||||
"hint": "导入的服务器将添加到您的当前项目配置中"
|
||||
},
|
||||
"installCmd": {
|
||||
"title": "安装命令",
|
||||
"cliCommand": "CLI 命令",
|
||||
"cliCommandHint": "在终端中运行此命令以安装服务器",
|
||||
"jsonConfig": "JSON 配置",
|
||||
"jsonConfigHint": "将此添加到您的 {filename} 文件中",
|
||||
"envVars": "环境变量",
|
||||
"steps": "安装步骤",
|
||||
"step1": "在您的终端中运行上面的 CLI 命令",
|
||||
"step2": "或将 JSON 配置手动添加到您的配置文件中",
|
||||
"step3": "重启您的 CLI 以加载新服务器"
|
||||
},
|
||||
"enterprise": {
|
||||
"label": "企业版",
|
||||
"tooltip": "企业版 MCP 服务器"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,18 @@
|
||||
"empty": "没有可用的节点",
|
||||
"clear": "清除全部"
|
||||
},
|
||||
"contextAssembler": {
|
||||
"title": "上下文模板",
|
||||
"helpTitle": "上下文组装语法",
|
||||
"helpSyntax1": "引用节点输出: {{node:节点ID}}",
|
||||
"helpSyntax2": "引用变量: {{var:变量名}}",
|
||||
"helpSyntax3": "以自定义格式组合多个来源",
|
||||
"addNode": "添加节点引用",
|
||||
"selectNode": "选择节点...",
|
||||
"addVariable": "添加变量引用",
|
||||
"selectVariable": "选择变量...",
|
||||
"manualEdit": "自定义模板 (使用 {{node:id}} 或 {{var:name}})"
|
||||
},
|
||||
"propertyPanel": {
|
||||
"title": "属性",
|
||||
"open": "打开属性面板",
|
||||
@@ -159,7 +171,9 @@
|
||||
"variableName": "变量名称",
|
||||
"condition": "例如: result.success === true",
|
||||
"trueLabel": "真",
|
||||
"falseLabel": "假"
|
||||
"falseLabel": "假",
|
||||
"contextTemplate": "带有 {variable} 占位符的模板",
|
||||
"promptText": "在此输入您的提示词..."
|
||||
},
|
||||
"labels": {
|
||||
"label": "标签",
|
||||
@@ -178,7 +192,13 @@
|
||||
"trueLabel": "真标签",
|
||||
"falseLabel": "假标签",
|
||||
"joinMode": "加入模式",
|
||||
"failFast": "快速失败 (首次错误时停止所有分支)"
|
||||
"failFast": "快速失败 (首次错误时停止所有分支)",
|
||||
"tool": "CLI 工具",
|
||||
"mode": "模式",
|
||||
"promptType": "提示词类型",
|
||||
"sourceNodes": "源节点",
|
||||
"contextTemplate": "上下文模板",
|
||||
"promptText": "提示词文本"
|
||||
},
|
||||
"options": {
|
||||
"modeMainprocess": "主进程",
|
||||
@@ -194,7 +214,18 @@
|
||||
"operationMove": "移动",
|
||||
"joinModeAll": "等待所有分支",
|
||||
"joinModeAny": "任一分支完成时完成",
|
||||
"joinModeNone": "无同步"
|
||||
"joinModeNone": "无同步",
|
||||
"toolGemini": "Gemini",
|
||||
"toolQwen": "Qwen",
|
||||
"toolCodex": "Codex",
|
||||
"modeAnalysis": "分析",
|
||||
"modeWrite": "写入",
|
||||
"modeReview": "审查",
|
||||
"promptTypeOrganize": "组织",
|
||||
"promptTypeRefine": "精炼",
|
||||
"promptTypeSummarize": "总结",
|
||||
"promptTypeTransform": "转换",
|
||||
"promptTypeCustom": "自定义"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// ========================================
|
||||
// MCP Manager Page
|
||||
// ========================================
|
||||
// Manage MCP servers (Model Context Protocol) with project/global scope switching
|
||||
// Supports both Claude and Codex CLI modes
|
||||
// Manage MCP servers (Model Context Protocol) with tabbed interface
|
||||
// Supports Templates, Servers, and Cross-CLI tabs
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -27,15 +27,24 @@ import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { McpServerDialog } from '@/components/mcp/McpServerDialog';
|
||||
import { CliModeToggle, type CliMode } from '@/components/mcp/CliModeToggle';
|
||||
import { CodexMcpCard } from '@/components/mcp/CodexMcpCard';
|
||||
import { CodexMcpEditableCard } from '@/components/mcp/CodexMcpEditableCard';
|
||||
import { CcwToolsMcpCard } from '@/components/mcp/CcwToolsMcpCard';
|
||||
import { McpTemplatesSection } from '@/components/mcp/McpTemplatesSection';
|
||||
import { RecommendedMcpSection } from '@/components/mcp/RecommendedMcpSection';
|
||||
import { ConfigTypeToggle } from '@/components/mcp/ConfigTypeToggle';
|
||||
import { WindowsCompatibilityWarning } from '@/components/mcp/WindowsCompatibilityWarning';
|
||||
import { CrossCliCopyButton } from '@/components/mcp/CrossCliCopyButton';
|
||||
import { AllProjectsTable } from '@/components/mcp/AllProjectsTable';
|
||||
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
|
||||
import { TabsNavigation } from '@/components/ui/TabsNavigation';
|
||||
import { useMcpServers, useMcpServerMutations } from '@/hooks';
|
||||
import {
|
||||
fetchCodexMcpServers,
|
||||
fetchCcwMcpConfig,
|
||||
updateCcwConfig,
|
||||
codexRemoveServer,
|
||||
codexToggleServer,
|
||||
type McpServer,
|
||||
type CodexMcpServer,
|
||||
type CcwMcpConfig,
|
||||
} from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -190,6 +199,7 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
|
||||
|
||||
export function McpManagerPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState<'templates' | 'servers' | 'cross-cli'>('servers');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'project' | 'global'>('all');
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||
@@ -197,6 +207,7 @@ export function McpManagerPage() {
|
||||
const [editingServer, setEditingServer] = useState<McpServer | undefined>(undefined);
|
||||
const [cliMode, setCliMode] = useState<CliMode>('claude');
|
||||
const [codexExpandedServers, setCodexExpandedServers] = useState<Set<string>>(new Set());
|
||||
const [configType, setConfigType] = useState<'mcp-json' | 'claude-json'>('mcp-json');
|
||||
|
||||
const {
|
||||
servers,
|
||||
@@ -317,6 +328,44 @@ export function McpManagerPage() {
|
||||
ccwMcpQuery.refetch();
|
||||
};
|
||||
|
||||
// Template handlers
|
||||
const handleInstallTemplate = (template: any) => {
|
||||
setEditingServer({
|
||||
name: template.name,
|
||||
command: template.serverConfig.command,
|
||||
args: template.serverConfig.args || [],
|
||||
env: template.serverConfig.env,
|
||||
scope: 'project',
|
||||
enabled: true,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveAsTemplate = (serverName: string, config: { command: string; args: string[] }) => {
|
||||
// This would open a dialog to save current server as template
|
||||
// For now, just log it
|
||||
console.log('Save as template:', serverName, config);
|
||||
};
|
||||
|
||||
// Codex MCP handlers
|
||||
const handleCodexRemove = async (serverName: string) => {
|
||||
try {
|
||||
await codexRemoveServer(serverName);
|
||||
codexQuery.refetch();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove Codex MCP server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodexToggle = async (serverName: string, enabled: boolean) => {
|
||||
try {
|
||||
await codexToggleServer(serverName, enabled);
|
||||
codexQuery.refetch();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle Codex MCP server:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter servers by search query
|
||||
const filteredServers = servers.filter((s) =>
|
||||
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -372,7 +421,48 @@ export function McpManagerPage() {
|
||||
codexConfigPath={codexConfigPath}
|
||||
/>
|
||||
|
||||
{/* Stats Cards - Claude mode only */}
|
||||
{/* Tabbed Interface */}
|
||||
<TabsNavigation
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as 'templates' | 'servers' | 'cross-cli')}
|
||||
tabs={[
|
||||
{ value: 'templates', label: formatMessage({ id: 'mcp.tabs.templates' }) },
|
||||
{ value: 'servers', label: formatMessage({ id: 'mcp.tabs.servers' }) },
|
||||
{ value: 'cross-cli', label: formatMessage({ id: 'mcp.tabs.crossCli' }) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Tab Content: Templates */}
|
||||
{activeTab === 'templates' && (
|
||||
<div className="mt-4">
|
||||
<McpTemplatesSection
|
||||
onInstallTemplate={handleInstallTemplate}
|
||||
onSaveAsTemplate={handleSaveAsTemplate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Content: Servers */}
|
||||
{activeTab === 'servers' && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Windows Compatibility Warning */}
|
||||
<WindowsCompatibilityWarning />
|
||||
|
||||
{/* Recommended MCP Servers */}
|
||||
{cliMode === 'claude' && (
|
||||
<RecommendedMcpSection onInstallComplete={() => refetch()} />
|
||||
)}
|
||||
|
||||
{/* Config Type Toggle */}
|
||||
{cliMode === 'claude' && (
|
||||
<ConfigTypeToggle
|
||||
currentType={configType}
|
||||
onTypeChange={setConfigType}
|
||||
existingServersCount={totalCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats Cards - Claude mode only */}
|
||||
{cliMode === 'claude' && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
@@ -492,12 +582,15 @@ export function McpManagerPage() {
|
||||
<div className="space-y-3">
|
||||
{currentServers.map((server) => (
|
||||
cliMode === 'codex' ? (
|
||||
<CodexMcpCard
|
||||
<CodexMcpEditableCard
|
||||
key={server.name}
|
||||
server={server as CodexMcpServer}
|
||||
server={server as McpServer}
|
||||
enabled={server.enabled}
|
||||
isExpanded={currentExpanded.has(server.name)}
|
||||
onToggleExpand={() => currentToggleExpand(server.name)}
|
||||
isEditable={true}
|
||||
onRemove={handleCodexRemove}
|
||||
onToggle={handleCodexToggle}
|
||||
/>
|
||||
) : (
|
||||
<McpServerCard
|
||||
@@ -513,8 +606,46 @@ export function McpManagerPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Dialog - Claude mode only */}
|
||||
{/* Tab Content: Cross-CLI */}
|
||||
{activeTab === 'cross-cli' && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Cross-CLI Copy Button */}
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.crossCli.title' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'mcp.crossCli.selectServersHint' })}
|
||||
</p>
|
||||
</div>
|
||||
<CrossCliCopyButton
|
||||
currentMode={cliMode}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* All Projects Table */}
|
||||
<AllProjectsTable
|
||||
maxProjects={10}
|
||||
onProjectClick={(path) => console.log('Open project:', path)}
|
||||
onOpenNewWindow={(path) => window.open(`/?project=${encodeURIComponent(path)}`, '_blank')}
|
||||
/>
|
||||
|
||||
{/* Other Projects Section */}
|
||||
<OtherProjectsSection
|
||||
onImportSuccess={(serverName, sourceProject) => {
|
||||
console.log('Imported server:', serverName, 'from:', sourceProject);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Dialog - Claude mode only (shared across tabs) */}
|
||||
{cliMode === 'claude' && (
|
||||
<McpServerDialog
|
||||
mode={editingServer ? 'edit' : 'add'}
|
||||
|
||||
@@ -171,6 +171,10 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
|
||||
return '#f59e0b'; // amber-500
|
||||
case 'parallel':
|
||||
return '#a855f7'; // purple-500
|
||||
case 'cli-command':
|
||||
return '#f59e0b'; // amber-500
|
||||
case 'prompt':
|
||||
return '#a855f7'; // purple-500
|
||||
default:
|
||||
return '#6b7280'; // gray-500
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
|
||||
'file-operation': FileText,
|
||||
conditional: GitBranch,
|
||||
parallel: GitMerge,
|
||||
'cli-command': Terminal,
|
||||
prompt: FileText,
|
||||
};
|
||||
|
||||
// Color mapping for node types
|
||||
@@ -26,6 +28,8 @@ const nodeColors: Record<FlowNodeType, string> = {
|
||||
'file-operation': 'bg-green-500 hover:bg-green-600',
|
||||
conditional: 'bg-amber-500 hover:bg-amber-600',
|
||||
parallel: 'bg-purple-500 hover:bg-purple-600',
|
||||
'cli-command': 'bg-amber-500 hover:bg-amber-600',
|
||||
prompt: 'bg-purple-500 hover:bg-purple-600',
|
||||
};
|
||||
|
||||
const nodeBorderColors: Record<FlowNodeType, string> = {
|
||||
@@ -33,6 +37,8 @@ const nodeBorderColors: Record<FlowNodeType, string> = {
|
||||
'file-operation': 'border-green-500',
|
||||
conditional: 'border-amber-500',
|
||||
parallel: 'border-purple-500',
|
||||
'cli-command': 'border-amber-500',
|
||||
prompt: 'border-purple-500',
|
||||
};
|
||||
|
||||
interface NodePaletteProps {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Settings, X, Terminal, FileText, GitBranch, GitMerge, Trash2 } from 'lu
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { MultiNodeSelector, type NodeOption } from '@/components/ui/MultiNodeSelector';
|
||||
import { ContextAssembler } from '@/components/ui/ContextAssembler';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import type {
|
||||
FlowNodeType,
|
||||
@@ -16,9 +18,55 @@ import type {
|
||||
FileOperationNodeData,
|
||||
ConditionalNodeData,
|
||||
ParallelNodeData,
|
||||
CliCommandNodeData,
|
||||
PromptNodeData,
|
||||
NodeData,
|
||||
} from '@/types/flow';
|
||||
|
||||
// ========== Common Form Field Components ==========
|
||||
|
||||
interface LabelInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function LabelInput({ value, onChange }: LabelInputProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}
|
||||
</label>
|
||||
<Input
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OutputVariableInputProps {
|
||||
value?: string;
|
||||
onChange: (value?: string) => void;
|
||||
}
|
||||
|
||||
function OutputVariableInput({ value, onChange }: OutputVariableInputProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}
|
||||
</label>
|
||||
<Input
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PropertyPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
@@ -29,6 +77,8 @@ const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
|
||||
'file-operation': FileText,
|
||||
conditional: GitBranch,
|
||||
parallel: GitMerge,
|
||||
'cli-command': Terminal,
|
||||
prompt: FileText,
|
||||
};
|
||||
|
||||
// Slash Command Property Editor
|
||||
@@ -43,14 +93,7 @@ function SlashCommandProperties({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
|
||||
/>
|
||||
</div>
|
||||
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.command' })}</label>
|
||||
@@ -118,14 +161,7 @@ function SlashCommandProperties({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
|
||||
<Input
|
||||
value={data.outputVariable || ''}
|
||||
onChange={(e) => onChange({ outputVariable: e.target.value })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
|
||||
/>
|
||||
</div>
|
||||
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -142,14 +178,7 @@ function FileOperationProperties({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
|
||||
/>
|
||||
</div>
|
||||
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.operation' })}</label>
|
||||
@@ -205,14 +234,7 @@ function FileOperationProperties({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
|
||||
<Input
|
||||
value={data.outputVariable || ''}
|
||||
onChange={(e) => onChange({ outputVariable: e.target.value })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
|
||||
/>
|
||||
</div>
|
||||
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -242,14 +264,7 @@ function ConditionalProperties({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
|
||||
/>
|
||||
</div>
|
||||
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.condition' })}</label>
|
||||
@@ -280,14 +295,7 @@ function ConditionalProperties({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
|
||||
<Input
|
||||
value={data.outputVariable || ''}
|
||||
onChange={(e) => onChange({ outputVariable: e.target.value })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
|
||||
/>
|
||||
</div>
|
||||
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -304,14 +312,7 @@ function ParallelProperties({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
|
||||
/>
|
||||
</div>
|
||||
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.joinMode' })}</label>
|
||||
@@ -353,14 +354,174 @@ function ParallelProperties({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// CLI Command Property Editor
|
||||
function CliCommandProperties({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: CliCommandNodeData;
|
||||
onChange: (updates: Partial<CliCommandNodeData>) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.command' })}</label>
|
||||
<Input
|
||||
value={data.outputVariable || ''}
|
||||
onChange={(e) => onChange({ outputVariable: e.target.value })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
|
||||
value={data.command || ''}
|
||||
onChange={(e) => onChange({ command: e.target.value })}
|
||||
placeholder="PURPOSE: ... TASK: ..."
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.arguments' })}</label>
|
||||
<Input
|
||||
value={data.args || ''}
|
||||
onChange={(e) => onChange({ args: e.target.value })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.commandArgs' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.tool' })}</label>
|
||||
<select
|
||||
value={data.tool || 'gemini'}
|
||||
onChange={(e) => onChange({ tool: e.target.value as 'gemini' | 'qwen' | 'codex' })}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="gemini">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolGemini' })}</option>
|
||||
<option value="qwen">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolQwen' })}</option>
|
||||
<option value="codex">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolCodex' })}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.mode' })}</label>
|
||||
<select
|
||||
value={data.mode || 'analysis'}
|
||||
onChange={(e) => onChange({ mode: e.target.value as 'analysis' | 'write' | 'review' })}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="analysis">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeAnalysis' })}</option>
|
||||
<option value="write">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeWrite' })}</option>
|
||||
<option value="review">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeReview' })}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.timeout' })}</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.execution?.timeout || ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
execution: {
|
||||
...data.execution,
|
||||
timeout: e.target.value ? parseInt(e.target.value) : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.timeout' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prompt Property Editor
|
||||
function PromptProperties({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: PromptNodeData;
|
||||
onChange: (updates: Partial<PromptNodeData>) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
|
||||
// Build available nodes list for MultiNodeSelector and ContextAssembler
|
||||
const availableNodes: NodeOption[] = nodes
|
||||
.filter((n) => n.id !== useFlowStore.getState().selectedNodeId) // Exclude current node
|
||||
.map((n) => ({
|
||||
id: n.id,
|
||||
label: n.data?.label || n.id,
|
||||
type: n.type,
|
||||
}));
|
||||
|
||||
// Build available variables list from nodes with outputVariable
|
||||
const availableVariables = nodes
|
||||
.filter((n) => n.data?.outputVariable)
|
||||
.map((n) => n.data?.outputVariable as string)
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.promptType' })}</label>
|
||||
<select
|
||||
value={data.promptType || 'custom'}
|
||||
onChange={(e) => onChange({ promptType: e.target.value as PromptNodeData['promptType'] })}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="organize">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeOrganize' })}</option>
|
||||
<option value="refine">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeRefine' })}</option>
|
||||
<option value="summarize">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeSummarize' })}</option>
|
||||
<option value="transform">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeTransform' })}</option>
|
||||
<option value="custom">{formatMessage({ id: 'orchestrator.propertyPanel.options.promptTypeCustom' })}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* MultiNodeSelector for source nodes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.sourceNodes' })}</label>
|
||||
<MultiNodeSelector
|
||||
availableNodes={availableNodes}
|
||||
selectedNodes={data.sourceNodes || []}
|
||||
onChange={(selectedIds) => onChange({ sourceNodes: selectedIds })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.multiNodeSelector.empty' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ContextAssembler for context template management */}
|
||||
<div>
|
||||
<ContextAssembler
|
||||
value={data.contextTemplate || ''}
|
||||
onChange={(value) => onChange({ contextTemplate: value })}
|
||||
availableNodes={nodes.map((n) => ({
|
||||
id: n.id,
|
||||
label: n.data?.label || n.id,
|
||||
type: n.type,
|
||||
outputVariable: n.data?.outputVariable,
|
||||
}))}
|
||||
availableVariables={availableVariables}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.promptText' })}</label>
|
||||
<textarea
|
||||
value={data.promptText || ''}
|
||||
onChange={(e) => onChange({ promptText: e.target.value })}
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.promptText' })}
|
||||
className="w-full h-32 px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -489,6 +650,18 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{nodeType === 'cli-command' && (
|
||||
<CliCommandProperties
|
||||
data={selectedNode.data as CliCommandNodeData}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{nodeType === 'prompt' && (
|
||||
<PromptProperties
|
||||
data={selectedNode.data as PromptNodeData}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
|
||||
112
ccw/frontend/src/pages/orchestrator/nodes/CliCommandNode.tsx
Normal file
112
ccw/frontend/src/pages/orchestrator/nodes/CliCommandNode.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
// ========================================
|
||||
// CLI Command Node Component
|
||||
// ========================================
|
||||
// Custom node for executing CLI tools with AI models
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import type { CliCommandNodeData } from '@/types/flow';
|
||||
import { NodeWrapper } from './NodeWrapper';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CliCommandNodeProps {
|
||||
data: CliCommandNodeData;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
// Mode badge styling
|
||||
const MODE_STYLES = {
|
||||
analysis: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
write: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
review: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
};
|
||||
|
||||
// Tool badge styling
|
||||
const TOOL_STYLES = {
|
||||
gemini: 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400 border border-blue-200 dark:border-blue-800',
|
||||
qwen: 'bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400 border border-green-200 dark:border-green-800',
|
||||
codex: 'bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400 border border-purple-200 dark:border-purple-800',
|
||||
};
|
||||
|
||||
export const CliCommandNode = memo(({ data, selected }: CliCommandNodeProps) => {
|
||||
const mode = data.mode || 'analysis';
|
||||
const tool = data.tool || 'gemini';
|
||||
|
||||
return (
|
||||
<NodeWrapper
|
||||
status={data.executionStatus}
|
||||
selected={selected}
|
||||
accentColor="amber"
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-background"
|
||||
/>
|
||||
|
||||
{/* Node Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-amber-500 text-white rounded-t-md">
|
||||
<Terminal className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{data.label || 'CLI Command'}
|
||||
</span>
|
||||
{/* Tool badge */}
|
||||
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded bg-white/20', TOOL_STYLES[tool])}>
|
||||
{tool}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Node Content */}
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{/* Command name */}
|
||||
{data.command && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||
ccw cli {data.command}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments (truncated) */}
|
||||
{data.args && (
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[160px]">
|
||||
<span className="text-foreground/70 font-mono">{data.args}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode badge */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-muted-foreground">Mode:</span>
|
||||
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', MODE_STYLES[mode])}>
|
||||
{mode}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Output variable indicator */}
|
||||
{data.outputVariable && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{'->'} {data.outputVariable}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution error message */}
|
||||
{data.executionStatus === 'failed' && data.executionError && (
|
||||
<div className="text-[10px] text-destructive truncate max-w-[160px]" title={data.executionError}>
|
||||
{data.executionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-background"
|
||||
/>
|
||||
</NodeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
CliCommandNode.displayName = 'CliCommandNode';
|
||||
120
ccw/frontend/src/pages/orchestrator/nodes/PromptNode.tsx
Normal file
120
ccw/frontend/src/pages/orchestrator/nodes/PromptNode.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// ========================================
|
||||
// Prompt Node Component
|
||||
// ========================================
|
||||
// Custom node for constructing AI prompts with context
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { PromptNodeData } from '@/types/flow';
|
||||
import { NodeWrapper } from './NodeWrapper';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PromptNodeProps {
|
||||
data: PromptNodeData;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
// Prompt type badge styling
|
||||
const PROMPT_TYPE_STYLES = {
|
||||
organize: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
refine: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
summarize: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
transform: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
custom: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
|
||||
};
|
||||
|
||||
// Prompt type labels for display
|
||||
const PROMPT_TYPE_LABELS: Record<PromptNodeData['promptType'], string> = {
|
||||
organize: 'Organize',
|
||||
refine: 'Refine',
|
||||
summarize: 'Summarize',
|
||||
transform: 'Transform',
|
||||
custom: 'Custom',
|
||||
};
|
||||
|
||||
export const PromptNode = memo(({ data, selected }: PromptNodeProps) => {
|
||||
const promptType = data.promptType || 'custom';
|
||||
|
||||
// Truncate prompt text for display
|
||||
const displayPrompt = data.promptText
|
||||
? data.promptText.length > 40
|
||||
? data.promptText.slice(0, 37) + '...'
|
||||
: data.promptText
|
||||
: 'No prompt';
|
||||
|
||||
return (
|
||||
<NodeWrapper
|
||||
status={data.executionStatus}
|
||||
selected={selected}
|
||||
accentColor="purple"
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
|
||||
/>
|
||||
|
||||
{/* Node Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-purple-500 text-white rounded-t-md">
|
||||
<FileText className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{data.label || 'Prompt'}
|
||||
</span>
|
||||
{/* Prompt type badge */}
|
||||
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', PROMPT_TYPE_STYLES[promptType])}>
|
||||
{PROMPT_TYPE_LABELS[promptType]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Node Content */}
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{/* Prompt text preview */}
|
||||
<div
|
||||
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
|
||||
title={data.promptText}
|
||||
>
|
||||
{displayPrompt}
|
||||
</div>
|
||||
|
||||
{/* Source nodes count */}
|
||||
{data.sourceNodes && data.sourceNodes.length > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Sources: {data.sourceNodes.length} node{data.sourceNodes.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context template indicator */}
|
||||
{data.contextTemplate && (
|
||||
<div className="text-[10px] text-muted-foreground truncate max-w-[160px]" title={data.contextTemplate}>
|
||||
Template: {data.contextTemplate.slice(0, 20)}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output variable indicator */}
|
||||
{data.outputVariable && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{'->'} {data.outputVariable}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution error message */}
|
||||
{data.executionStatus === 'failed' && data.executionError && (
|
||||
<div className="text-[10px] text-destructive truncate max-w-[160px]" title={data.executionError}>
|
||||
{data.executionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
|
||||
/>
|
||||
</NodeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
PromptNode.displayName = 'PromptNode';
|
||||
@@ -10,12 +10,16 @@ export { SlashCommandNode } from './SlashCommandNode';
|
||||
export { FileOperationNode } from './FileOperationNode';
|
||||
export { ConditionalNode } from './ConditionalNode';
|
||||
export { ParallelNode } from './ParallelNode';
|
||||
export { CliCommandNode } from './CliCommandNode';
|
||||
export { PromptNode } from './PromptNode';
|
||||
|
||||
// Node types map for React Flow registration
|
||||
import { SlashCommandNode } from './SlashCommandNode';
|
||||
import { FileOperationNode } from './FileOperationNode';
|
||||
import { ConditionalNode } from './ConditionalNode';
|
||||
import { ParallelNode } from './ParallelNode';
|
||||
import { CliCommandNode } from './CliCommandNode';
|
||||
import { PromptNode } from './PromptNode';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const nodeTypes: Record<string, any> = {
|
||||
@@ -23,4 +27,6 @@ export const nodeTypes: Record<string, any> = {
|
||||
'file-operation': FileOperationNode,
|
||||
conditional: ConditionalNode,
|
||||
parallel: ParallelNode,
|
||||
'cli-command': CliCommandNode,
|
||||
prompt: PromptNode,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Node, Edge } from '@xyflow/react';
|
||||
|
||||
// ========== Node Types ==========
|
||||
|
||||
export type FlowNodeType = 'slash-command' | 'file-operation' | 'conditional' | 'parallel';
|
||||
export type FlowNodeType = 'slash-command' | 'file-operation' | 'conditional' | 'parallel' | 'cli-command' | 'prompt';
|
||||
|
||||
// Execution status for nodes during workflow execution
|
||||
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
@@ -41,7 +41,6 @@ export interface FileOperationNodeData extends BaseNodeData {
|
||||
content?: string;
|
||||
destinationPath?: string;
|
||||
encoding?: 'utf8' | 'ascii' | 'base64';
|
||||
outputVariable?: string;
|
||||
addToContext?: boolean;
|
||||
}
|
||||
|
||||
@@ -60,12 +59,33 @@ export interface ParallelNodeData extends BaseNodeData {
|
||||
failFast?: boolean;
|
||||
}
|
||||
|
||||
// CLI Command Node Data
|
||||
export interface CliCommandNodeData extends BaseNodeData {
|
||||
command: string;
|
||||
args?: string;
|
||||
tool: 'gemini' | 'qwen' | 'codex';
|
||||
mode: 'analysis' | 'write' | 'review';
|
||||
execution?: {
|
||||
timeout?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Prompt Node Data
|
||||
export interface PromptNodeData extends BaseNodeData {
|
||||
promptType: 'organize' | 'refine' | 'summarize' | 'transform' | 'custom';
|
||||
sourceNodes: string[];
|
||||
contextTemplate?: string;
|
||||
promptText: string;
|
||||
}
|
||||
|
||||
// Union type for all node data
|
||||
export type NodeData =
|
||||
| SlashCommandNodeData
|
||||
| FileOperationNodeData
|
||||
| ConditionalNodeData
|
||||
| ParallelNodeData;
|
||||
| ParallelNodeData
|
||||
| CliCommandNodeData
|
||||
| PromptNodeData;
|
||||
|
||||
// Extended Node type for React Flow
|
||||
export type FlowNode = Node<NodeData, FlowNodeType>;
|
||||
@@ -238,4 +258,32 @@ export const NODE_TYPE_CONFIGS: Record<FlowNodeType, NodeTypeConfig> = {
|
||||
} as ParallelNodeData,
|
||||
handles: { inputs: 1, outputs: 2 },
|
||||
},
|
||||
'cli-command': {
|
||||
type: 'cli-command',
|
||||
label: 'CLI Command',
|
||||
description: 'Execute CLI tools with AI models',
|
||||
icon: 'Terminal',
|
||||
color: 'bg-amber-500',
|
||||
defaultData: {
|
||||
label: 'CLI Command',
|
||||
command: '',
|
||||
tool: 'gemini',
|
||||
mode: 'analysis',
|
||||
} as CliCommandNodeData,
|
||||
handles: { inputs: 1, outputs: 1 },
|
||||
},
|
||||
prompt: {
|
||||
type: 'prompt',
|
||||
label: 'Prompt',
|
||||
description: 'Construct AI prompts with context',
|
||||
icon: 'FileText',
|
||||
color: 'bg-purple-500',
|
||||
defaultData: {
|
||||
label: 'Prompt',
|
||||
promptType: 'custom',
|
||||
sourceNodes: [],
|
||||
promptText: '',
|
||||
} as PromptNodeData,
|
||||
handles: { inputs: 1, outputs: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -805,3 +805,111 @@ export interface Suggestion {
|
||||
/** Whether suggestion was applied */
|
||||
applied?: boolean;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// MCP Template Types
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* MCP Server Template for reusable configurations
|
||||
* Matches backend schema from mcp-templates-db.ts
|
||||
*/
|
||||
export interface McpTemplate {
|
||||
/** Template ID (database primary key) */
|
||||
id?: number;
|
||||
/** Unique template name */
|
||||
name: string;
|
||||
/** Template description */
|
||||
description?: string;
|
||||
/** Server command configuration */
|
||||
serverConfig: {
|
||||
/** Command to run */
|
||||
command: string;
|
||||
/** Command arguments */
|
||||
args?: string[];
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
/** Optional tags for categorization */
|
||||
tags?: string[];
|
||||
/** Category for grouping */
|
||||
category?: string;
|
||||
/** Creation timestamp */
|
||||
createdAt?: number;
|
||||
/** Last update timestamp */
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Template category response
|
||||
*/
|
||||
export interface McpTemplateCategory {
|
||||
/** Category name */
|
||||
name: string;
|
||||
/** Number of templates in category */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Template installation request
|
||||
*/
|
||||
export interface McpTemplateInstallRequest {
|
||||
/** Template name to install */
|
||||
templateName: string;
|
||||
/** Target project path (required for project scope) */
|
||||
projectPath?: string;
|
||||
/** Installation scope */
|
||||
scope: 'global' | 'project';
|
||||
/** Configuration type ('mcp' for .mcp.json, 'claude' for .claude.json) */
|
||||
configType?: 'mcp' | 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* All projects overview response
|
||||
*/
|
||||
export interface AllProjectsResponse {
|
||||
/** List of all project paths */
|
||||
projects: string[];
|
||||
/** Current active project path */
|
||||
currentProject?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Other projects' MCP servers response
|
||||
*/
|
||||
export interface OtherProjectsServersResponse {
|
||||
/** Map of project path to their MCP servers */
|
||||
servers: Record<string, Array<{
|
||||
name: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-CLI MCP server copy request
|
||||
*/
|
||||
export interface CrossCliCopyRequest {
|
||||
/** Source CLI (claude or codex) */
|
||||
source: 'claude' | 'codex';
|
||||
/** Target CLI (claude or codex) */
|
||||
target: 'claude' | 'codex';
|
||||
/** Server names to copy */
|
||||
serverNames: string[];
|
||||
/** Target project path (optional, defaults to current) */
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-CLI copy response
|
||||
*/
|
||||
export interface CrossCliCopyResponse {
|
||||
/** Copy success status */
|
||||
success: boolean;
|
||||
/** Successfully copied servers */
|
||||
copied: string[];
|
||||
/** Failed servers with error messages */
|
||||
failed: Array<{ name: string; error: string }>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user