mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user