mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 21:33:58 +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 type { ProjectGitFileReference } from '@shared/domain/project';
|
||||||
import { applySessionModelConfig } from '@shared/domain/session';
|
import { applySessionModelConfig } from '@shared/domain/session';
|
||||||
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
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 { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import type { UpdateStatus } from '@shared/contracts/ipc';
|
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() {
|
export default function App() {
|
||||||
const api = getElectronApi();
|
const api = getElectronApi();
|
||||||
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
||||||
@@ -723,6 +768,9 @@ export default function App() {
|
|||||||
onDeletePattern={async (id) => {
|
onDeletePattern={async (id) => {
|
||||||
await api.deletePattern(id);
|
await api.deletePattern(id);
|
||||||
}}
|
}}
|
||||||
|
onDeleteWorkflow={async (id) => {
|
||||||
|
await api.deleteWorkflow(id);
|
||||||
|
}}
|
||||||
onNewLspProfile={createDraftLspProfile}
|
onNewLspProfile={createDraftLspProfile}
|
||||||
onNewMcpServer={createDraftMcpServer}
|
onNewMcpServer={createDraftMcpServer}
|
||||||
onNewPattern={() => {
|
onNewPattern={() => {
|
||||||
@@ -733,6 +781,13 @@ export default function App() {
|
|||||||
resolveReasoningEffort(defaultModel, 'high'),
|
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}
|
onRefreshCapabilities={refreshCapabilities}
|
||||||
onSaveLspProfile={async (profile) => {
|
onSaveLspProfile={async (profile) => {
|
||||||
await api.saveLspProfile({ profile });
|
await api.saveLspProfile({ profile });
|
||||||
@@ -743,6 +798,9 @@ export default function App() {
|
|||||||
onSavePattern={async (pattern) => {
|
onSavePattern={async (pattern) => {
|
||||||
await api.savePattern({ pattern });
|
await api.savePattern({ pattern });
|
||||||
}}
|
}}
|
||||||
|
onSaveWorkflow={async (workflow) => {
|
||||||
|
await api.saveWorkflow({ workflow });
|
||||||
|
}}
|
||||||
onSaveWorkspaceAgent={async (agent) => {
|
onSaveWorkspaceAgent={async (agent) => {
|
||||||
await api.saveWorkspaceAgent({ agent });
|
await api.saveWorkspaceAgent({ agent });
|
||||||
}}
|
}}
|
||||||
@@ -769,6 +827,7 @@ export default function App() {
|
|||||||
setShowSettings(false);
|
setShowSettings(false);
|
||||||
}}
|
}}
|
||||||
patterns={workspace.patterns}
|
patterns={workspace.patterns}
|
||||||
|
workflows={workspace.workflows}
|
||||||
sidecarCapabilities={sidecarCapabilities}
|
sidecarCapabilities={sidecarCapabilities}
|
||||||
theme={workspace.settings.theme}
|
theme={workspace.settings.theme}
|
||||||
toolingSettings={workspace.settings.tooling}
|
toolingSettings={workspace.settings.tooling}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
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 { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||||
|
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
|
||||||
import { ToggleSwitch } from '@renderer/components/ui';
|
import { ToggleSwitch } from '@renderer/components/ui';
|
||||||
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
|
||||||
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
|
||||||
@@ -13,6 +14,7 @@ import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } fro
|
|||||||
import type { ModelDefinition } from '@shared/domain/models';
|
import type { ModelDefinition } from '@shared/domain/models';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
||||||
|
import { normalizeWorkflowDefinition, type WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import {
|
import {
|
||||||
normalizeLspProfileDefinition,
|
normalizeLspProfileDefinition,
|
||||||
normalizeMcpServerDefinition,
|
normalizeMcpServerDefinition,
|
||||||
@@ -26,6 +28,7 @@ import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type Works
|
|||||||
interface SettingsPanelProps {
|
interface SettingsPanelProps {
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
patterns: PatternDefinition[];
|
patterns: PatternDefinition[];
|
||||||
|
workflows: WorkflowDefinition[];
|
||||||
sidecarCapabilities?: SidecarCapabilities;
|
sidecarCapabilities?: SidecarCapabilities;
|
||||||
theme: AppearanceTheme;
|
theme: AppearanceTheme;
|
||||||
toolingSettings: WorkspaceToolingSettings;
|
toolingSettings: WorkspaceToolingSettings;
|
||||||
@@ -37,6 +40,9 @@ interface SettingsPanelProps {
|
|||||||
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
||||||
onDeletePattern: (patternId: string) => Promise<void>;
|
onDeletePattern: (patternId: string) => Promise<void>;
|
||||||
onNewPattern: () => PatternDefinition;
|
onNewPattern: () => PatternDefinition;
|
||||||
|
onSaveWorkflow: (workflow: WorkflowDefinition) => Promise<void>;
|
||||||
|
onDeleteWorkflow: (workflowId: string) => Promise<void>;
|
||||||
|
onNewWorkflow: () => WorkflowDefinition;
|
||||||
onSaveMcpServer: (server: McpServerDefinition) => Promise<void>;
|
onSaveMcpServer: (server: McpServerDefinition) => Promise<void>;
|
||||||
onDeleteMcpServer: (serverId: string) => Promise<void>;
|
onDeleteMcpServer: (serverId: string) => Promise<void>;
|
||||||
onNewMcpServer: () => McpServerDefinition;
|
onNewMcpServer: () => McpServerDefinition;
|
||||||
@@ -60,7 +66,7 @@ interface SettingsPanelProps {
|
|||||||
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
|
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 {
|
interface NavItem {
|
||||||
id: SettingsSection;
|
id: SettingsSection;
|
||||||
@@ -90,6 +96,7 @@ const navGroups: NavGroup[] = [
|
|||||||
label: 'Workflows',
|
label: 'Workflows',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
|
{ 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" /> },
|
{ id: 'agents', label: 'Agents', icon: <UserCircle className="size-3.5" /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -116,6 +123,7 @@ function modeBadgeClasses(pattern: PatternDefinition) {
|
|||||||
export function SettingsPanel({
|
export function SettingsPanel({
|
||||||
availableModels,
|
availableModels,
|
||||||
patterns,
|
patterns,
|
||||||
|
workflows,
|
||||||
sidecarCapabilities,
|
sidecarCapabilities,
|
||||||
theme,
|
theme,
|
||||||
toolingSettings,
|
toolingSettings,
|
||||||
@@ -127,6 +135,9 @@ export function SettingsPanel({
|
|||||||
onSavePattern,
|
onSavePattern,
|
||||||
onDeletePattern,
|
onDeletePattern,
|
||||||
onNewPattern,
|
onNewPattern,
|
||||||
|
onSaveWorkflow,
|
||||||
|
onDeleteWorkflow,
|
||||||
|
onNewWorkflow,
|
||||||
onSaveMcpServer,
|
onSaveMcpServer,
|
||||||
onDeleteMcpServer,
|
onDeleteMcpServer,
|
||||||
onNewMcpServer,
|
onNewMcpServer,
|
||||||
@@ -151,6 +162,7 @@ export function SettingsPanel({
|
|||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
|
||||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||||
|
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
|
||||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
||||||
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
||||||
const [editingWorkspaceAgent, setEditingWorkspaceAgent] = useState<WorkspaceAgentDefinition | 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) {
|
if (editingMcpServer) {
|
||||||
const exists = toolingSettings.mcpServers.some((server) => server.id === editingMcpServer.id);
|
const exists = toolingSettings.mcpServers.some((server) => server.id === editingMcpServer.id);
|
||||||
return (
|
return (
|
||||||
@@ -337,6 +372,13 @@ export function SettingsPanel({
|
|||||||
patterns={patterns}
|
patterns={patterns}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeSection === 'workflows' && (
|
||||||
|
<WorkflowsSection
|
||||||
|
onEditWorkflow={(wf) => setEditingWorkflow(structuredClone(wf))}
|
||||||
|
onNewWorkflow={() => setEditingWorkflow(onNewWorkflow())}
|
||||||
|
workflows={workflows}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeSection === 'agents' && (
|
{activeSection === 'agents' && (
|
||||||
<WorkspaceAgentsSection
|
<WorkspaceAgentsSection
|
||||||
agents={workspaceAgents}
|
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({
|
function WorkspaceAgentsSection({
|
||||||
agents,
|
agents,
|
||||||
patterns,
|
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