refactor: unify node types into a single PromptTemplate model

- Removed individual node components (SlashCommandNode, FileOperationNode, etc.) and replaced them with a unified PromptTemplateNode.
- Updated flow types and interfaces to reflect the new single node type system.
- Refactored flow execution logic to handle the new unified model, simplifying node execution and context handling.
- Adjusted UI components to support the new PromptTemplateNode, including instruction display and context references.
- Cleaned up legacy code related to removed node types and ensured compatibility with the new structure.
This commit is contained in:
catlog22
2026-02-04 22:22:27 +08:00
parent 113c14970f
commit 4ee165119b
17 changed files with 647 additions and 2017 deletions

View File

@@ -163,70 +163,27 @@
"deleteNode": "Delete Node",
"placeholders": {
"nodeLabel": "Node label",
"commandName": "/command-name",
"commandArgs": "Command arguments",
"timeout": "60000",
"path": "/path/to/file",
"content": "File content...",
"destinationPath": "/path/to/destination",
"variableName": "variableName",
"condition": "e.g., result.success === true",
"trueLabel": "True",
"falseLabel": "False",
"contextTemplate": "Template with {variable} placeholders",
"promptText": "Enter your prompt here..."
"instruction": "e.g., Execute /workflow:plan for login feature\nor: Analyze code architecture\nor: Save {{analysis}} to ./output/result.json",
"outputName": "e.g., analysis, plan, result"
},
"labels": {
"label": "Label",
"command": "Command",
"arguments": "Arguments",
"executionMode": "Execution Mode",
"onError": "On Error",
"timeout": "Timeout (ms)",
"operation": "Operation",
"path": "Path",
"content": "Content",
"destinationPath": "Destination Path",
"outputVariable": "Output Variable",
"addToContext": "Add to context",
"condition": "Condition",
"trueLabel": "True Label",
"falseLabel": "False Label",
"joinMode": "Join Mode",
"failFast": "Fail fast (stop all branches on first error)",
"instruction": "Instruction",
"outputName": "Output Name",
"tool": "CLI Tool",
"mode": "Mode",
"promptType": "Prompt Type",
"sourceNodes": "Source Nodes",
"contextTemplate": "Context Template",
"promptText": "Prompt Text"
"mode": "Execution Mode",
"contextRefs": "Context References"
},
"options": {
"modeMainprocess": "Main Process",
"modeAsync": "Async",
"errorStop": "Stop execution",
"errorContinue": "Continue",
"errorRetry": "Retry",
"operationRead": "Read",
"operationWrite": "Write",
"operationAppend": "Append",
"operationDelete": "Delete",
"operationCopy": "Copy",
"operationMove": "Move",
"joinModeAll": "Wait for all branches",
"joinModeAny": "Complete when any branch finishes",
"joinModeNone": "No synchronization",
"toolNone": "None (auto-select)",
"toolGemini": "Gemini",
"toolQwen": "Qwen",
"toolCodex": "Codex",
"modeAnalysis": "Analysis",
"modeWrite": "Write",
"modeReview": "Review",
"promptTypeOrganize": "Organize",
"promptTypeRefine": "Refine",
"promptTypeSummarize": "Summarize",
"promptTypeTransform": "Transform",
"promptTypeCustom": "Custom"
"toolClaude": "Claude",
"modeAnalysis": "Analysis (read-only)",
"modeWrite": "Write (modify files)",
"modeMainprocess": "Main Process (blocking)",
"modeAsync": "Async (non-blocking)"
}
}
}

View File

@@ -162,70 +162,27 @@
"deleteNode": "删除节点",
"placeholders": {
"nodeLabel": "节点标签",
"commandName": "/命令名称",
"commandArgs": "命令参数",
"timeout": "60000",
"path": "/文件路径",
"content": "文件内容...",
"destinationPath": "/目标路径",
"variableName": "变量名称",
"condition": "例如: result.success === true",
"trueLabel": "真",
"falseLabel": "假",
"contextTemplate": "带有 {variable} 占位符的模板",
"promptText": "在此输入您的提示词..."
"instruction": "例如: 执行 /workflow:plan 用于登录功能\n或: 分析代码架构\n或: 将 {{analysis}} 保存到 ./output/result.json",
"outputName": "例如: analysis, plan, result"
},
"labels": {
"label": "标签",
"command": "令",
"arguments": "参数",
"executionMode": "执行模式",
"onError": "出错时",
"timeout": "超时 (毫秒)",
"operation": "操作",
"path": "路径",
"content": "内容",
"destinationPath": "目标路径",
"outputVariable": "输出变量",
"addToContext": "添加到上下文",
"condition": "条件",
"trueLabel": "真标签",
"falseLabel": "假标签",
"joinMode": "加入模式",
"failFast": "快速失败 (首次错误时停止所有分支)",
"instruction": "令",
"outputName": "输出名称",
"tool": "CLI 工具",
"mode": "模式",
"promptType": "提示词类型",
"sourceNodes": "源节点",
"contextTemplate": "上下文模板",
"promptText": "提示词文本"
"mode": "执行模式",
"contextRefs": "上下文引用"
},
"options": {
"modeMainprocess": "主进程",
"modeAsync": "异步",
"errorStop": "停止执行",
"errorContinue": "继续",
"errorRetry": "重试",
"operationRead": "读取",
"operationWrite": "写入",
"operationAppend": "追加",
"operationDelete": "删除",
"operationCopy": "复制",
"operationMove": "移动",
"joinModeAll": "等待所有分支",
"joinModeAny": "任一分支完成时完成",
"joinModeNone": "无同步",
"toolNone": "无 (自动选择)",
"toolGemini": "Gemini",
"toolQwen": "Qwen",
"toolCodex": "Codex",
"modeAnalysis": "分析",
"modeWrite": "写入",
"modeReview": "审查",
"promptTypeOrganize": "组织",
"promptTypeRefine": "精炼",
"promptTypeSummarize": "总结",
"promptTypeTransform": "转换",
"promptTypeCustom": "自定义"
"toolClaude": "Claude",
"modeAnalysis": "分析 (只读)",
"modeWrite": "写入 (修改文件)",
"modeMainprocess": "主进程 (阻塞)",
"modeAsync": "异步 (非阻塞)"
}
}
}

View File

@@ -23,8 +23,7 @@ import {
import '@xyflow/react/dist/style.css';
import { useFlowStore } from '@/stores';
import type { FlowNodeType, FlowNode, FlowEdge } from '@/types/flow';
import { NODE_TYPE_CONFIGS } from '@/types/flow';
import type { FlowNode, FlowEdge } from '@/types/flow';
// Custom node types (enhanced with execution status in IMPL-A8)
import { nodeTypes } from './nodes';
@@ -116,8 +115,9 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const nodeType = event.dataTransfer.getData('application/reactflow-node-type') as FlowNodeType;
if (!nodeType || !NODE_TYPE_CONFIGS[nodeType]) {
// Verify the drop is from node palette
const nodeType = event.dataTransfer.getData('application/reactflow-node-type');
if (!nodeType) {
return;
}
@@ -127,8 +127,8 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
y: event.clientY,
});
// Add node at drop position
addNode(nodeType, position);
// Add prompt-template node at drop position
addNode(position);
},
[screenToFlowPosition, addNode]
);
@@ -161,24 +161,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
/>
<MiniMap
className="bg-card border border-border rounded-md shadow-sm"
nodeColor={(node) => {
switch (node.type) {
case 'slash-command':
return '#3b82f6'; // blue-500
case 'file-operation':
return '#22c55e'; // green-500
case 'conditional':
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
}
}}
nodeColor={() => '#3b82f6'}
maskColor="rgba(0, 0, 0, 0.1)"
/>
<Background

View File

@@ -5,57 +5,25 @@
import { DragEvent, useState } from 'react';
import { useIntl } from 'react-intl';
import { Terminal, FileText, GitBranch, GitMerge, ChevronDown, ChevronRight, GripVertical } from 'lucide-react';
import { MessageSquare, ChevronDown, ChevronRight, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { useFlowStore } from '@/stores';
import type { FlowNodeType } from '@/types/flow';
import { NODE_TYPE_CONFIGS } from '@/types/flow';
// Icon mapping for node types
const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
'slash-command': Terminal,
'file-operation': FileText,
conditional: GitBranch,
parallel: GitMerge,
'cli-command': Terminal,
prompt: FileText,
};
// Color mapping for node types
const nodeColors: Record<FlowNodeType, string> = {
'slash-command': 'bg-blue-500 hover:bg-blue-600',
'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> = {
'slash-command': 'border-blue-500',
'file-operation': 'border-green-500',
conditional: 'border-amber-500',
parallel: 'border-purple-500',
'cli-command': 'border-amber-500',
prompt: 'border-purple-500',
};
interface NodePaletteProps {
className?: string;
}
interface NodeTypeCardProps {
type: FlowNodeType;
}
function NodeTypeCard({ type }: NodeTypeCardProps) {
const config = NODE_TYPE_CONFIGS[type];
const Icon = nodeIcons[type];
/**
* Draggable card for the unified Prompt Template node type
*/
function PromptTemplateCard() {
const config = NODE_TYPE_CONFIGS['prompt-template'];
// Handle drag start
const onDragStart = (event: DragEvent<HTMLDivElement>) => {
event.dataTransfer.setData('application/reactflow-node-type', type);
event.dataTransfer.setData('application/reactflow-node-type', 'prompt-template');
event.dataTransfer.effectAllowed = 'move';
};
@@ -66,11 +34,11 @@ function NodeTypeCard({ type }: NodeTypeCardProps) {
className={cn(
'group flex items-center gap-3 p-3 rounded-lg border-2 bg-card cursor-grab transition-all',
'hover:shadow-md hover:scale-[1.02] active:cursor-grabbing active:scale-[0.98]',
nodeBorderColors[type]
'border-blue-500'
)}
>
<div className={cn('p-2 rounded-md text-white', nodeColors[type])}>
<Icon className="w-4 h-4" />
<div className="p-2 rounded-md text-white bg-blue-500 hover:bg-blue-600">
<MessageSquare className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">{config.label}</div>
@@ -141,9 +109,7 @@ export function NodePalette({ className }: NodePaletteProps) {
{isExpanded && (
<div className="space-y-2">
{(Object.keys(NODE_TYPE_CONFIGS) as FlowNodeType[]).map((type) => (
<NodeTypeCard key={type} type={type} />
))}
<PromptTemplateCard />
</div>
)}
</div>

View File

@@ -1,30 +1,18 @@
// ========================================
// Property Panel Component
// ========================================
// Dynamic property editor for selected nodes
// Dynamic property editor for unified PromptTemplate nodes
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Settings, X, Terminal, FileText, GitBranch, GitMerge, Trash2 } from 'lucide-react';
import { Settings, X, MessageSquare, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { CommandCombobox } from '@/components/ui/CommandCombobox';
import { MultiNodeSelector, type NodeOption } from '@/components/ui/MultiNodeSelector';
import { ContextAssembler } from '@/components/ui/ContextAssembler';
import { useFlowStore } from '@/stores';
import type {
FlowNodeType,
SlashCommandNodeData,
FileOperationNodeData,
ConditionalNodeData,
ParallelNodeData,
CliCommandNodeData,
PromptNodeData,
NodeData,
} from '@/types/flow';
import type { PromptTemplateNodeData, CliTool, ExecutionMode } from '@/types/flow';
// ========== Common Form Field Components ==========
// ========== Form Field Components ==========
interface LabelInputProps {
value: string;
@@ -47,483 +35,189 @@ function LabelInput({ value, onChange }: LabelInputProps) {
);
}
interface OutputVariableInputProps {
value?: string;
onChange: (value?: string) => void;
// ========== Unified PromptTemplate Property Editor ==========
interface PromptTemplatePropertiesProps {
data: PromptTemplateNodeData;
onChange: (updates: Partial<PromptTemplateNodeData>) => void;
}
function OutputVariableInput({ value, onChange }: OutputVariableInputProps) {
function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesProps) {
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>
const nodes = useFlowStore((state) => state.nodes);
const selectedNodeId = useFlowStore((state) => state.selectedNodeId);
// Build available outputNames from other nodes for contextRefs picker
const availableOutputNames = useMemo(() => {
return nodes
.filter((n) => n.id !== selectedNodeId && n.data?.outputName)
.map((n) => ({
id: n.data.outputName as string,
label: n.data.label || n.id,
}));
}, [nodes, selectedNodeId]);
const toggleContextRef = useCallback(
(outputName: string) => {
const currentRefs = data.contextRefs || [];
const newRefs = currentRefs.includes(outputName)
? currentRefs.filter((ref) => ref !== outputName)
: [...currentRefs, outputName];
onChange({ contextRefs: newRefs });
},
[data.contextRefs, onChange]
);
}
interface PropertyPanelProps {
className?: string;
}
// Icon mapping for node types
const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
'slash-command': Terminal,
'file-operation': FileText,
conditional: GitBranch,
parallel: GitMerge,
'cli-command': Terminal,
prompt: FileText,
};
// Slash Command Property Editor
function SlashCommandProperties({
data,
onChange,
}: {
data: SlashCommandNodeData;
onChange: (updates: Partial<SlashCommandNodeData>) => void;
}) {
const { formatMessage } = useIntl();
return (
<div className="space-y-4">
{/* Label */}
<LabelInput value={data.label} onChange={(value) => onChange({ label: value })} />
{/* Instruction - main textarea */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.command' })}</label>
<CommandCombobox
value={data.command || ''}
onChange={(value) => onChange({ command: value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.commandName' })}
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.instruction' })}
</label>
<textarea
value={data.instruction || ''}
onChange={(e) => onChange({ instruction: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.instruction' })}
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>
{/* Output Name */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.arguments' })}</label>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputName' })}
</label>
<Input
value={data.args || ''}
onChange={(e) => onChange({ args: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.commandArgs' })}
value={data.outputName || ''}
onChange={(e) => onChange({ outputName: e.target.value || undefined })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.outputName' })}
/>
</div>
{/* Tool Select */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.executionMode' })}</label>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.tool' })}
</label>
<select
value={data.execution?.mode || 'mainprocess'}
onChange={(e) =>
onChange({
execution: { ...data.execution, mode: e.target.value as 'mainprocess' | 'async' },
})
}
value={data.tool || ''}
onChange={(e) => onChange({ tool: (e.target.value || undefined) as CliTool | undefined })}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolNone' })}</option>
<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>
<option value="claude">{formatMessage({ id: 'orchestrator.propertyPanel.options.toolClaude' })}</option>
</select>
</div>
{/* Mode Select */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.mode' })}
</label>
<select
value={data.mode || 'mainprocess'}
onChange={(e) => onChange({ mode: e.target.value as ExecutionMode })}
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="mainprocess">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeMainprocess' })}</option>
<option value="async">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeAsync' })}</option>
</select>
</div>
{/* Context References - multi-select */}
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.onError' })}</label>
<select
value={data.onError || 'stop'}
onChange={(e) => onChange({ onError: e.target.value as 'continue' | 'stop' | 'retry' })}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="stop">{formatMessage({ id: 'orchestrator.propertyPanel.options.errorStop' })}</option>
<option value="continue">{formatMessage({ id: 'orchestrator.propertyPanel.options.errorContinue' })}</option>
<option value="retry">{formatMessage({ id: 'orchestrator.propertyPanel.options.errorRetry' })}</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,
mode: data.execution?.mode || 'mainprocess',
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>
);
}
// File Operation Property Editor
function FileOperationProperties({
data,
onChange,
}: {
data: FileOperationNodeData;
onChange: (updates: Partial<FileOperationNodeData>) => 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.operation' })}</label>
<select
value={data.operation || 'read'}
onChange={(e) =>
onChange({
operation: e.target.value as FileOperationNodeData['operation'],
})
}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="read">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationRead' })}</option>
<option value="write">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationWrite' })}</option>
<option value="append">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationAppend' })}</option>
<option value="delete">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationDelete' })}</option>
<option value="copy">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationCopy' })}</option>
<option value="move">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationMove' })}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.path' })}</label>
<Input
value={data.path || ''}
onChange={(e) => onChange({ path: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.path' })}
className="font-mono"
/>
</div>
{(data.operation === 'write' || data.operation === 'append') && (
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.content' })}</label>
<textarea
value={data.content || ''}
onChange={(e) => onChange({ content: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.content' })}
className="w-full h-24 px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none font-mono"
/>
</div>
)}
{(data.operation === 'copy' || data.operation === 'move') && (
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.destinationPath' })}</label>
<Input
value={data.destinationPath || ''}
onChange={(e) => onChange({ destinationPath: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.destinationPath' })}
className="font-mono"
/>
</div>
)}
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
<div className="flex items-center gap-2">
<input
type="checkbox"
id="addToContext"
checked={data.addToContext || false}
onChange={(e) => onChange({ addToContext: e.target.checked })}
className="rounded border-border"
/>
<label htmlFor="addToContext" className="text-sm text-foreground">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.addToContext' })}
<label className="block text-sm font-medium text-foreground mb-1">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.contextRefs' })}
</label>
</div>
</div>
);
}
<div className="space-y-2">
{/* Selected refs tags */}
{data.contextRefs && data.contextRefs.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 rounded-md border border-border bg-muted/30">
{data.contextRefs.map((ref) => {
const node = availableOutputNames.find((n) => n.id === ref);
return (
<span
key={ref}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md bg-primary text-primary-foreground text-xs"
>
<span>{node?.label || ref}</span>
<button
type="button"
onClick={() => toggleContextRef(ref)}
className="hover:bg-primary-foreground/20 rounded p-0.5"
>
<X className="w-3 h-3" />
</button>
</span>
);
})}
<button
type="button"
onClick={() => onChange({ contextRefs: [] })}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
{formatMessage({ id: 'orchestrator.multiNodeSelector.clear' })}
</button>
</div>
)}
// Conditional Property Editor
function ConditionalProperties({
data,
onChange,
}: {
data: ConditionalNodeData;
onChange: (updates: Partial<ConditionalNodeData>) => 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.condition' })}</label>
<textarea
value={data.condition || ''}
onChange={(e) => onChange({ condition: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.condition' })}
className="w-full h-20 px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.trueLabel' })}</label>
<Input
value={data.trueLabel || ''}
onChange={(e) => onChange({ trueLabel: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.trueLabel' })}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.falseLabel' })}</label>
<Input
value={data.falseLabel || ''}
onChange={(e) => onChange({ falseLabel: e.target.value })}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.falseLabel' })}
/>
{/* Available outputs list */}
<div className="border border-border rounded-md bg-background max-h-32 overflow-y-auto">
{availableOutputNames.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
{formatMessage({ id: 'orchestrator.variablePicker.empty' })}
</div>
) : (
<div className="p-1">
{availableOutputNames.map((item) => {
const isSelected = data.contextRefs?.includes(item.id);
return (
<div
key={item.id}
onClick={() => toggleContextRef(item.id)}
className={cn(
'flex items-center gap-2 p-2 rounded cursor-pointer transition-colors',
'hover:bg-muted',
isSelected && 'bg-muted'
)}
>
<div
className={cn(
'w-4 h-4 rounded border flex items-center justify-center',
isSelected ? 'bg-primary border-primary' : 'border-border'
)}
>
{isSelected && <span className="text-primary-foreground text-xs">+</span>}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">{item.label}</div>
<div className="text-xs text-muted-foreground font-mono">{'{{' + item.id + '}}'}</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
<OutputVariableInput value={data.outputVariable} onChange={(value) => onChange({ outputVariable: value })} />
</div>
);
}
// Parallel Property Editor
function ParallelProperties({
data,
onChange,
}: {
data: ParallelNodeData;
onChange: (updates: Partial<ParallelNodeData>) => void;
}) {
const { formatMessage } = useIntl();
// ========== Main PropertyPanel Component ==========
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.joinMode' })}</label>
<select
value={data.joinMode || 'all'}
onChange={(e) =>
onChange({ joinMode: e.target.value as ParallelNodeData['joinMode'] })
}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value="all">{formatMessage({ id: 'orchestrator.propertyPanel.options.joinModeAll' })}</option>
<option value="any">{formatMessage({ id: 'orchestrator.propertyPanel.options.joinModeAny' })}</option>
<option value="none">{formatMessage({ id: 'orchestrator.propertyPanel.options.joinModeNone' })}</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.timeout || ''}
onChange={(e) =>
onChange({ timeout: e.target.value ? parseInt(e.target.value) : undefined })
}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.timeout' })}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="failFast"
checked={data.failFast || false}
onChange={(e) => onChange({ failFast: e.target.checked })}
className="rounded border-border"
/>
<label htmlFor="failFast" className="text-sm text-foreground">
{formatMessage({ id: 'orchestrator.propertyPanel.labels.failFast' })}
</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.command' })}</label>
<Input
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>
);
interface PropertyPanelProps {
className?: string;
}
export function PropertyPanel({ className }: PropertyPanelProps) {
@@ -538,7 +232,7 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
const handleChange = useCallback(
(updates: Partial<NodeData>) => {
(updates: Partial<PromptTemplateNodeData>) => {
if (selectedNodeId) {
updateNode(selectedNodeId, updates);
}
@@ -552,6 +246,7 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
}
}, [selectedNodeId, removeNode]);
// Collapsed state
if (!isPropertyPanelOpen) {
return (
<div className={cn('w-10 bg-card border-l border-border flex flex-col items-center py-4', className)}>
@@ -567,10 +262,10 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
);
}
// No node selected
if (!selectedNode) {
return (
<div className={cn('w-72 bg-card border-l border-border flex flex-col', className)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="font-semibold text-foreground">{formatMessage({ id: 'orchestrator.propertyPanel.title' })}</h3>
<Button
@@ -583,8 +278,6 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
<X className="w-4 h-4" />
</Button>
</div>
{/* Empty State */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center text-muted-foreground">
<Settings className="w-12 h-12 mx-auto mb-2 opacity-50" />
@@ -595,15 +288,12 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
);
}
const nodeType = selectedNode.type as FlowNodeType;
const Icon = nodeIcons[nodeType];
return (
<div className={cn('w-72 bg-card border-l border-border flex flex-col', className)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
{Icon && <Icon className="w-4 h-4 text-primary" />}
<MessageSquare className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-foreground">{formatMessage({ id: 'orchestrator.propertyPanel.title' })}</h3>
</div>
<Button
@@ -620,57 +310,21 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
{/* Node Type Badge */}
<div className="px-4 py-2 border-b border-border bg-muted/30">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{nodeType.replace('-', ' ')}
prompt template
</span>
</div>
{/* Properties Form */}
{/* Properties Form - unified for all nodes */}
<div className="flex-1 overflow-y-auto p-4">
{nodeType === 'slash-command' && (
<SlashCommandProperties
data={selectedNode.data as SlashCommandNodeData}
onChange={handleChange}
/>
)}
{nodeType === 'file-operation' && (
<FileOperationProperties
data={selectedNode.data as FileOperationNodeData}
onChange={handleChange}
/>
)}
{nodeType === 'conditional' && (
<ConditionalProperties
data={selectedNode.data as ConditionalNodeData}
onChange={handleChange}
/>
)}
{nodeType === 'parallel' && (
<ParallelProperties
data={selectedNode.data as ParallelNodeData}
onChange={handleChange}
/>
)}
{nodeType === 'cli-command' && (
<CliCommandProperties
data={selectedNode.data as CliCommandNodeData}
onChange={handleChange}
/>
)}
{nodeType === 'prompt' && (
<PromptProperties
data={selectedNode.data as PromptNodeData}
onChange={handleChange}
/>
)}
<PromptTemplateProperties
data={selectedNode.data as PromptTemplateNodeData}
onChange={handleChange}
/>
</div>
{/* Delete Button */}
<div className="px-4 py-3 border-t border-border">
<Button
variant="destructive"
className="w-full"
onClick={handleDelete}
>
<Button variant="destructive" className="w-full" onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-2" />
{formatMessage({ id: 'orchestrator.propertyPanel.deleteNode' })}
</Button>

View File

@@ -1,112 +0,0 @@
// ========================================
// 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';

View File

@@ -1,118 +0,0 @@
// ========================================
// Conditional Node Component
// ========================================
// Custom node for conditional branching with true/false outputs
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { GitBranch, Check, X } from 'lucide-react';
import type { ConditionalNodeData } from '@/types/flow';
import { NodeWrapper } from './NodeWrapper';
interface ConditionalNodeProps {
data: ConditionalNodeData;
selected?: boolean;
}
export const ConditionalNode = memo(({ data, selected }: ConditionalNodeProps) => {
// Truncate condition for display
const displayCondition = data.condition
? data.condition.length > 30
? data.condition.slice(0, 27) + '...'
: data.condition
: 'No condition';
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">
<GitBranch className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{data.label || 'Condition'}
</span>
</div>
{/* Node Content */}
<div className="px-3 py-2 space-y-2">
{/* Condition expression */}
<div
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
title={data.condition}
>
{displayCondition}
</div>
{/* Branch labels */}
<div className="flex justify-between items-center pt-1">
<div className="flex items-center gap-1">
<Check className="w-3 h-3 text-green-500" />
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
{data.trueLabel || 'True'}
</span>
</div>
<div className="flex items-center gap-1">
<X className="w-3 h-3 text-red-500" />
<span className="text-xs text-red-600 dark:text-red-400 font-medium">
{data.falseLabel || 'False'}
</span>
</div>
</div>
{/* Execution result indicator */}
{data.executionStatus === 'completed' && data.executionResult !== undefined && (
<div className="text-[10px] text-muted-foreground text-center">
Result:{' '}
<span
className={
data.executionResult
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}
>
{data.executionResult ? 'true' : 'false'}
</span>
</div>
)}
{/* Execution error message */}
{data.executionStatus === 'failed' && data.executionError && (
<div
className="text-[10px] text-destructive truncate"
title={data.executionError}
>
{data.executionError}
</div>
)}
</div>
{/* Output Handles (True and False) */}
<Handle
type="source"
position={Position.Bottom}
id="true"
className="!w-3 !h-3 !bg-green-500 !border-2 !border-background"
style={{ left: '30%' }}
/>
<Handle
type="source"
position={Position.Bottom}
id="false"
className="!w-3 !h-3 !bg-red-500 !border-2 !border-background"
style={{ left: '70%' }}
/>
</NodeWrapper>
);
});
ConditionalNode.displayName = 'ConditionalNode';

View File

@@ -1,145 +0,0 @@
// ========================================
// File Operation Node Component
// ========================================
// Custom node for file read/write operations
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import {
FileText,
FileInput,
FileOutput,
FilePlus,
FileX,
Copy,
Move,
} from 'lucide-react';
import type { FileOperationNodeData } from '@/types/flow';
import { NodeWrapper } from './NodeWrapper';
import { cn } from '@/lib/utils';
interface FileOperationNodeProps {
data: FileOperationNodeData;
selected?: boolean;
}
// Operation icons and colors
const OPERATION_CONFIG: Record<
string,
{ icon: React.ElementType; label: string; color: string }
> = {
read: { icon: FileInput, label: 'Read', color: 'text-blue-500' },
write: { icon: FileOutput, label: 'Write', color: 'text-amber-500' },
append: { icon: FilePlus, label: 'Append', color: 'text-green-500' },
delete: { icon: FileX, label: 'Delete', color: 'text-red-500' },
copy: { icon: Copy, label: 'Copy', color: 'text-purple-500' },
move: { icon: Move, label: 'Move', color: 'text-indigo-500' },
};
export const FileOperationNode = memo(({ data, selected }: FileOperationNodeProps) => {
const operation = data.operation || 'read';
const config = OPERATION_CONFIG[operation] || OPERATION_CONFIG.read;
const IconComponent = config.icon;
// Truncate path for display
const displayPath = data.path
? data.path.length > 25
? '...' + data.path.slice(-22)
: data.path
: '';
return (
<NodeWrapper
status={data.executionStatus}
selected={selected}
accentColor="green"
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Top}
className="!w-3 !h-3 !bg-green-500 !border-2 !border-background"
/>
{/* Node Header */}
<div className="flex items-center gap-2 px-3 py-2 bg-green-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 || 'File Operation'}
</span>
</div>
{/* Node Content */}
<div className="px-3 py-2 space-y-1.5">
{/* Operation type with icon */}
<div className="flex items-center gap-1.5">
<IconComponent className={cn('w-3.5 h-3.5', config.color)} />
<span className="text-xs font-medium text-foreground">
{config.label}
</span>
</div>
{/* File path */}
{data.path && (
<div
className="text-xs text-muted-foreground font-mono truncate max-w-[160px]"
title={data.path}
>
{displayPath}
</div>
)}
{/* Badges row */}
<div className="flex items-center gap-1 flex-wrap">
{/* Add to context badge */}
{data.addToContext && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
+ context
</span>
)}
{/* Output variable badge */}
{data.outputVariable && (
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 truncate max-w-[80px]"
title={data.outputVariable}
>
${data.outputVariable}
</span>
)}
</div>
{/* Destination path for copy/move */}
{(operation === 'copy' || operation === 'move') && data.destinationPath && (
<div className="text-[10px] text-muted-foreground">
To:{' '}
<span className="font-mono text-foreground/70" title={data.destinationPath}>
{data.destinationPath.length > 20
? '...' + data.destinationPath.slice(-17)
: data.destinationPath}
</span>
</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-green-500 !border-2 !border-background"
/>
</NodeWrapper>
);
});
FileOperationNode.displayName = 'FileOperationNode';

View File

@@ -1,129 +0,0 @@
// ========================================
// Parallel Node Component
// ========================================
// Custom node for parallel execution with multiple branch outputs
import { memo, useMemo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { GitMerge, Layers, Timer, AlertTriangle } from 'lucide-react';
import type { ParallelNodeData } from '@/types/flow';
import { NodeWrapper } from './NodeWrapper';
import { cn } from '@/lib/utils';
interface ParallelNodeProps {
data: ParallelNodeData;
selected?: boolean;
}
// Join mode configuration
const JOIN_MODE_CONFIG: Record<string, { label: string; color: string }> = {
all: { label: 'Wait All', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
any: { label: 'Wait Any', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
none: { label: 'Fire & Forget', color: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400' },
};
export const ParallelNode = memo(({ data, selected }: ParallelNodeProps) => {
const joinMode = data.joinMode || 'all';
const branchCount = Math.max(2, Math.min(data.branchCount || 2, 5)); // Clamp between 2-5
const joinConfig = JOIN_MODE_CONFIG[joinMode] || JOIN_MODE_CONFIG.all;
// Calculate branch handle positions
const branchPositions = useMemo(() => {
const positions: number[] = [];
const step = 100 / (branchCount + 1);
for (let i = 1; i <= branchCount; i++) {
positions.push(step * i);
}
return positions;
}, [branchCount]);
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">
<GitMerge className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{data.label || 'Parallel'}
</span>
{/* Branch count indicator */}
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded">
{branchCount}x
</span>
</div>
{/* Node Content */}
<div className="px-3 py-2 space-y-2">
{/* Join mode badge */}
<div className="flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', joinConfig.color)}>
{joinConfig.label}
</span>
</div>
{/* Additional settings row */}
<div className="flex items-center gap-2 flex-wrap">
{/* Timeout indicator */}
{data.timeout && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Timer className="w-3 h-3" />
<span>{data.timeout}ms</span>
</div>
)}
{/* Fail fast indicator */}
{data.failFast && (
<div className="flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3 h-3" />
<span>Fail Fast</span>
</div>
)}
</div>
{/* Branch labels */}
<div className="flex justify-between text-[10px] text-muted-foreground pt-1">
{branchPositions.map((_, index) => (
<span key={index} className="text-purple-600 dark:text-purple-400">
B{index + 1}
</span>
))}
</div>
{/* Execution error message */}
{data.executionStatus === 'failed' && data.executionError && (
<div
className="text-[10px] text-destructive truncate"
title={data.executionError}
>
{data.executionError}
</div>
)}
</div>
{/* Dynamic Branch Output Handles */}
{branchPositions.map((position, index) => (
<Handle
key={`branch-${index + 1}`}
type="source"
position={Position.Bottom}
id={`branch-${index + 1}`}
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
style={{ left: `${position}%` }}
/>
))}
</NodeWrapper>
);
});
ParallelNode.displayName = 'ParallelNode';

View File

@@ -1,120 +0,0 @@
// ========================================
// 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';

View File

@@ -0,0 +1,132 @@
// ========================================
// Prompt Template Node Component
// ========================================
// Unified node component for all workflow steps
// Replaces: SlashCommandNode, CliCommandNode, PromptNode,
// FileOperationNode, ConditionalNode, ParallelNode
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { MessageSquare, Link2 } from 'lucide-react';
import type { PromptTemplateNodeData } from '@/types/flow';
import { NodeWrapper } from './NodeWrapper';
import { cn } from '@/lib/utils';
interface PromptTemplateNodeProps {
data: PromptTemplateNodeData;
selected?: boolean;
}
// Mode badge styling
const MODE_STYLES: Record<string, string> = {
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',
mainprocess: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
async: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
};
// Tool badge styling
const TOOL_STYLES: Record<string, string> = {
gemini: 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400',
qwen: 'bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400',
codex: 'bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400',
claude: 'bg-amber-50 text-amber-600 dark:bg-amber-900/20 dark:text-amber-400',
};
export const PromptTemplateNode = memo(({ data, selected }: PromptTemplateNodeProps) => {
// Truncate instruction for display (max 50 chars)
const displayInstruction = data.instruction
? data.instruction.length > 50
? data.instruction.slice(0, 47) + '...'
: data.instruction
: 'No instruction';
const hasContextRefs = data.contextRefs && data.contextRefs.length > 0;
return (
<NodeWrapper
status={data.executionStatus}
selected={selected}
accentColor="blue"
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Top}
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-background"
/>
{/* Node Header */}
<div className="flex items-center gap-2 px-3 py-2 bg-blue-500 text-white rounded-t-md">
<MessageSquare className="w-4 h-4 shrink-0" />
<span className="text-sm font-medium truncate flex-1">
{data.label || 'Step'}
</span>
{/* Mode badge in header */}
{data.mode && (
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', MODE_STYLES[data.mode])}>
{data.mode}
</span>
)}
</div>
{/* Node Content */}
<div className="px-3 py-2 space-y-1.5">
{/* Instruction preview */}
<div
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
title={data.instruction}
>
{displayInstruction}
</div>
{/* Tool and output badges row */}
<div className="flex items-center gap-1.5 flex-wrap">
{/* Tool badge */}
{data.tool && (
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded border', TOOL_STYLES[data.tool])}>
{data.tool}
</span>
)}
{/* Output name badge */}
{data.outputName && (
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 truncate max-w-[80px]"
title={data.outputName}
>
-&gt; {data.outputName}
</span>
)}
</div>
{/* Context refs indicator */}
{hasContextRefs && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Link2 className="w-3 h-3" />
<span>{data.contextRefs!.length} ref{data.contextRefs!.length !== 1 ? 's' : ''}</span>
</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-blue-500 !border-2 !border-background"
/>
</NodeWrapper>
);
});
PromptTemplateNode.displayName = 'PromptTemplateNode';

View File

@@ -1,100 +0,0 @@
// ========================================
// Slash Command Node Component
// ========================================
// Custom node for executing CCW slash commands
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { Terminal } from 'lucide-react';
import type { SlashCommandNodeData } from '@/types/flow';
import { NodeWrapper } from './NodeWrapper';
import { cn } from '@/lib/utils';
interface SlashCommandNodeProps {
data: SlashCommandNodeData;
selected?: boolean;
}
// Mode badge styling
const MODE_STYLES = {
mainprocess: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
async: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
};
export const SlashCommandNode = memo(({ data, selected }: SlashCommandNodeProps) => {
const executionMode = data.execution?.mode || 'mainprocess';
return (
<NodeWrapper
status={data.executionStatus}
selected={selected}
accentColor="blue"
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Top}
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-background"
/>
{/* Node Header */}
<div className="flex items-center gap-2 px-3 py-2 bg-blue-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 || 'Command'}
</span>
{/* Execution mode badge */}
<span
className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded',
MODE_STYLES[executionMode]
)}
>
{executionMode}
</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">
/{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>
)}
{/* Error handling indicator */}
{data.onError && data.onError !== 'stop' && (
<div className="text-[10px] text-muted-foreground">
On error: <span className="text-foreground">{data.onError}</span>
</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-blue-500 !border-2 !border-background"
/>
</NodeWrapper>
);
});
SlashCommandNode.displayName = 'SlashCommandNode';

View File

@@ -1,32 +1,18 @@
// ========================================
// Node Components Barrel Export
// ========================================
// Unified PromptTemplate node system
// Shared wrapper component
export { NodeWrapper } from './NodeWrapper';
// Custom node components
export { SlashCommandNode } from './SlashCommandNode';
export { FileOperationNode } from './FileOperationNode';
export { ConditionalNode } from './ConditionalNode';
export { ParallelNode } from './ParallelNode';
export { CliCommandNode } from './CliCommandNode';
export { PromptNode } from './PromptNode';
// Unified prompt template node component
export { PromptTemplateNode } from './PromptTemplateNode';
// 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';
import { PromptTemplateNode } from './PromptTemplateNode';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const nodeTypes: Record<string, any> = {
'slash-command': SlashCommandNode,
'file-operation': FileOperationNode,
conditional: ConditionalNode,
parallel: ParallelNode,
'cli-command': CliCommandNode,
prompt: PromptNode,
'prompt-template': PromptTemplateNode,
};

View File

@@ -10,7 +10,6 @@ import type {
Flow,
FlowNode,
FlowEdge,
FlowNodeType,
NodeData,
FlowEdgeData,
} from '../types/flow';
@@ -234,13 +233,13 @@ export const useFlowStore = create<FlowStore>()(
// ========== Node Operations ==========
addNode: (type: FlowNodeType, position: { x: number; y: number }): string => {
const config = nodeConfigs[type];
addNode: (position: { x: number; y: number }): string => {
const config = nodeConfigs['prompt-template'];
const id = generateId('node');
const newNode: FlowNode = {
id,
type,
type: 'prompt-template',
position,
data: { ...config.defaultData },
};

View File

@@ -2,93 +2,133 @@
// Flow Types
// ========================================
// TypeScript interfaces for Orchestrator flow editor
// Unified PromptTemplate model - all nodes are prompt templates
// See: .workflow/.analysis/ANL-前端编排器与skill设计简化分析-2026-02-04/conclusions.json
import type { Node, Edge } from '@xyflow/react';
// ========== Node Types ==========
export type FlowNodeType = 'slash-command' | 'file-operation' | 'conditional' | 'parallel' | 'cli-command' | 'prompt';
/**
* Single unified node type - all nodes are prompt templates
* This replaces the previous 6-type system (slash-command, file-operation,
* conditional, parallel, cli-command, prompt) with a single unified model.
*/
export type FlowNodeType = 'prompt-template';
// Execution status for nodes during workflow execution
/**
* Execution status for nodes during workflow execution
*/
export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed';
// Base interface for all node data - must have index signature for React Flow compatibility
interface BaseNodeData {
/**
* Available CLI tools for execution
*/
export type CliTool = 'gemini' | 'qwen' | 'codex' | 'claude';
/**
* Execution modes for prompt templates
* - analysis: Read-only operations, code review, exploration
* - write: Create/modify/delete files
* - mainprocess: Execute in main process (blocking)
* - async: Execute asynchronously (non-blocking)
*/
export type ExecutionMode = 'analysis' | 'write' | 'mainprocess' | 'async';
/**
* Unified PromptTemplate node data model
*
* All workflow nodes are represented as prompt templates with natural language
* instructions. This model replaces the previous 6 specialized node types:
* - slash-command -> instruction: "Execute /command args"
* - cli-command -> instruction + tool + mode
* - file-operation -> instruction: "Save {{ref}} to path"
* - conditional -> instruction: "If {{condition}} then..."
* - parallel -> instruction: "Execute in parallel..."
* - prompt -> instruction (direct)
*
* @example Slash command equivalent
* { instruction: "Execute /workflow:plan for login feature", outputName: "plan", mode: "mainprocess" }
*
* @example CLI command equivalent
* { instruction: "Analyze code architecture", outputName: "analysis", tool: "gemini", mode: "analysis" }
*
* @example File operation equivalent
* { instruction: "Save {{analysis}} to ./output/result.json", contextRefs: ["analysis"] }
*
* @example Conditional equivalent
* { instruction: "If {{prev.success}} is true, continue; otherwise stop", contextRefs: ["prev"] }
*/
export interface PromptTemplateNodeData {
/**
* Display label for the node in the editor
*/
label: string;
/**
* Natural language instruction describing what to execute
* Can include context references using {{variableName}} syntax
*/
instruction: string;
/**
* Optional name for the output, allowing subsequent steps to reference it
* via contextRefs or {{outputName}} syntax in instructions
*/
outputName?: string;
/**
* Optional CLI tool to use for execution
* If not specified, the system selects based on task requirements
*/
tool?: CliTool;
/**
* Optional execution mode
* Defaults to 'mainprocess' if not specified
*/
mode?: ExecutionMode;
/**
* References to outputs from previous steps
* Use the outputName values from earlier nodes
*/
contextRefs?: string[];
// ========== Execution State Fields ==========
/**
* Current execution status of this node
*/
executionStatus?: ExecutionStatus;
/**
* Error message if execution failed
*/
executionError?: string;
/**
* Result data from execution
*/
executionResult?: unknown;
outputVariable?: string;
/**
* Index signature for React Flow compatibility
*/
[key: string]: unknown;
}
// Slash Command Node Data
export interface SlashCommandNodeData extends BaseNodeData {
command: string;
args?: string;
execution: {
mode: 'mainprocess' | 'async';
timeout?: number;
};
contextHint?: string;
onError?: 'continue' | 'stop' | 'retry';
}
/**
* NodeData type - unified to single PromptTemplateNodeData
* @deprecated Individual node data types are deprecated.
* Use PromptTemplateNodeData directly.
*/
export type NodeData = PromptTemplateNodeData;
// File Operation Node Data
export interface FileOperationNodeData extends BaseNodeData {
operation: 'read' | 'write' | 'append' | 'delete' | 'copy' | 'move';
path: string;
content?: string;
destinationPath?: string;
encoding?: 'utf8' | 'ascii' | 'base64';
addToContext?: boolean;
}
// Conditional Node Data
export interface ConditionalNodeData extends BaseNodeData {
condition: string;
trueLabel?: string;
falseLabel?: string;
}
// Parallel Node Data
export interface ParallelNodeData extends BaseNodeData {
joinMode: 'all' | 'any' | 'none';
branchCount?: number; // Number of parallel branches (default: 2)
timeout?: number;
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
| CliCommandNodeData
| PromptNodeData;
// Extended Node type for React Flow
export type FlowNode = Node<NodeData, FlowNodeType>;
/**
* Extended Node type for React Flow with unified PromptTemplate model
*/
export type FlowNode = Node<PromptTemplateNodeData, FlowNodeType>;
// ========== Edge Types ==========
@@ -156,7 +196,7 @@ export interface FlowActions {
duplicateFlow: (id: string) => Promise<Flow | null>;
// Node operations
addNode: (type: FlowNodeType, position: { x: number; y: number }) => string;
addNode: (position: { x: number; y: number }) => string;
updateNode: (id: string, data: Partial<NodeData>) => void;
removeNode: (id: string) => void;
setNodes: (nodes: FlowNode[]) => void;
@@ -188,102 +228,41 @@ export type FlowStore = FlowState & FlowActions;
// ========== Node Type Configuration ==========
/**
* Configuration for the unified prompt-template node type
*/
export interface NodeTypeConfig {
type: FlowNodeType;
label: string;
description: string;
icon: string;
color: string;
defaultData: NodeData;
defaultData: PromptTemplateNodeData;
handles: {
inputs: number;
outputs: number;
};
}
/**
* Single unified node type configuration
* Replaces the previous 6 separate configurations
*/
export const NODE_TYPE_CONFIGS: Record<FlowNodeType, NodeTypeConfig> = {
'slash-command': {
type: 'slash-command',
label: 'Slash Command',
description: 'Execute CCW slash commands',
icon: 'Terminal',
'prompt-template': {
type: 'prompt-template',
label: 'Prompt Template',
description: 'Natural language instruction for workflow step',
icon: 'MessageSquare',
color: 'bg-blue-500',
defaultData: {
label: 'New Command',
command: '',
args: '',
execution: { mode: 'mainprocess' },
onError: 'stop',
} as SlashCommandNodeData,
handles: { inputs: 1, outputs: 1 },
},
'file-operation': {
type: 'file-operation',
label: 'File Operation',
description: 'Read/write/delete files',
icon: 'FileText',
color: 'bg-green-500',
defaultData: {
label: 'File Operation',
operation: 'read',
path: '',
addToContext: false,
} as FileOperationNodeData,
handles: { inputs: 1, outputs: 1 },
},
conditional: {
type: 'conditional',
label: 'Conditional',
description: 'Branch based on condition',
icon: 'GitBranch',
color: 'bg-amber-500',
defaultData: {
label: 'Condition',
condition: '',
trueLabel: 'True',
falseLabel: 'False',
} as ConditionalNodeData,
handles: { inputs: 1, outputs: 2 },
},
parallel: {
type: 'parallel',
label: 'Parallel',
description: 'Execute branches in parallel',
icon: 'GitMerge',
color: 'bg-purple-500',
defaultData: {
label: 'Parallel',
joinMode: 'all',
failFast: false,
} 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,
label: 'New Step',
instruction: '',
outputName: undefined,
tool: undefined,
mode: undefined,
contextRefs: [],
},
handles: { inputs: 1, outputs: 1 },
},
};

View File

@@ -40,63 +40,101 @@ const __dirname = dirname(__filename);
// ============================================================================
/**
* Node types supported by the orchestrator
* Unified node type - all nodes are prompt templates
* Replaces previous 6-type system with single unified model
*/
export type FlowNodeType = 'slash-command' | 'file-operation' | 'conditional' | 'parallel';
export type FlowNodeType = 'prompt-template';
/**
* SlashCommand node data
* Available CLI tools for execution
*/
export interface SlashCommandNodeData {
command: string;
args?: string;
execution: {
mode: 'mainprocess' | 'async';
timeout?: number;
};
contextHint?: string;
export type CliTool = 'gemini' | 'qwen' | 'codex' | 'claude';
/**
* Execution modes for prompt templates
* - analysis: Read-only operations, code review, exploration
* - write: Create/modify/delete files
* - mainprocess: Execute in main process (blocking)
* - async: Execute asynchronously (non-blocking)
*/
export type ExecutionMode = 'analysis' | 'write' | 'mainprocess' | 'async';
/**
* Unified PromptTemplate node data model
*
* All workflow nodes are represented as prompt templates with natural language
* instructions. This model replaces the previous specialized node types:
* - slash-command -> instruction: "Execute /command args"
* - cli-command -> instruction + tool + mode
* - file-operation -> instruction: "Save {{ref}} to path"
* - conditional -> instruction: "If {{condition}} then..."
* - parallel -> instruction: "Execute in parallel..."
* - prompt -> instruction (direct)
*/
export interface PromptTemplateNodeData {
/**
* Display label for the node in the editor
*/
label: string;
/**
* Natural language instruction describing what to execute
* Can include context references using {{variableName}} syntax
*/
instruction: string;
/**
* Optional name for the output, allowing subsequent steps to reference it
* via contextRefs or {{outputName}} syntax in instructions
*/
outputName?: string;
/**
* Optional CLI tool to use for execution
* If not specified, the system selects based on task requirements
*/
tool?: CliTool;
/**
* Optional execution mode
* Defaults to 'mainprocess' if not specified
*/
mode?: ExecutionMode;
/**
* References to outputs from previous steps
* Use the outputName values from earlier nodes
*/
contextRefs?: string[];
/**
* Error handling behavior
*/
onError?: 'continue' | 'pause' | 'fail';
// ========== Execution State Fields ==========
/**
* Current execution status of this node
* Uses same values as NodeExecutionStatus defined below
*/
executionStatus?: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
/**
* Error message if execution failed
*/
executionError?: string;
/**
* Result data from execution
*/
executionResult?: unknown;
}
/**
* FileOperation node data
* NodeData type - unified to single PromptTemplateNodeData
*/
export interface FileOperationNodeData {
operation: 'read' | 'write' | 'append' | 'delete' | 'copy' | 'move';
path: string;
content?: string;
destinationPath?: string;
encoding?: string;
outputVariable?: string;
addToContext: boolean;
}
/**
* Conditional node data
*/
export interface ConditionalNodeData {
condition: string;
trueLabel?: string;
falseLabel?: string;
}
/**
* Parallel node data
*/
export interface ParallelNodeData {
joinMode: 'all' | 'any' | 'none';
timeout?: number;
failFast?: boolean;
}
/**
* Union type for all node data types
*/
export type NodeData =
| SlashCommandNodeData
| FileOperationNodeData
| ConditionalNodeData
| ParallelNodeData;
export type NodeData = PromptTemplateNodeData;
/**
* Flow node definition

View File

@@ -14,9 +14,9 @@
* - cli-executor for slash-command execution
*/
import { readFile, writeFile, mkdir, unlink, copyFile, rename } from 'fs/promises';
import { readFile, writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join, dirname } from 'path';
import { join } from 'path';
import { broadcastToClients } from '../websocket.js';
import { executeCliTool } from '../../tools/cli-executor-core.js';
import type {
@@ -25,14 +25,13 @@ import type {
FlowEdge,
FlowNodeType,
ExecutionState,
ExecutionStatus,
ExecutionStatus as RouteExecutionStatus,
NodeExecutionState,
NodeExecutionStatus,
ExecutionLog,
SlashCommandNodeData,
FileOperationNodeData,
ConditionalNodeData,
ParallelNodeData,
PromptTemplateNodeData,
CliTool,
ExecutionMode,
} from '../routes/orchestrator-routes.js';
// ============================================================================
@@ -161,11 +160,17 @@ export function interpolateObject<T>(obj: T, variables: Record<string, unknown>)
}
// ============================================================================
// NodeRunner - Type-specific Node Execution
// NodeRunner - Unified Prompt Template Execution
// ============================================================================
/**
* NodeRunner executes individual nodes based on their type
* Default CLI tool when not specified
*/
const DEFAULT_CLI_TOOL: CliTool = 'claude';
/**
* NodeRunner executes unified prompt-template nodes
* All nodes are interpreted through natural language instructions
*/
export class NodeRunner {
private context: ExecutionContext;
@@ -176,44 +181,37 @@ export class NodeRunner {
/**
* Execute a node and return the result
* All nodes are prompt-template type
*/
async run(node: FlowNode): Promise<NodeResult> {
switch (node.type) {
case 'slash-command':
return this.runSlashCommand(node);
case 'file-operation':
return this.runFileOperation(node);
case 'conditional':
return this.runConditional(node);
case 'parallel':
return this.runParallel(node);
default:
return {
success: false,
error: `Unknown node type: ${(node as FlowNode).type}`
};
// All nodes are prompt-template type
if (node.type === 'prompt-template') {
return this.runPromptTemplate(node);
}
// Fallback for any legacy node types
return {
success: false,
error: `Unsupported node type: ${node.type}. Only 'prompt-template' is supported.`
};
}
/**
* Execute a slash-command node
* Integrates with executeCliTool from cli-executor
* Execute a prompt-template node
* Interprets instruction field to build and execute CLI command
*/
private async runSlashCommand(node: FlowNode): Promise<NodeResult> {
const data = node.data as SlashCommandNodeData;
private async runPromptTemplate(node: FlowNode): Promise<NodeResult> {
const data = node.data as PromptTemplateNodeData;
// Interpolate command and args
const command = interpolate(data.command, this.context.variables);
const args = data.args ? interpolate(data.args, this.context.variables) : '';
const contextHint = data.contextHint ? interpolate(data.contextHint, this.context.variables) : '';
// Interpolate instruction with variables
let instruction = interpolate(data.instruction, this.context.variables);
// Build prompt: combine command, args, and context hint
let prompt = command;
if (args) {
prompt += ` ${args}`;
}
if (contextHint) {
prompt = `${contextHint}\n\n${prompt}`;
// Resolve context references
if (data.contextRefs && data.contextRefs.length > 0) {
const contextContent = this.resolveContextRefs(data.contextRefs);
if (contextContent) {
instruction = `${contextContent}\n\n${instruction}`;
}
}
// Add file context if available
@@ -224,23 +222,33 @@ export class NodeRunner {
.join('\n\n');
if (fileContextStr) {
prompt = `${fileContextStr}\n\n${prompt}`;
instruction = `${fileContextStr}\n\n${instruction}`;
}
}
// Determine tool and mode
const tool = data.tool || DEFAULT_CLI_TOOL;
const mode = this.determineCliMode(data.mode);
try {
// Use claude tool for slash-command execution
// Execute via CLI tool
const result = await executeCliTool({
tool: 'claude',
prompt,
mode: data.execution?.mode === 'mainprocess' ? 'write' : 'analysis',
tool,
prompt: instruction,
mode,
cd: this.context.workingDir
});
// Store output in variables for subsequent nodes
const outputVar = `${node.id}_output`;
this.context.variables[outputVar] = result.stdout;
// Store output using outputName if specified, otherwise use node.id
const outputKey = data.outputName || `${node.id}_output`;
this.context.variables[outputKey] = result.stdout;
this.context.variables[`${node.id}_exitCode`] = result.execution?.exit_code ?? 0;
this.context.variables[`${node.id}_success`] = result.success;
// If outputName is specified, also store structured result
if (data.outputName) {
this.context.variables[data.outputName] = result.stdout;
}
return {
success: result.success,
@@ -256,248 +264,38 @@ export class NodeRunner {
}
/**
* Execute a file-operation node
* Supports: read, write, append, delete, copy, move
* Resolve context references to actual output values
* Looks up outputName values from previous nodes
*/
private async runFileOperation(node: FlowNode): Promise<NodeResult> {
const data = node.data as FileOperationNodeData;
private resolveContextRefs(refs: string[]): string {
const resolvedParts: string[] = [];
// Interpolate path and content
const filePath = interpolate(data.path, this.context.variables);
const resolvedPath = join(this.context.workingDir, filePath);
const encoding = (data.encoding || 'utf-8') as BufferEncoding;
try {
switch (data.operation) {
case 'read': {
const content = await readFile(resolvedPath, encoding);
// Store in output variable if specified
if (data.outputVariable) {
this.context.variables[data.outputVariable] = content;
}
// Add to file context for subsequent nodes
if (data.addToContext) {
this.context.fileContext.push({ path: filePath, content });
}
return { success: true, output: content };
}
case 'write': {
const content = data.content ? interpolate(data.content, this.context.variables) : '';
// Ensure directory exists
const dir = dirname(resolvedPath);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await writeFile(resolvedPath, content, encoding);
if (data.addToContext) {
this.context.fileContext.push({ path: filePath, operation: 'written' });
}
return { success: true, output: { path: filePath, bytesWritten: content.length } };
}
case 'append': {
const content = data.content ? interpolate(data.content, this.context.variables) : '';
// Ensure directory exists
const dir = dirname(resolvedPath);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
// Read existing content and append
let existingContent = '';
if (existsSync(resolvedPath)) {
existingContent = await readFile(resolvedPath, encoding);
}
await writeFile(resolvedPath, existingContent + content, encoding);
if (data.addToContext) {
this.context.fileContext.push({ path: filePath, operation: 'appended' });
}
return { success: true, output: { path: filePath, bytesAppended: content.length } };
}
case 'delete': {
if (existsSync(resolvedPath)) {
await unlink(resolvedPath);
}
return { success: true, output: { path: filePath, deleted: true } };
}
case 'copy': {
const destPath = data.destinationPath
? join(this.context.workingDir, interpolate(data.destinationPath, this.context.variables))
: resolvedPath + '.copy';
// Ensure destination directory exists
const destDir = dirname(destPath);
if (!existsSync(destDir)) {
await mkdir(destDir, { recursive: true });
}
await copyFile(resolvedPath, destPath);
return { success: true, output: { source: filePath, destination: destPath } };
}
case 'move': {
const destPath = data.destinationPath
? join(this.context.workingDir, interpolate(data.destinationPath, this.context.variables))
: resolvedPath;
if (destPath === resolvedPath) {
return { success: false, error: 'Source and destination are the same' };
}
// Ensure destination directory exists
const destDir = dirname(destPath);
if (!existsSync(destDir)) {
await mkdir(destDir, { recursive: true });
}
await rename(resolvedPath, destPath);
return { success: true, output: { source: filePath, destination: destPath } };
}
default:
return { success: false, error: `Unknown file operation: ${data.operation}` };
for (const ref of refs) {
const value = this.context.variables[ref];
if (value !== undefined && value !== null) {
const valueStr = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
resolvedParts.push(`=== Context: ${ref} ===\n${valueStr}`);
}
} catch (error) {
return {
success: false,
error: (error as Error).message
};
}
return resolvedParts.join('\n\n');
}
/**
* Execute a conditional node
* Evaluates condition and returns branch decision
* Determine CLI mode from execution mode
* Maps prompt-template modes to CLI executor modes
*/
private async runConditional(node: FlowNode): Promise<NodeResult> {
const data = node.data as ConditionalNodeData;
// Interpolate condition
const condition = interpolate(data.condition, this.context.variables);
try {
// Evaluate condition in a safe context
const result = this.evaluateCondition(condition);
return {
success: true,
output: result,
branch: result ? 'true' : 'false'
};
} catch (error) {
return {
success: false,
error: `Condition evaluation failed: ${(error as Error).message}`
};
private determineCliMode(mode?: ExecutionMode): 'analysis' | 'write' {
switch (mode) {
case 'write':
case 'mainprocess':
return 'write';
case 'analysis':
case 'async':
default:
return 'analysis';
}
}
/**
* Safely evaluate a condition expression
* Uses Function constructor with limited scope
*/
private evaluateCondition(condition: string): boolean {
// Create a safe evaluation context with common comparison helpers
const safeContext = {
// Allow access to variables
...this.context.variables,
// Add helper functions
isEmpty: (v: unknown) => v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0),
isNotEmpty: (v: unknown) => !(v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)),
contains: (str: string, search: string) => String(str).includes(search),
startsWith: (str: string, search: string) => String(str).startsWith(search),
endsWith: (str: string, search: string) => String(str).endsWith(search),
};
// Build a safe evaluation function
const keys = Object.keys(safeContext);
const values = Object.values(safeContext);
try {
// Create function with explicit parameters to prevent scope leakage
const evalFn = new Function(...keys, `return (${condition})`);
const result = evalFn(...values);
return Boolean(result);
} catch (error) {
// If direct evaluation fails, try simpler comparison
// Handle common patterns like "value >= 0.95"
const simpleMatch = condition.match(/^(.+?)\s*(===|!==|==|!=|>=|<=|>|<)\s*(.+)$/);
if (simpleMatch) {
const [, left, op, right] = simpleMatch;
const leftVal = this.parseValue(left.trim());
const rightVal = this.parseValue(right.trim());
switch (op) {
case '===': return leftVal === rightVal;
case '!==': return leftVal !== rightVal;
case '==': return leftVal == rightVal;
case '!=': return leftVal != rightVal;
case '>=': return Number(leftVal) >= Number(rightVal);
case '<=': return Number(leftVal) <= Number(rightVal);
case '>': return Number(leftVal) > Number(rightVal);
case '<': return Number(leftVal) < Number(rightVal);
}
}
throw error;
}
}
/**
* Parse a value from condition string
*/
private parseValue(val: string): unknown {
// Check for number
if (/^-?\d+(\.\d+)?$/.test(val)) {
return parseFloat(val);
}
// Check for boolean
if (val === 'true') return true;
if (val === 'false') return false;
// Check for null/undefined
if (val === 'null') return null;
if (val === 'undefined') return undefined;
// Check for quoted string
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
return val.slice(1, -1);
}
// Otherwise, try to get from context
return this.context.variables[val] ?? val;
}
/**
* Execute a parallel node
* Forks execution into multiple branches
*/
private async runParallel(node: FlowNode): Promise<NodeResult> {
const data = node.data as ParallelNodeData;
// Parallel node doesn't execute directly - it's a control flow marker
// The FlowExecutor handles the actual parallel execution based on outgoing edges
// This method returns success to indicate the fork point was reached
return {
success: true,
output: {
joinMode: data.joinMode,
timeout: data.timeout,
failFast: data.failFast
}
};
}
}
// ============================================================================
@@ -673,33 +471,13 @@ export class FlowExecutor {
}
/**
* Check if a node should be skipped based on conditional branching
* Check if a node should be skipped
* With unified prompt-template model, conditional logic is handled
* via natural language instructions interpreted by the LLM
*/
private shouldSkipNode(node: FlowNode): boolean {
const dagNode = this.dag.get(node.id);
if (!dagNode) return false;
// Check if this node is on a conditional branch that wasn't taken
for (const depId of dagNode.incoming) {
const depState = this.state.nodeStates[depId];
const depNode = this.flow.nodes.find(n => n.id === depId);
if (depNode?.type === 'conditional' && depState.status === 'completed') {
const result = this.state.nodeStates[depId].result as NodeResult;
const branch = result?.branch;
// Find the edge from conditional to this node
const edge = this.flow.edges.find(e => e.source === depId && e.target === node.id);
if (edge) {
// Check if edge label matches the branch taken
const edgeLabel = edge.sourceHandle || edge.label || 'true';
if (branch && edgeLabel !== branch) {
return true; // Skip this branch
}
}
}
}
private shouldSkipNode(_node: FlowNode): boolean {
// With unified prompt-template nodes, branching decisions are made
// by the LLM interpreting instructions. No special skip logic needed.
return false;
}
@@ -796,6 +574,7 @@ export class FlowExecutor {
/**
* Execute a single node
* All nodes are prompt-template type in the unified model
*/
private async executeNode(node: FlowNode, context: ExecutionContext): Promise<void> {
const nodeState = this.state.nodeStates[node.id];
@@ -816,14 +595,9 @@ export class FlowExecutor {
// Create node runner with current context
const runner = new NodeRunner(context);
// Execute the node
// Execute the node (all nodes are prompt-template type)
const result = await runner.run(node);
// Handle parallel node specially
if (node.type === 'parallel') {
await this.executeParallelBranches(node, context);
}
// Update node state
nodeState.status = result.success ? 'completed' : 'failed';
nodeState.completedAt = new Date().toISOString();
@@ -848,7 +622,7 @@ export class FlowExecutor {
this.addLog('info', `Completed node: ${node.id}`, node.id);
} else {
// Handle error based on node's onError setting
const nodeData = node.data as SlashCommandNodeData;
const nodeData = node.data as PromptTemplateNodeData;
const onError = nodeData.onError || 'fail';
this.addLog('error', `Node failed: ${node.id} - ${result.error}`, node.id);
@@ -875,77 +649,6 @@ export class FlowExecutor {
}
}
/**
* Execute parallel branches
*/
private async executeParallelBranches(parallelNode: FlowNode, context: ExecutionContext): Promise<void> {
const data = parallelNode.data as ParallelNodeData;
const dagNode = this.dag.get(parallelNode.id);
if (!dagNode) return;
// Get branch starting nodes (direct outgoing edges from parallel node)
const branchNodeIds = dagNode.outgoing;
if (branchNodeIds.length === 0) return;
this.addLog('info', `Executing ${branchNodeIds.length} parallel branches`, parallelNode.id);
// Create promises for each branch
const branchPromises = branchNodeIds.map(async (branchNodeId) => {
const branchNode = this.flow.nodes.find(n => n.id === branchNodeId);
if (!branchNode) return { success: false, error: 'Branch node not found' };
try {
await this.executeNode(branchNode, context);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// Execute based on join mode
const timeout = data.timeout || 300000; // Default 5 minutes
try {
if (data.joinMode === 'all') {
// Wait for all branches to complete
const results = await Promise.all(
branchPromises.map(p =>
Promise.race([
p,
new Promise<{ success: false; error: string }>((_, reject) =>
setTimeout(() => reject(new Error('Branch timeout')), timeout)
)
])
)
);
// Check if any failed (and failFast is enabled)
if (data.failFast) {
const failed = results.find(r => !r.success);
if (failed && 'error' in failed) {
throw new Error(`Parallel branch failed: ${failed.error}`);
}
}
} else if (data.joinMode === 'any') {
// Wait for first branch to complete
await Promise.race([
Promise.race(branchPromises),
new Promise((_, reject) => setTimeout(() => reject(new Error('All branches timeout')), timeout))
]);
} else {
// 'none' - fire and forget, don't wait
// Just trigger the branches without awaiting
for (const promise of branchPromises) {
promise.catch(() => {}); // Suppress unhandled rejection
}
}
} catch (error) {
this.addLog('error', `Parallel execution failed: ${(error as Error).message}`, parallelNode.id);
throw error;
}
}
/**
* Request pause (will pause at next safe point)
*/