feat: 新增技能编排引擎和工作流构建器组件
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

refactor: 统一Hands系统常量到单个源文件
refactor: 更新Hands中文名称和描述

fix: 修复技能市场在连接状态变化时重新加载
fix: 修复身份变更提案的错误处理逻辑

docs: 更新多个功能文档的验证状态和实现位置
docs: 更新Hands系统文档

test: 添加测试文件验证工作区路径
This commit is contained in:
iven
2026-03-25 08:27:25 +08:00
parent 9c781f5f2a
commit aa6a9cbd84
110 changed files with 12384 additions and 1337 deletions

View File

@@ -0,0 +1,79 @@
/**
* Condition Node Component
*
* Node for conditional branching.
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import type { ConditionNodeData } from '../../../lib/workflow-builder/types';
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
const branchCount = data.branches.length + (data.hasDefault ? 1 : 0);
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[200px]
bg-orange-50 border-orange-300
${selected ? 'border-orange-500 shadow-lg shadow-orange-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-orange-400 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🔀</span>
<span className="font-medium text-orange-800">{data.label}</span>
</div>
{/* Condition Preview */}
<div className="text-sm text-orange-600 bg-orange-100 rounded px-2 py-1 font-mono mb-2">
{data.condition || 'No condition'}
</div>
{/* Branches */}
<div className="space-y-1">
{data.branches.map((branch, index) => (
<div key={index} className="flex items-center justify-between">
<div className="relative">
{/* Branch Output Handle */}
<Handle
type="source"
position={Position.Right}
id={`branch-${index}`}
style={{ top: `${((index + 1) / (branchCount + 1)) * 100}%` }}
className="w-3 h-3 bg-orange-400 border-2 border-white"
/>
</div>
<span className="text-xs text-orange-500 truncate max-w-[120px]">
{branch.label || branch.when}
</span>
</div>
))}
{data.hasDefault && (
<div className="flex items-center justify-between">
<Handle
type="source"
position={Position.Right}
id="default"
style={{ top: '100%' }}
className="w-3 h-3 bg-gray-400 border-2 border-white"
/>
<span className="text-xs text-gray-500">Default</span>
</div>
)}
</div>
</div>
);
});
ConditionNode.displayName = 'ConditionNode';
export default ConditionNode;

View File

@@ -0,0 +1,72 @@
/**
* Export Node Component
*
* Node for exporting workflow results to various formats.
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import type { ExportNodeData } from '../../../lib/workflow-builder/types';
export const ExportNode = memo(({ data, selected }: NodeProps<ExportNodeData>) => {
const formatLabels: Record<string, string> = {
pptx: 'PowerPoint',
html: 'HTML',
pdf: 'PDF',
markdown: 'Markdown',
json: 'JSON',
};
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-blue-50 border-blue-300
${selected ? 'border-blue-500 shadow-lg shadow-blue-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-blue-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-blue-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">📤</span>
<span className="font-medium text-blue-800">{data.label}</span>
</div>
{/* Formats */}
<div className="flex flex-wrap gap-1">
{data.formats.map((format) => (
<span
key={format}
className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded"
>
{formatLabels[format] || format}
</span>
))}
</div>
{/* Output Directory */}
{data.outputDir && (
<div className="text-xs text-blue-500 mt-2 truncate">
📁 {data.outputDir}
</div>
)}
</div>
);
});
ExportNode.displayName = 'ExportNode';
export default ExportNode;

View File

@@ -0,0 +1,74 @@
/**
* Hand Node Component
*
* Node for executing hand actions.
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import type { HandNodeData } from '../../../lib/workflow-builder/types';
export const HandNode = memo(({ data, selected }: NodeProps<HandNodeData>) => {
const hasHand = Boolean(data.handId);
const hasAction = Boolean(data.action);
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-rose-50 border-rose-300
${selected ? 'border-rose-500 shadow-lg shadow-rose-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-rose-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-rose-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg"></span>
<span className="font-medium text-rose-800">{data.label}</span>
</div>
{/* Hand Info */}
<div className="space-y-1">
<div className={`text-sm ${hasHand ? 'text-rose-600' : 'text-rose-400 italic'}`}>
{hasHand ? (
<span className="font-mono bg-rose-100 px-1.5 py-0.5 rounded">
{data.handName || data.handId}
</span>
) : (
'No hand selected'
)}
</div>
{hasAction && (
<div className="text-xs text-rose-500">
Action: <span className="font-mono">{data.action}</span>
</div>
)}
</div>
{/* Params Count */}
{Object.keys(data.params).length > 0 && (
<div className="text-xs text-rose-500 mt-1">
{Object.keys(data.params).length} param(s)
</div>
)}
</div>
);
});
HandNode.displayName = 'HandNode';
export default HandNode;

View File

@@ -0,0 +1,81 @@
/**
* HTTP Node Component
*
* Node for making HTTP requests.
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import type { HttpNodeData } from '../../../lib/workflow-builder/types';
const methodColors: Record<string, string> = {
GET: 'bg-green-100 text-green-700',
POST: 'bg-blue-100 text-blue-700',
PUT: 'bg-yellow-100 text-yellow-700',
DELETE: 'bg-red-100 text-red-700',
PATCH: 'bg-purple-100 text-purple-700',
};
export const HttpNode = memo(({ data, selected }: NodeProps<HttpNodeData>) => {
const hasUrl = Boolean(data.url);
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[200px]
bg-slate-50 border-slate-300
${selected ? 'border-slate-500 shadow-lg shadow-slate-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-slate-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-slate-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🌐</span>
<span className="font-medium text-slate-800">{data.label}</span>
</div>
{/* Method Badge */}
<div className="flex items-center gap-2 mb-2">
<span className={`text-xs font-bold px-2 py-0.5 rounded ${methodColors[data.method]}`}>
{data.method}
</span>
</div>
{/* URL */}
<div className={`text-sm font-mono bg-slate-100 rounded px-2 py-1 truncate ${hasUrl ? 'text-slate-600' : 'text-slate-400 italic'}`}>
{hasUrl ? data.url : 'No URL specified'}
</div>
{/* Headers Count */}
{Object.keys(data.headers).length > 0 && (
<div className="text-xs text-slate-500 mt-2">
{Object.keys(data.headers).length} header(s)
</div>
)}
{/* Body Indicator */}
{data.body && (
<div className="text-xs text-slate-500 mt-1">
Has body content
</div>
)}
</div>
);
});
HttpNode.displayName = 'HttpNode';
export default HttpNode;

View File

@@ -0,0 +1,54 @@
/**
* Input Node Component
*
* Node for defining workflow input variables.
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import type { InputNodeData } from '../../../lib/workflow-builder/types';
export const InputNode = memo(({ data, selected }: NodeProps<InputNodeData>) => {
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-emerald-50 border-emerald-300
${selected ? 'border-emerald-500 shadow-lg shadow-emerald-200' : ''}
`}
>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-emerald-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">📥</span>
<span className="font-medium text-emerald-800">{data.label}</span>
</div>
{/* Variable Name */}
<div className="text-sm text-emerald-600">
<span className="font-mono bg-emerald-100 px-1.5 py-0.5 rounded">
{data.variableName}
</span>
</div>
{/* Default Value Indicator */}
{data.defaultValue !== undefined && (
<div className="text-xs text-emerald-500 mt-1">
default: {typeof data.defaultValue === 'string'
? `"${data.defaultValue}"`
: JSON.stringify(data.defaultValue)}
</div>
)}
</div>
);
});
InputNode.displayName = 'InputNode';
export default InputNode;

View File

@@ -0,0 +1,70 @@
/**
* LLM Node Component
*
* Node for LLM generation actions.
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import type { LlmNodeData } from '../../../lib/workflow-builder/types';
export const LlmNode = memo(({ data, selected }: NodeProps<LlmNodeData>) => {
const templatePreview = data.template.length > 50
? data.template.slice(0, 50) + '...'
: data.template || 'No template';
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[200px]
bg-violet-50 border-violet-300
${selected ? 'border-violet-500 shadow-lg shadow-violet-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-violet-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-violet-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🤖</span>
<span className="font-medium text-violet-800">{data.label}</span>
{data.jsonMode && (
<span className="text-xs bg-violet-200 text-violet-700 px-1.5 py-0.5 rounded">
JSON
</span>
)}
</div>
{/* Template Preview */}
<div className="text-sm text-violet-600 bg-violet-100 rounded px-2 py-1 font-mono">
{data.isTemplateFile ? '📄 ' : ''}
{templatePreview}
</div>
{/* Model Info */}
{(data.model || data.temperature !== undefined) && (
<div className="flex gap-2 mt-2 text-xs text-violet-500">
{data.model && <span>Model: {data.model}</span>}
{data.temperature !== undefined && (
<span>Temp: {data.temperature}</span>
)}
</div>
)}
</div>
);
});
LlmNode.displayName = 'LlmNode';
export default LlmNode;

View File

@@ -0,0 +1,81 @@
/**
* Orchestration Node Component
*
* Node for executing skill orchestration graphs (DAGs).
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import type { OrchestrationNodeData } from '../../../lib/workflow-builder/types';
export const OrchestrationNode = memo(({ data, selected }: NodeProps<OrchestrationNodeData>) => {
const hasGraphId = Boolean(data.graphId);
const hasGraph = Boolean(data.graph);
const inputCount = Object.keys(data.inputMappings).length;
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[200px]
bg-gradient-to-br from-indigo-50 to-purple-50
border-indigo-300
${selected ? 'border-indigo-500 shadow-lg shadow-indigo-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-indigo-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-indigo-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🔀</span>
<span className="font-medium text-indigo-800">{data.label}</span>
</div>
{/* Graph Reference */}
<div className={`text-sm mb-2 ${hasGraphId || hasGraph ? 'text-indigo-600' : 'text-indigo-400 italic'}`}>
{hasGraphId ? (
<div className="flex items-center gap-1.5 bg-indigo-100 rounded px-2 py-1">
<span className="text-xs">📋</span>
<span className="font-mono text-xs">{data.graphId}</span>
</div>
) : hasGraph ? (
<div className="flex items-center gap-1.5 bg-indigo-100 rounded px-2 py-1">
<span className="text-xs">📊</span>
<span className="text-xs">Inline graph</span>
</div>
) : (
'No graph configured'
)}
</div>
{/* Input Mappings */}
{inputCount > 0 && (
<div className="text-xs text-indigo-500 mt-2">
{inputCount} input mapping(s)
</div>
)}
{/* Description */}
{data.description && (
<div className="text-xs text-indigo-400 mt-2 line-clamp-2">
{data.description}
</div>
)}
</div>
);
});
OrchestrationNode.displayName = 'OrchestrationNode';
export default OrchestrationNode;

View File

@@ -0,0 +1,55 @@
/**
* Parallel Node Component
*
* Node for parallel execution of steps.
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import type { ParallelNodeData } from '../../../lib/workflow-builder/types';
export const ParallelNode = memo(({ data, selected }: NodeProps<ParallelNodeData>) => {
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-cyan-50 border-cyan-300
${selected ? 'border-cyan-500 shadow-lg shadow-cyan-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-cyan-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-cyan-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg"></span>
<span className="font-medium text-cyan-800">{data.label}</span>
</div>
{/* Each Expression */}
<div className="text-sm text-cyan-600 bg-cyan-100 rounded px-2 py-1 font-mono">
each: {data.each || '${inputs.items}'}
</div>
{/* Max Workers */}
<div className="text-xs text-cyan-500 mt-2">
Max workers: {data.maxWorkers}
</div>
</div>
);
});
ParallelNode.displayName = 'ParallelNode';
export default ParallelNode;

View File

@@ -0,0 +1,65 @@
/**
* Skill Node Component
*
* Node for executing skills.
*/
import React, { memo } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react';
import type { SkillNodeData } from '../../../lib/workflow-builder/types';
export const SkillNode = memo(({ data, selected }: NodeProps<SkillNodeData>) => {
const hasSkill = Boolean(data.skillId);
return (
<div
className={`
px-4 py-3 rounded-lg border-2 min-w-[180px]
bg-amber-50 border-amber-300
${selected ? 'border-amber-500 shadow-lg shadow-amber-200' : ''}
`}
>
{/* Input Handle */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-amber-400 border-2 border-white"
/>
{/* Output Handle */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-amber-500 border-2 border-white"
/>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg"></span>
<span className="font-medium text-amber-800">{data.label}</span>
</div>
{/* Skill ID */}
<div className={`text-sm ${hasSkill ? 'text-amber-600' : 'text-amber-400 italic'}`}>
{hasSkill ? (
<span className="font-mono bg-amber-100 px-1.5 py-0.5 rounded">
{data.skillName || data.skillId}
</span>
) : (
'No skill selected'
)}
</div>
{/* Input Mappings Count */}
{Object.keys(data.inputMappings).length > 0 && (
<div className="text-xs text-amber-500 mt-1">
{Object.keys(data.inputMappings).length} input mapping(s)
</div>
)}
</div>
);
});
SkillNode.displayName = 'SkillNode';
export default SkillNode;