mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-30 08:28:48 +02:00
feat: add Workflow Designer frontend (Phase 1)
Implement the visual workflow editor UI with ReactFlow canvas, node palette, inspector panel, and full SettingsPanel integration. New files: - workflowGraph.ts: graph utilities (layout, connection rules, edges) - WorkflowGraphNodes.tsx: custom ReactFlow nodes per workflow kind - WorkflowGraphCanvas.tsx: ReactFlow canvas with drag, connect, layout - WorkflowGraphInspector.tsx: node/edge inspector with agent config - WorkflowNodePalette.tsx: categorized node palette for adding nodes - WorkflowEditor.tsx: full-screen editor with validation and settings Modified files: - SettingsPanel.tsx: add workflows section, nav item, editing state - App.tsx: wire workflow CRUD props and createDraftWorkflow factory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -49,6 +49,7 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje
|
||||
import type { ProjectGitFileReference } from '@shared/domain/project';
|
||||
import { applySessionModelConfig } from '@shared/domain/session';
|
||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
||||
@@ -121,6 +122,50 @@ function createDraftWorkspaceAgent(defaultModelId: string): WorkspaceAgentDefini
|
||||
};
|
||||
}
|
||||
|
||||
function createDraftWorkflow(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): WorkflowDefinition {
|
||||
const timestamp = nowIso();
|
||||
const startId = createId('wf-start');
|
||||
const agentId = createId('wf-agent');
|
||||
const endId = createId('wf-end');
|
||||
return {
|
||||
id: createId('workflow'),
|
||||
name: 'New Workflow',
|
||||
description: '',
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: startId, kind: 'start', label: 'Start', position: { x: 0, y: 100 }, config: { kind: 'start' } },
|
||||
{
|
||||
id: agentId,
|
||||
kind: 'agent',
|
||||
label: 'Primary Agent',
|
||||
position: { x: 250, y: 100 },
|
||||
config: {
|
||||
kind: 'agent',
|
||||
id: createId('agent'),
|
||||
name: 'Primary Agent',
|
||||
description: 'General-purpose assistant.',
|
||||
instructions: 'You are a helpful coding assistant working inside the selected project.',
|
||||
model: defaultModelId,
|
||||
reasoningEffort: defaultReasoningEffort,
|
||||
},
|
||||
},
|
||||
{ id: endId, kind: 'end', label: 'End', position: { x: 500, y: 100 }, config: { kind: 'end' } },
|
||||
],
|
||||
edges: [
|
||||
{ id: `edge-${startId}-to-${agentId}`, source: startId, target: agentId, kind: 'direct' },
|
||||
{ id: `edge-${agentId}-to-${endId}`, source: agentId, target: endId, kind: 'direct' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
checkpointing: { enabled: false },
|
||||
executionMode: 'off-thread',
|
||||
maxIterations: 5,
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const api = getElectronApi();
|
||||
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
||||
@@ -723,6 +768,9 @@ export default function App() {
|
||||
onDeletePattern={async (id) => {
|
||||
await api.deletePattern(id);
|
||||
}}
|
||||
onDeleteWorkflow={async (id) => {
|
||||
await api.deleteWorkflow(id);
|
||||
}}
|
||||
onNewLspProfile={createDraftLspProfile}
|
||||
onNewMcpServer={createDraftMcpServer}
|
||||
onNewPattern={() => {
|
||||
@@ -733,6 +781,13 @@ export default function App() {
|
||||
resolveReasoningEffort(defaultModel, 'high'),
|
||||
);
|
||||
}}
|
||||
onNewWorkflow={() => {
|
||||
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
|
||||
return createDraftWorkflow(
|
||||
defaultModel?.id ?? 'gpt-5.4',
|
||||
resolveReasoningEffort(defaultModel, 'high'),
|
||||
);
|
||||
}}
|
||||
onRefreshCapabilities={refreshCapabilities}
|
||||
onSaveLspProfile={async (profile) => {
|
||||
await api.saveLspProfile({ profile });
|
||||
@@ -743,6 +798,9 @@ export default function App() {
|
||||
onSavePattern={async (pattern) => {
|
||||
await api.savePattern({ pattern });
|
||||
}}
|
||||
onSaveWorkflow={async (workflow) => {
|
||||
await api.saveWorkflow({ workflow });
|
||||
}}
|
||||
onSaveWorkspaceAgent={async (agent) => {
|
||||
await api.saveWorkspaceAgent({ agent });
|
||||
}}
|
||||
@@ -769,6 +827,7 @@ export default function App() {
|
||||
setShowSettings(false);
|
||||
}}
|
||||
patterns={workspace.patterns}
|
||||
workflows={workspace.workflows}
|
||||
sidecarCapabilities={sidecarCapabilities}
|
||||
theme={workspace.settings.theme}
|
||||
toolingSettings={workspace.settings.tooling}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Workflow, Wrench } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Workflow, Wrench } from 'lucide-react';
|
||||
|
||||
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
||||
import { ToggleSwitch } from '@renderer/components/ui';
|
||||
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
||||
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
||||
@@ -13,6 +14,7 @@ import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } fro
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
||||
import { normalizeWorkflowDefinition, type WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import {
|
||||
normalizeLspProfileDefinition,
|
||||
normalizeMcpServerDefinition,
|
||||
@@ -26,6 +28,7 @@ import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type Works
|
||||
interface SettingsPanelProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
patterns: PatternDefinition[];
|
||||
workflows: WorkflowDefinition[];
|
||||
sidecarCapabilities?: SidecarCapabilities;
|
||||
theme: AppearanceTheme;
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
@@ -37,6 +40,9 @@ interface SettingsPanelProps {
|
||||
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
||||
onDeletePattern: (patternId: string) => Promise<void>;
|
||||
onNewPattern: () => PatternDefinition;
|
||||
onSaveWorkflow: (workflow: WorkflowDefinition) => Promise<void>;
|
||||
onDeleteWorkflow: (workflowId: string) => Promise<void>;
|
||||
onNewWorkflow: () => WorkflowDefinition;
|
||||
onSaveMcpServer: (server: McpServerDefinition) => Promise<void>;
|
||||
onDeleteMcpServer: (serverId: string) => Promise<void>;
|
||||
onNewMcpServer: () => McpServerDefinition;
|
||||
@@ -60,7 +66,7 @@ interface SettingsPanelProps {
|
||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
||||
}
|
||||
|
||||
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||
export type SettingsSection = 'appearance' | 'connection' | 'patterns' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
|
||||
|
||||
interface NavItem {
|
||||
id: SettingsSection;
|
||||
@@ -90,6 +96,7 @@ const navGroups: NavGroup[] = [
|
||||
label: 'Workflows',
|
||||
items: [
|
||||
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
|
||||
{ id: 'workflows', label: 'Workflows', icon: <GitBranch className="size-3.5" /> },
|
||||
{ id: 'agents', label: 'Agents', icon: <UserCircle className="size-3.5" /> },
|
||||
],
|
||||
},
|
||||
@@ -116,6 +123,7 @@ function modeBadgeClasses(pattern: PatternDefinition) {
|
||||
export function SettingsPanel({
|
||||
availableModels,
|
||||
patterns,
|
||||
workflows,
|
||||
sidecarCapabilities,
|
||||
theme,
|
||||
toolingSettings,
|
||||
@@ -127,6 +135,9 @@ export function SettingsPanel({
|
||||
onSavePattern,
|
||||
onDeletePattern,
|
||||
onNewPattern,
|
||||
onSaveWorkflow,
|
||||
onDeleteWorkflow,
|
||||
onNewWorkflow,
|
||||
onSaveMcpServer,
|
||||
onDeleteMcpServer,
|
||||
onNewMcpServer,
|
||||
@@ -151,6 +162,7 @@ export function SettingsPanel({
|
||||
}: SettingsPanelProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
|
||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
||||
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
||||
const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState<WorkspaceAgentDefinition | null>(null);
|
||||
@@ -184,6 +196,29 @@ export function SettingsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (editingWorkflow) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||
<WorkflowEditor
|
||||
availableModels={availableModels}
|
||||
onBack={() => setEditingWorkflow(null)}
|
||||
onChange={setEditingWorkflow}
|
||||
onDelete={
|
||||
async () => {
|
||||
await onDeleteWorkflow(editingWorkflow.id);
|
||||
setEditingWorkflow(null);
|
||||
}
|
||||
}
|
||||
onSave={async () => {
|
||||
await onSaveWorkflow(normalizeWorkflowDefinition(editingWorkflow));
|
||||
setEditingWorkflow(null);
|
||||
}}
|
||||
workflow={editingWorkflow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (editingMcpServer) {
|
||||
const exists = toolingSettings.mcpServers.some((server) => server.id === editingMcpServer.id);
|
||||
return (
|
||||
@@ -337,6 +372,13 @@ export function SettingsPanel({
|
||||
patterns={patterns}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'workflows' && (
|
||||
<WorkflowsSection
|
||||
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
|
||||
onNewWorkflow={() => setEditingWorkflow(onNewWorkflow())}
|
||||
workflows={workflows}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'agents' && (
|
||||
<WorkspaceAgentsSection
|
||||
agents={workspaceAgents}
|
||||
@@ -601,6 +643,62 @@ function PatternsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowsSection({
|
||||
workflows,
|
||||
onEditWorkflow,
|
||||
onNewWorkflow,
|
||||
}: {
|
||||
workflows: WorkflowDefinition[];
|
||||
onEditWorkflow: (workflow: WorkflowDefinition) => void;
|
||||
onNewWorkflow: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
description="Design multi-step agent workflows with visual graphs"
|
||||
title="Workflows"
|
||||
>
|
||||
<SectionAction label="New Workflow" onClick={onNewWorkflow} />
|
||||
</SectionHeader>
|
||||
|
||||
<div className="space-y-1">
|
||||
{workflows.map((wf) => {
|
||||
const agentCount = wf.graph.nodes.filter((n) => n.kind === 'agent').length;
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)]"
|
||||
key={wf.id}
|
||||
onClick={() => onEditWorkflow(wf)}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">{wf.name}</span>
|
||||
<span className="rounded-full bg-[var(--color-surface-3)] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-secondary)]">
|
||||
{wf.settings.executionMode}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[var(--color-text-muted)]">{wf.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||
{agentCount} agent{agentCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{workflows.length === 0 && (
|
||||
<p className="px-4 py-6 text-center text-[12px] text-[var(--color-text-muted)]">
|
||||
No workflows yet. Create one to get started.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceAgentsSection({
|
||||
agents,
|
||||
patterns,
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertCircle, CheckCircle, ChevronLeft, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import type {
|
||||
WorkflowDefinition,
|
||||
WorkflowGraph,
|
||||
WorkflowNode,
|
||||
WorkflowNodeConfig,
|
||||
WorkflowNodeKind,
|
||||
WorkflowEdge,
|
||||
AgentNodeConfig,
|
||||
} from '@shared/domain/workflow';
|
||||
import { validateWorkflowDefinition } from '@shared/domain/workflow';
|
||||
import { createId } from '@shared/utils/ids';
|
||||
import { ToggleSwitch } from '@renderer/components/ui';
|
||||
|
||||
import { WorkflowGraphCanvas } from './workflow/WorkflowGraphCanvas';
|
||||
import { WorkflowGraphInspector } from './workflow/WorkflowGraphInspector';
|
||||
import { WorkflowNodePalette } from './workflow/WorkflowNodePalette';
|
||||
|
||||
interface WorkflowEditorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
workflow: WorkflowDefinition;
|
||||
onChange: (workflow: WorkflowDefinition) => void;
|
||||
onDelete?: () => void;
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
multiline,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
multiline?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const baseClasses =
|
||||
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50';
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className={`${baseClasses} min-h-20 resize-y`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={baseClasses}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Default configs for new nodes ─────────────────────────── */
|
||||
|
||||
function defaultConfigForKind(kind: WorkflowNodeKind): WorkflowNodeConfig {
|
||||
switch (kind) {
|
||||
case 'start':
|
||||
return { kind: 'start' };
|
||||
case 'end':
|
||||
return { kind: 'end' };
|
||||
case 'agent':
|
||||
return {
|
||||
kind: 'agent',
|
||||
id: createId('agent'),
|
||||
name: 'New Agent',
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
};
|
||||
case 'code-executor':
|
||||
return { kind: 'code-executor' };
|
||||
case 'function-executor':
|
||||
return { kind: 'function-executor', functionRef: '' };
|
||||
case 'sub-workflow':
|
||||
return { kind: 'sub-workflow' };
|
||||
case 'request-port':
|
||||
return { kind: 'request-port', portId: '', requestType: '', responseType: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLabelForKind(kind: WorkflowNodeKind): string {
|
||||
switch (kind) {
|
||||
case 'start':
|
||||
return 'Start';
|
||||
case 'end':
|
||||
return 'End';
|
||||
case 'agent':
|
||||
return 'New Agent';
|
||||
case 'code-executor':
|
||||
return 'Code Executor';
|
||||
case 'function-executor':
|
||||
return 'Function';
|
||||
case 'sub-workflow':
|
||||
return 'Sub-Workflow';
|
||||
case 'request-port':
|
||||
return 'Request Port';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Main editor ───────────────────────────────────────────── */
|
||||
|
||||
export function WorkflowEditor({
|
||||
availableModels,
|
||||
workflow,
|
||||
onChange,
|
||||
onDelete,
|
||||
onSave,
|
||||
onBack,
|
||||
}: WorkflowEditorProps) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const issues = validateWorkflowDefinition(workflow);
|
||||
|
||||
function emitChange(next: WorkflowDefinition) {
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function emitGraphChange(graph: WorkflowGraph) {
|
||||
onChange({ ...workflow, graph });
|
||||
}
|
||||
|
||||
function handleAddNode(kind: WorkflowNodeKind) {
|
||||
const nodeId = createId(`wf-${kind}`);
|
||||
const newNode: WorkflowNode = {
|
||||
id: nodeId,
|
||||
kind,
|
||||
label: defaultLabelForKind(kind),
|
||||
position: { x: 300, y: 200 },
|
||||
config: defaultConfigForKind(kind),
|
||||
};
|
||||
emitGraphChange({
|
||||
...workflow.graph,
|
||||
nodes: [...workflow.graph.nodes, newNode],
|
||||
});
|
||||
setSelectedNodeId(nodeId);
|
||||
setSelectedEdgeId(null);
|
||||
}
|
||||
|
||||
function handleNodeChange(nodeId: string, patch: Partial<WorkflowNode>) {
|
||||
emitGraphChange({
|
||||
...workflow.graph,
|
||||
nodes: workflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, ...patch } : n)),
|
||||
});
|
||||
}
|
||||
|
||||
function handleNodeConfigChange(nodeId: string, config: WorkflowNodeConfig) {
|
||||
emitGraphChange({
|
||||
...workflow.graph,
|
||||
nodes: workflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, config } : n)),
|
||||
});
|
||||
}
|
||||
|
||||
function handleNodeRemove(nodeId: string) {
|
||||
const node = workflow.graph.nodes.find((n) => n.id === nodeId);
|
||||
if (!node || node.kind === 'start' || node.kind === 'end') {
|
||||
return;
|
||||
}
|
||||
|
||||
emitGraphChange({
|
||||
nodes: workflow.graph.nodes.filter((n) => n.id !== nodeId),
|
||||
edges: workflow.graph.edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
|
||||
});
|
||||
setSelectedNodeId(null);
|
||||
}
|
||||
|
||||
function handleEdgeChange(edgeId: string, patch: Partial<WorkflowEdge>) {
|
||||
emitGraphChange({
|
||||
...workflow.graph,
|
||||
edges: workflow.graph.edges.map((e) => (e.id === edgeId ? { ...e, ...patch } : e)),
|
||||
});
|
||||
}
|
||||
|
||||
function handleEdgeRemove(edgeId: string) {
|
||||
emitGraphChange({
|
||||
...workflow.graph,
|
||||
edges: workflow.graph.edges.filter((e) => e.id !== edgeId),
|
||||
});
|
||||
setSelectedEdgeId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border)] pb-3 pl-5 pr-36 pt-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="no-drag flex size-8 items-center justify-center rounded-lg text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{workflow.name || 'Untitled workflow'}
|
||||
</h3>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">Workflow Designer</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="no-drag flex items-center gap-2">
|
||||
{onDelete && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-[var(--color-status-error)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded-lg bg-[var(--color-accent)] px-4 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-sky)]"
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body — palette + canvas + inspector */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left palette */}
|
||||
<div className="w-40 shrink-0 overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||
<WorkflowNodePalette onAddNode={handleAddNode} />
|
||||
</div>
|
||||
|
||||
{/* Center column: validation + canvas + settings */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Validation banner */}
|
||||
<div className="px-5 pt-4">
|
||||
{issues.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{issues.map((issue, i) => (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[12px] ${
|
||||
issue.level === 'error'
|
||||
? 'bg-[var(--color-status-error)]/10 text-[var(--color-status-error)]'
|
||||
: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]'
|
||||
}`}
|
||||
key={`${issue.field ?? 'v'}-${i}`}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
{issue.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-status-success)]/10 px-3 py-2 text-[12px] text-[var(--color-status-success)]">
|
||||
<CheckCircle className="size-3.5" />
|
||||
Workflow is valid
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Graph canvas */}
|
||||
<div className="min-h-0 flex-1 px-5 pb-2 pt-4">
|
||||
<WorkflowGraphCanvas
|
||||
availableModels={availableModels}
|
||||
onEdgeSelect={setSelectedEdgeId}
|
||||
onGraphChange={emitGraphChange}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
selectedNodeId={selectedNodeId}
|
||||
workflow={workflow}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings below canvas */}
|
||||
<WorkflowSettingsPanel workflow={workflow} onChange={emitChange} />
|
||||
</div>
|
||||
|
||||
{/* Right inspector */}
|
||||
<div className="w-80 shrink-0 overflow-y-auto border-l border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||
<WorkflowGraphInspector
|
||||
availableModels={availableModels}
|
||||
onEdgeChange={handleEdgeChange}
|
||||
onEdgeRemove={handleEdgeRemove}
|
||||
onNodeChange={handleNodeChange}
|
||||
onNodeConfigChange={handleNodeConfigChange}
|
||||
onNodeRemove={handleNodeRemove}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
selectedNodeId={selectedNodeId}
|
||||
workflow={workflow}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Settings panel below canvas ───────────────────────────── */
|
||||
|
||||
function WorkflowSettingsPanel({
|
||||
workflow,
|
||||
onChange,
|
||||
}: {
|
||||
workflow: WorkflowDefinition;
|
||||
onChange: (workflow: WorkflowDefinition) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="shrink-0 border-t border-[var(--color-border)] px-5 py-4">
|
||||
<h4 className="mb-3 text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Settings
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => onChange({ ...workflow, name: v })}
|
||||
placeholder="Workflow name"
|
||||
value={workflow.name}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => onChange({ ...workflow, description: v })}
|
||||
placeholder="What this workflow does"
|
||||
value={workflow.description}
|
||||
/>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Execution Mode</span>
|
||||
<select
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...workflow,
|
||||
settings: { ...workflow.settings, executionMode: e.target.value as 'off-thread' | 'lockstep' },
|
||||
})
|
||||
}
|
||||
value={workflow.settings.executionMode}
|
||||
>
|
||||
<option value="off-thread">Off-thread</option>
|
||||
<option value="lockstep">Lockstep</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Max Iterations</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 focus:border-[var(--color-accent)]/50"
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
onChange({
|
||||
...workflow,
|
||||
settings: {
|
||||
...workflow.settings,
|
||||
maxIterations: Number.isNaN(raw) ? undefined : raw,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. 5"
|
||||
type="number"
|
||||
value={workflow.settings.maxIterations ?? ''}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="col-span-2 flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-3">
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">Checkpointing</div>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">Enable state checkpointing between steps</p>
|
||||
</div>
|
||||
<button
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...workflow,
|
||||
settings: {
|
||||
...workflow.settings,
|
||||
checkpointing: { enabled: !workflow.settings.checkpointing.enabled },
|
||||
},
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ToggleSwitch enabled={workflow.settings.checkpointing.enabled} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Panel,
|
||||
MiniMap,
|
||||
MarkerType,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
type OnConnect,
|
||||
type OnEdgesChange,
|
||||
type OnNodesChange,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
|
||||
import type { WorkflowDefinition, WorkflowGraph } from '@shared/domain/workflow';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import {
|
||||
addWorkflowEdge,
|
||||
autoLayoutWorkflowGraph,
|
||||
fromCanvasPositions,
|
||||
isWorkflowConnectionAllowed,
|
||||
removeWorkflowEdge,
|
||||
toCanvasEdges,
|
||||
toCanvasNodes,
|
||||
type WorkflowGraphNodeData,
|
||||
} from '@renderer/lib/workflowGraph';
|
||||
|
||||
import { workflowNodeTypes } from './WorkflowGraphNodes';
|
||||
|
||||
interface WorkflowGraphCanvasProps {
|
||||
workflow: WorkflowDefinition;
|
||||
availableModels?: ReadonlyArray<ModelDefinition>;
|
||||
onGraphChange: (graph: WorkflowGraph) => void;
|
||||
onNodeSelect: (nodeId: string | null) => void;
|
||||
onEdgeSelect: (edgeId: string | null) => void;
|
||||
selectedNodeId: string | null;
|
||||
}
|
||||
|
||||
function WorkflowGraphCanvasInner({
|
||||
workflow,
|
||||
availableModels,
|
||||
onGraphChange,
|
||||
onNodeSelect,
|
||||
onEdgeSelect,
|
||||
selectedNodeId,
|
||||
}: WorkflowGraphCanvasProps) {
|
||||
const { fitView } = useReactFlow();
|
||||
const graph = workflow.graph;
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
|
||||
toCanvasNodes(graph, availableModels),
|
||||
);
|
||||
const [edges, setEdges, onEdgesChangeBase] = useEdgesState(
|
||||
toCanvasEdges(graph),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(toCanvasNodes(graph, availableModels));
|
||||
setEdges(toCanvasEdges(graph));
|
||||
}, [graph, availableModels, setNodes, setEdges]);
|
||||
|
||||
const handleNodesChange: OnNodesChange<Node<WorkflowGraphNodeData>> = useCallback(
|
||||
(changes) => {
|
||||
const removals = changes.filter((c) => c.type === 'remove');
|
||||
const nonRemovals = changes.filter((c) => c.type !== 'remove');
|
||||
|
||||
if (removals.length > 0) {
|
||||
let updatedGraph = graph;
|
||||
for (const removal of removals) {
|
||||
if (removal.type === 'remove') {
|
||||
const node = graph.nodes.find((n) => n.id === removal.id);
|
||||
if (node && node.kind !== 'start' && node.kind !== 'end') {
|
||||
updatedGraph = {
|
||||
...updatedGraph,
|
||||
nodes: updatedGraph.nodes.filter((n) => n.id !== removal.id),
|
||||
edges: updatedGraph.edges.filter((e) => e.source !== removal.id && e.target !== removal.id),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updatedGraph !== graph) {
|
||||
onGraphChange(updatedGraph);
|
||||
onNodeSelect(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (nonRemovals.length > 0) {
|
||||
onNodesChangeBase(nonRemovals);
|
||||
}
|
||||
|
||||
const hasDragStart = nonRemovals.some(
|
||||
(c) => c.type === 'position' && 'dragging' in c && c.dragging,
|
||||
);
|
||||
const hasDragStop = nonRemovals.some(
|
||||
(c) => c.type === 'position' && !('dragging' in c && c.dragging),
|
||||
);
|
||||
|
||||
if (hasDragStart) {
|
||||
draggingRef.current = true;
|
||||
}
|
||||
|
||||
if (hasDragStop && draggingRef.current) {
|
||||
draggingRef.current = false;
|
||||
setNodes((currentNodes) => {
|
||||
const updatedGraph = fromCanvasPositions(workflow, currentNodes);
|
||||
onGraphChange(updatedGraph);
|
||||
return currentNodes;
|
||||
});
|
||||
}
|
||||
},
|
||||
[onNodesChangeBase, workflow, graph, onGraphChange, onNodeSelect, setNodes],
|
||||
);
|
||||
|
||||
const handleEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => {
|
||||
const removals = changes.filter((c) => c.type === 'remove');
|
||||
if (removals.length > 0) {
|
||||
let updatedGraph = graph;
|
||||
for (const removal of removals) {
|
||||
if (removal.type === 'remove') {
|
||||
updatedGraph = removeWorkflowEdge(updatedGraph, removal.id);
|
||||
}
|
||||
}
|
||||
onGraphChange(updatedGraph);
|
||||
onEdgeSelect(null);
|
||||
}
|
||||
|
||||
const nonRemovals = changes.filter((c) => c.type !== 'remove');
|
||||
if (nonRemovals.length > 0) {
|
||||
onEdgesChangeBase(nonRemovals);
|
||||
}
|
||||
},
|
||||
[onEdgesChangeBase, graph, onGraphChange, onEdgeSelect],
|
||||
);
|
||||
|
||||
const handleConnect: OnConnect = useCallback(
|
||||
(connection) => {
|
||||
if (!isWorkflowConnectionAllowed(connection, graph)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connection.source && connection.target) {
|
||||
const updatedGraph = addWorkflowEdge(graph, connection.source, connection.target);
|
||||
onGraphChange(updatedGraph);
|
||||
}
|
||||
},
|
||||
[graph, onGraphChange],
|
||||
);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node<WorkflowGraphNodeData>) => {
|
||||
onNodeSelect(node.id);
|
||||
onEdgeSelect(null);
|
||||
},
|
||||
[onNodeSelect, onEdgeSelect],
|
||||
);
|
||||
|
||||
const handleEdgeClick = useCallback(
|
||||
(_event: React.MouseEvent, edge: Edge) => {
|
||||
onEdgeSelect(edge.id);
|
||||
onNodeSelect(null);
|
||||
},
|
||||
[onEdgeSelect, onNodeSelect],
|
||||
);
|
||||
|
||||
const handlePaneClick = useCallback(() => {
|
||||
onNodeSelect(null);
|
||||
onEdgeSelect(null);
|
||||
}, [onNodeSelect, onEdgeSelect]);
|
||||
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
const layouted = autoLayoutWorkflowGraph(graph);
|
||||
onGraphChange(layouted);
|
||||
requestAnimationFrame(() => fitView({ padding: 0.3 }));
|
||||
}, [graph, onGraphChange, fitView]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-0)]/50">
|
||||
<ReactFlow
|
||||
nodes={nodes.map((n) => ({
|
||||
...n,
|
||||
selected: n.id === selectedNodeId,
|
||||
}))}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
nodeTypes={workflowNodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
defaultEdgeOptions={{
|
||||
type: 'default',
|
||||
style: { stroke: '#6366f1', strokeWidth: 1.5 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#6366f1' },
|
||||
}}
|
||||
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
||||
deleteKeyCode="Delete"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a1e2e" />
|
||||
<MiniMap
|
||||
className="!rounded-lg !border !border-[var(--color-border)] !bg-[var(--color-surface-1)]"
|
||||
maskColor="rgba(0,0,0,0.6)"
|
||||
nodeColor="#3f3f46"
|
||||
/>
|
||||
<Panel position="top-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAutoLayout}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)]/90 px-2.5 py-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] shadow-sm backdrop-blur transition hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-3)]/90 hover:text-[var(--color-text-primary)]"
|
||||
title="Auto-layout nodes"
|
||||
>
|
||||
<LayoutGrid className="size-3.5" />
|
||||
Auto layout
|
||||
</button>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowGraphCanvas(props: WorkflowGraphCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowGraphCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { Bot, Code, FunctionSquare, GitBranch, Info, Radio, Trash2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
findModel,
|
||||
getSupportedReasoningEfforts,
|
||||
resolveReasoningEffort,
|
||||
type ModelDefinition,
|
||||
} from '@shared/domain/models';
|
||||
import type {
|
||||
WorkflowDefinition,
|
||||
WorkflowEdge,
|
||||
WorkflowNode,
|
||||
WorkflowNodeConfig,
|
||||
AgentNodeConfig,
|
||||
} from '@shared/domain/workflow';
|
||||
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
|
||||
|
||||
interface WorkflowGraphInspectorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
workflow: WorkflowDefinition;
|
||||
selectedNodeId: string | null;
|
||||
selectedEdgeId: string | null;
|
||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
|
||||
onEdgeRemove: (edgeId: string) => void;
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
multiline,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
multiline?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const base =
|
||||
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50';
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
className={`${base} min-h-20 resize-y`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={base}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Agent node inspector ──────────────────────────────────── */
|
||||
|
||||
function AgentNodeInspector({
|
||||
node,
|
||||
availableModels,
|
||||
onNodeChange,
|
||||
onNodeConfigChange,
|
||||
onNodeRemove,
|
||||
}: {
|
||||
node: WorkflowNode;
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
}) {
|
||||
const config = node.config as AgentNodeConfig;
|
||||
const model = findModel(config.model, availableModels);
|
||||
|
||||
function patchConfig(patch: Partial<AgentNodeConfig>) {
|
||||
onNodeConfigChange(node.id, { ...config, ...patch });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
|
||||
<Bot className="size-4 text-[var(--color-text-primary)]" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{config.name || 'Unnamed Agent'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => onNodeRemove(node.id)}
|
||||
title="Remove node"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label="Label"
|
||||
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||
placeholder="Display label"
|
||||
value={node.label}
|
||||
/>
|
||||
<InputField
|
||||
label="Agent Name"
|
||||
onChange={(v) => patchConfig({ name: v })}
|
||||
placeholder="Agent name"
|
||||
value={config.name}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => patchConfig({ description: v })}
|
||||
placeholder="What this agent does"
|
||||
value={config.description}
|
||||
/>
|
||||
<InputField
|
||||
label="Instructions"
|
||||
multiline
|
||||
onChange={(v) => patchConfig({ instructions: v })}
|
||||
placeholder="System instructions for this agent"
|
||||
value={config.instructions}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ModelSelect
|
||||
models={availableModels}
|
||||
onChange={(value) => {
|
||||
const m = findModel(value, availableModels);
|
||||
patchConfig({
|
||||
model: value,
|
||||
reasoningEffort: resolveReasoningEffort(m, config.reasoningEffort),
|
||||
});
|
||||
}}
|
||||
value={config.model}
|
||||
/>
|
||||
<ReasoningEffortSelect
|
||||
label="Reasoning"
|
||||
onChange={(value) => patchConfig({ reasoningEffort: value })}
|
||||
supportedEfforts={getSupportedReasoningEfforts(model)}
|
||||
value={config.reasoningEffort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── System node inspector ─────────────────────────────────── */
|
||||
|
||||
function SystemNodeInspector({ node }: { node: WorkflowNode }) {
|
||||
const kindLabels: Record<string, string> = {
|
||||
start: 'Start Node',
|
||||
end: 'End Node',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
|
||||
<Info className="size-4 text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{kindLabels[node.kind] ?? node.kind}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||
System node — cannot be edited or removed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Placeholder inspector for future node kinds ───────────── */
|
||||
|
||||
const placeholderIcons: Record<string, typeof Code> = {
|
||||
'code-executor': Code,
|
||||
'function-executor': FunctionSquare,
|
||||
'sub-workflow': GitBranch,
|
||||
'request-port': Radio,
|
||||
};
|
||||
|
||||
function PlaceholderNodeInspector({
|
||||
node,
|
||||
onNodeChange,
|
||||
onNodeRemove,
|
||||
}: {
|
||||
node: WorkflowNode;
|
||||
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||
onNodeRemove: (nodeId: string) => void;
|
||||
}) {
|
||||
const Icon = placeholderIcons[node.kind] ?? Info;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
|
||||
<Icon className="size-4 text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||
{node.label || node.kind}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => onNodeRemove(node.id)}
|
||||
title="Remove node"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<InputField
|
||||
label="Label"
|
||||
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||
placeholder="Display label"
|
||||
value={node.label}
|
||||
/>
|
||||
<div className="rounded-lg border border-[var(--color-status-warning)]/20 bg-[var(--color-status-warning)]/5 px-3 py-2 text-[12px] text-[var(--color-status-warning)]">
|
||||
Full configuration coming in Phase 2.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Edge inspector ────────────────────────────────────────── */
|
||||
|
||||
function EdgeInspector({
|
||||
edge,
|
||||
onEdgeChange,
|
||||
onEdgeRemove,
|
||||
}: {
|
||||
edge: WorkflowEdge;
|
||||
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
|
||||
onEdgeRemove: (edgeId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">Edge</div>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||
onClick={() => onEdgeRemove(edge.id)}
|
||||
title="Remove edge"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Kind</span>
|
||||
<select
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||
onChange={(e) => onEdgeChange(edge.id, { kind: e.target.value as WorkflowEdge['kind'] })}
|
||||
value={edge.kind}
|
||||
>
|
||||
<option value="direct">Direct</option>
|
||||
<option value="fan-out">Fan-out</option>
|
||||
<option value="fan-in">Fan-in</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<InputField
|
||||
label="Label"
|
||||
onChange={(v) => onEdgeChange(edge.id, { label: v || undefined })}
|
||||
placeholder="Optional edge label"
|
||||
value={edge.label ?? ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main inspector component ──────────────────────────────── */
|
||||
|
||||
export function WorkflowGraphInspector({
|
||||
availableModels,
|
||||
workflow,
|
||||
selectedNodeId,
|
||||
selectedEdgeId,
|
||||
onNodeChange,
|
||||
onNodeConfigChange,
|
||||
onNodeRemove,
|
||||
onEdgeChange,
|
||||
onEdgeRemove,
|
||||
}: WorkflowGraphInspectorProps) {
|
||||
const selectedNode = selectedNodeId
|
||||
? workflow.graph.nodes.find((n) => n.id === selectedNodeId)
|
||||
: undefined;
|
||||
const selectedEdge = selectedEdgeId
|
||||
? workflow.graph.edges.find((e) => e.id === selectedEdgeId)
|
||||
: undefined;
|
||||
|
||||
if (selectedEdge) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<EdgeInspector edge={selectedEdge} onEdgeChange={onEdgeChange} onEdgeRemove={onEdgeRemove} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedNode) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<p className="text-center text-[12px] text-[var(--color-text-muted)]">
|
||||
Select a node or edge to inspect
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedNode.kind === 'start' || selectedNode.kind === 'end') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<SystemNodeInspector node={selectedNode} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedNode.kind === 'agent') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<AgentNodeInspector
|
||||
availableModels={availableModels}
|
||||
node={selectedNode}
|
||||
onNodeChange={onNodeChange}
|
||||
onNodeConfigChange={onNodeConfigChange}
|
||||
onNodeRemove={onNodeRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<PlaceholderNodeInspector
|
||||
node={selectedNode}
|
||||
onNodeChange={onNodeChange}
|
||||
onNodeRemove={onNodeRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { Bot, Circle, Code, FunctionSquare, GitBranch, Radio, Play, Square } from 'lucide-react';
|
||||
|
||||
import type { WorkflowGraphNodeData } from '@renderer/lib/workflowGraph';
|
||||
import type { WorkflowNodeKind } from '@shared/domain/workflow';
|
||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||
|
||||
/* ── Styling constants ─────────────────────────────────────── */
|
||||
|
||||
const kindColors: Record<WorkflowNodeKind, { bg: string; border: string; text: string }> = {
|
||||
start: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', text: 'text-emerald-400' },
|
||||
end: { bg: 'bg-rose-500/10', border: 'border-rose-500/30', text: 'text-rose-400' },
|
||||
agent: { bg: 'bg-[var(--color-surface-2)]/80', border: 'border-[var(--color-border)]/40', text: 'text-[var(--color-text-primary)]' },
|
||||
'code-executor': { bg: 'bg-sky-500/10', border: 'border-sky-500/30', text: 'text-sky-400' },
|
||||
'function-executor': { bg: 'bg-violet-500/10', border: 'border-violet-500/30', text: 'text-violet-400' },
|
||||
'sub-workflow': { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
|
||||
'request-port': { bg: 'bg-teal-500/10', border: 'border-teal-500/30', text: 'text-teal-400' },
|
||||
};
|
||||
|
||||
const kindIcons: Record<WorkflowNodeKind, typeof Bot> = {
|
||||
start: Play,
|
||||
end: Square,
|
||||
agent: Bot,
|
||||
'code-executor': Code,
|
||||
'function-executor': FunctionSquare,
|
||||
'sub-workflow': GitBranch,
|
||||
'request-port': Radio,
|
||||
};
|
||||
|
||||
const handleStyles = {
|
||||
flow: '!size-2 !border-[var(--color-border)] !bg-[var(--color-text-secondary)]',
|
||||
agent: '!size-2 !border-[var(--color-accent-sky)] !bg-[var(--color-accent)]',
|
||||
hidden: '!size-0 !border-0 !bg-transparent !min-w-0 !min-h-0',
|
||||
};
|
||||
|
||||
/* ── Shared node content ───────────────────────────────────── */
|
||||
|
||||
function WorkflowNodeContent({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) {
|
||||
const colors = kindColors[data.kind] ?? kindColors.agent;
|
||||
const isAgent = data.kind === 'agent';
|
||||
|
||||
const renderIcon = () => {
|
||||
if (isAgent && data.provider) {
|
||||
return <ProviderIcon provider={data.provider} className="size-4 shrink-0" />;
|
||||
}
|
||||
const FallbackIcon = kindIcons[data.kind] ?? Circle;
|
||||
return <FallbackIcon className={`size-4 shrink-0 ${colors.text}`} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md backdrop-blur-sm transition ${
|
||||
colors.bg
|
||||
} ${selected ? 'ring-2 ring-[var(--color-accent)]/50' : ''} ${colors.border}`}
|
||||
>
|
||||
{renderIcon()}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate text-[12px] font-semibold ${colors.text}`}>
|
||||
{data.label}
|
||||
</div>
|
||||
{isAgent && data.modelLabel && (
|
||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{data.modelLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Node type components (all memoized) ───────────────────── */
|
||||
|
||||
export const StartNode = memo(function StartNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.hidden} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const EndNode = memo(function EndNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.hidden} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.agent} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.agent} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const CodeExecutorNode = memo(function CodeExecutorNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const FunctionExecutorNode = memo(function FunctionExecutorNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const SubWorkflowNode = memo(function SubWorkflowNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const RequestPortNode = memo(function RequestPortNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as WorkflowGraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.flow} />
|
||||
<WorkflowNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.flow} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Node type map for ReactFlow ───────────────────────────── */
|
||||
|
||||
export const workflowNodeTypes = {
|
||||
startNode: StartNode,
|
||||
endNode: EndNode,
|
||||
agentNode: AgentNode,
|
||||
codeExecutorNode: CodeExecutorNode,
|
||||
functionExecutorNode: FunctionExecutorNode,
|
||||
subWorkflowNode: SubWorkflowNode,
|
||||
requestPortNode: RequestPortNode,
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Bot, Code, FunctionSquare, GitBranch, Play, Radio, Square } from 'lucide-react';
|
||||
|
||||
import type { WorkflowNodeKind } from '@shared/domain/workflow';
|
||||
|
||||
interface WorkflowNodePaletteProps {
|
||||
onAddNode: (kind: WorkflowNodeKind) => void;
|
||||
}
|
||||
|
||||
interface PaletteItem {
|
||||
kind: WorkflowNodeKind;
|
||||
label: string;
|
||||
icon: typeof Bot;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface PaletteGroup {
|
||||
label: string;
|
||||
items: PaletteItem[];
|
||||
}
|
||||
|
||||
const paletteGroups: PaletteGroup[] = [
|
||||
{
|
||||
label: 'Flow Control',
|
||||
items: [
|
||||
{ kind: 'start', label: 'Start', icon: Play, color: 'text-emerald-400' },
|
||||
{ kind: 'end', label: 'End', icon: Square, color: 'text-rose-400' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Agents',
|
||||
items: [
|
||||
{ kind: 'agent', label: 'Agent', icon: Bot, color: 'text-[var(--color-accent)]' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Processing',
|
||||
items: [
|
||||
{ kind: 'code-executor', label: 'Code', icon: Code, color: 'text-sky-400' },
|
||||
{ kind: 'function-executor', label: 'Function', icon: FunctionSquare, color: 'text-violet-400' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Integration',
|
||||
items: [
|
||||
{ kind: 'sub-workflow', label: 'Sub-Workflow', icon: GitBranch, color: 'text-amber-400' },
|
||||
{ kind: 'request-port', label: 'Port', icon: Radio, color: 'text-teal-400' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function WorkflowNodePalette({ onAddNode }: WorkflowNodePaletteProps) {
|
||||
return (
|
||||
<div className="space-y-4 p-3">
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Nodes
|
||||
</h4>
|
||||
{paletteGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{group.label}
|
||||
</span>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[12px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
key={item.kind}
|
||||
onClick={() => onAddNode(item.kind)}
|
||||
type="button"
|
||||
>
|
||||
<Icon className={`size-3.5 ${item.color}`} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import dagre from '@dagrejs/dagre';
|
||||
import { MarkerType, type Node, type Edge, type Connection } from '@xyflow/react';
|
||||
|
||||
import type {
|
||||
WorkflowDefinition,
|
||||
WorkflowEdge as WfEdge,
|
||||
WorkflowEdgeKind,
|
||||
WorkflowGraph,
|
||||
WorkflowNode,
|
||||
WorkflowNodeKind,
|
||||
} from '@shared/domain/workflow';
|
||||
import type { ModelProvider } from '@shared/domain/models';
|
||||
import { inferProvider, findModel, type ModelDefinition } from '@shared/domain/models';
|
||||
|
||||
/* ── Canvas node data ──────────────────────────────────────── */
|
||||
|
||||
export interface WorkflowGraphNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
kind: WorkflowNodeKind;
|
||||
/** AI provider inferred from the agent's model (agent nodes only). */
|
||||
provider?: ModelProvider;
|
||||
/** Short display name for the agent's model (agent nodes only). */
|
||||
modelLabel?: string;
|
||||
}
|
||||
|
||||
/* ── View-model projection ─────────────────────────────────── */
|
||||
|
||||
function resolveNodeType(kind: WorkflowNodeKind): string {
|
||||
switch (kind) {
|
||||
case 'start':
|
||||
return 'startNode';
|
||||
case 'end':
|
||||
return 'endNode';
|
||||
case 'agent':
|
||||
return 'agentNode';
|
||||
case 'code-executor':
|
||||
return 'codeExecutorNode';
|
||||
case 'function-executor':
|
||||
return 'functionExecutorNode';
|
||||
case 'sub-workflow':
|
||||
return 'subWorkflowNode';
|
||||
case 'request-port':
|
||||
return 'requestPortNode';
|
||||
}
|
||||
}
|
||||
|
||||
export function toCanvasNodes(
|
||||
graph: WorkflowGraph,
|
||||
models?: ReadonlyArray<ModelDefinition>,
|
||||
): Node<WorkflowGraphNodeData>[] {
|
||||
return graph.nodes.map((node) => {
|
||||
let provider: ModelProvider | undefined;
|
||||
let modelLabel: string | undefined;
|
||||
|
||||
if (node.kind === 'agent' && node.config.kind === 'agent') {
|
||||
const modelId = node.config.model;
|
||||
if (modelId) {
|
||||
provider = inferProvider(modelId);
|
||||
const modelDef = models ? findModel(modelId, models) : undefined;
|
||||
modelLabel = modelDef?.name ?? modelId;
|
||||
}
|
||||
}
|
||||
|
||||
const isSystemNode = node.kind === 'start' || node.kind === 'end';
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: resolveNodeType(node.kind),
|
||||
position: { x: node.position.x, y: node.position.y },
|
||||
data: {
|
||||
label: node.label,
|
||||
kind: node.kind,
|
||||
provider,
|
||||
modelLabel,
|
||||
},
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: !isSystemNode,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Edge styling ──────────────────────────────────────────── */
|
||||
|
||||
const EDGE_COLORS: Record<WorkflowEdgeKind, { stroke: string; markerColor: string }> = {
|
||||
direct: { stroke: '#6366f1', markerColor: '#6366f1' },
|
||||
'fan-out': { stroke: '#f59e0b', markerColor: '#f59e0b' },
|
||||
'fan-in': { stroke: '#10b981', markerColor: '#10b981' },
|
||||
};
|
||||
|
||||
const EDGE_DASH: Record<WorkflowEdgeKind, string | undefined> = {
|
||||
direct: undefined,
|
||||
'fan-out': '6 3',
|
||||
'fan-in': '2 2',
|
||||
};
|
||||
|
||||
export function toCanvasEdges(graph: WorkflowGraph): Edge[] {
|
||||
return graph.edges.map((edge) => {
|
||||
const color = EDGE_COLORS[edge.kind] ?? EDGE_COLORS.direct;
|
||||
const dashArray = EDGE_DASH[edge.kind];
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'default',
|
||||
animated: edge.kind === 'fan-out',
|
||||
deletable: true,
|
||||
label: edge.label,
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: color.markerColor },
|
||||
style: {
|
||||
stroke: color.stroke,
|
||||
strokeWidth: 1.5,
|
||||
...(dashArray ? { strokeDasharray: dashArray } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Position sync ─────────────────────────────────────────── */
|
||||
|
||||
export function fromCanvasPositions(
|
||||
workflow: WorkflowDefinition,
|
||||
canvasNodes: Node[],
|
||||
): WorkflowGraph {
|
||||
const positionMap = new Map(canvasNodes.map((n) => [n.id, n.position]));
|
||||
|
||||
return {
|
||||
nodes: workflow.graph.nodes.map((node) => {
|
||||
const pos = positionMap.get(node.id);
|
||||
return pos ? { ...node, position: { x: Math.round(pos.x), y: Math.round(pos.y) } } : node;
|
||||
}),
|
||||
edges: workflow.graph.edges,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Connection rules ──────────────────────────────────────── */
|
||||
|
||||
export function isWorkflowConnectionAllowed(
|
||||
connection: Connection,
|
||||
graph: WorkflowGraph,
|
||||
): boolean {
|
||||
if (!connection.source || !connection.target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (connection.source === connection.target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceNode = graph.nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = graph.nodes.find((n) => n.id === connection.target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start nodes cannot receive incoming edges
|
||||
if (targetNode.kind === 'start') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// End nodes cannot have outgoing edges
|
||||
if (sourceNode.kind === 'end') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No duplicate edges
|
||||
if (graph.edges.some((e) => e.source === connection.source && e.target === connection.target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Graph mutation helpers ─────────────────────────────────── */
|
||||
|
||||
function edgeId(source: string, target: string): string {
|
||||
return `edge-${source}-to-${target}`;
|
||||
}
|
||||
|
||||
export function addWorkflowEdge(graph: WorkflowGraph, source: string, target: string): WorkflowGraph {
|
||||
const newEdge: WfEdge = {
|
||||
id: edgeId(source, target),
|
||||
source,
|
||||
target,
|
||||
kind: 'direct',
|
||||
};
|
||||
|
||||
if (graph.edges.some((e) => e.source === source && e.target === target)) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
return { ...graph, edges: [...graph.edges, newEdge] };
|
||||
}
|
||||
|
||||
export function removeWorkflowEdge(graph: WorkflowGraph, removeEdgeId: string): WorkflowGraph {
|
||||
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
|
||||
}
|
||||
|
||||
/* ── Auto-layout via dagre ─────────────────────────────────── */
|
||||
|
||||
const NODE_WIDTH = 170;
|
||||
const NODE_HEIGHT = 52;
|
||||
|
||||
export function autoLayoutWorkflowGraph(graph: WorkflowGraph): WorkflowGraph {
|
||||
const g = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({
|
||||
rankdir: 'LR',
|
||||
nodesep: 60,
|
||||
ranksep: 120,
|
||||
marginx: 20,
|
||||
marginy: 20,
|
||||
});
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const layoutedNodes = graph.nodes.map((node) => {
|
||||
const dagreNode = g.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: Math.round(dagreNode.x - NODE_WIDTH / 2),
|
||||
y: Math.round(dagreNode.y - NODE_HEIGHT / 2),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges: graph.edges };
|
||||
}
|
||||
Reference in New Issue
Block a user