mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 09:43:26 +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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user