mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "异步 (非阻塞)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
132
ccw/frontend/src/pages/orchestrator/nodes/PromptTemplateNode.tsx
Normal file
132
ccw/frontend/src/pages/orchestrator/nodes/PromptTemplateNode.tsx
Normal 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}
|
||||
>
|
||||
-> {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';
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user