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 },
},
};