mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-29 16:08:46 +02:00
feat: replace form-based pattern editor with interactive graph canvas
Replace the flat agent list and static flow diagrams in PatternEditor with an interactive React Flow canvas backed by the authoritative PatternGraph model. Users can now see and manipulate orchestration topology visually. Changes: - Add @xyflow/react (React Flow) as a canvas library - Create patternGraph view-model layer for graph-to-canvas projection - Create GraphNodes with custom system/agent node components - Create PatternGraphCanvas with drag, connect, and selection support - Create PatternGraphInspector for selected node/agent details - Rewrite PatternEditor with split layout: canvas + inspector sidebar - Add dark theme CSS overrides for React Flow - Add 11 tests covering view-model projection, connection rules, and graph mutation helpers - Update ARCHITECTURE.md to reflect the new renderer graph boundary The canvas projects PatternGraph into React Flow nodes/edges. Handoff mode supports drawing new agent-to-agent edges interactively. Node positions persist to the graph on drag completion. Agent editing moves from inline forms to the inspector panel on node selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeftRight,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
GitFork,
|
||||
ListOrdered,
|
||||
@@ -17,18 +16,15 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { ApprovalCheckpointKind, ApprovalPolicy } from '@shared/domain/approval';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import {
|
||||
findModel,
|
||||
getSupportedReasoningEfforts,
|
||||
resolveReasoningEffort,
|
||||
type ModelDefinition,
|
||||
} from '@shared/domain/models';
|
||||
import {
|
||||
resolvePatternGraph,
|
||||
syncPatternGraph,
|
||||
validatePatternDefinition,
|
||||
type OrchestrationMode,
|
||||
type PatternDefinition,
|
||||
type PatternAgentDefinition,
|
||||
type PatternGraph,
|
||||
} from '@shared/domain/pattern';
|
||||
import {
|
||||
listApprovalToolDefinitions,
|
||||
@@ -38,7 +34,8 @@ import {
|
||||
type WorkspaceToolingSettings,
|
||||
} from '@shared/domain/tooling';
|
||||
|
||||
import { ModelSelect, ReasoningEffortSelect } from './AgentConfigFields';
|
||||
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
|
||||
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
|
||||
|
||||
interface PatternEditorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
@@ -91,86 +88,6 @@ const modeInfo: Record<OrchestrationMode, ModeInfo> = {
|
||||
},
|
||||
};
|
||||
|
||||
function FlowPill({ children, variant = 'agent' }: { children: ReactNode; variant?: 'user' | 'agent' }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded px-1.5 py-0.5 text-[9px] font-semibold leading-none ${
|
||||
variant === 'user'
|
||||
? 'bg-indigo-500/20 text-indigo-400'
|
||||
: 'bg-zinc-700/60 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowArrow({ label }: { label?: string }) {
|
||||
return <span className="text-[10px] leading-none text-zinc-600">{label ?? '→'}</span>;
|
||||
}
|
||||
|
||||
function ModeFlowDiagram({ mode }: { mode: OrchestrationMode }) {
|
||||
switch (mode) {
|
||||
case 'single':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FlowPill variant="user">You</FlowPill>
|
||||
<FlowArrow />
|
||||
<FlowPill>Agent</FlowPill>
|
||||
<FlowArrow />
|
||||
<FlowPill variant="user">You</FlowPill>
|
||||
</div>
|
||||
);
|
||||
case 'sequential':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FlowPill>A₁</FlowPill>
|
||||
<FlowArrow />
|
||||
<FlowPill>A₂</FlowPill>
|
||||
<FlowArrow />
|
||||
<FlowPill>A₃</FlowPill>
|
||||
</div>
|
||||
);
|
||||
case 'concurrent':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FlowPill variant="user">You</FlowPill>
|
||||
<FlowArrow />
|
||||
<div className="flex gap-0.5">
|
||||
<FlowPill>A₁</FlowPill>
|
||||
<FlowPill>A₂</FlowPill>
|
||||
<FlowPill>A₃</FlowPill>
|
||||
</div>
|
||||
<FlowArrow />
|
||||
<FlowPill variant="user">You</FlowPill>
|
||||
</div>
|
||||
);
|
||||
case 'handoff':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FlowPill>Triage</FlowPill>
|
||||
<FlowArrow label="⇄" />
|
||||
<FlowPill>S₁</FlowPill>
|
||||
<FlowPill>S₂</FlowPill>
|
||||
</div>
|
||||
);
|
||||
case 'group-chat':
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<FlowPill>A₁</FlowPill>
|
||||
<FlowArrow label="⇄" />
|
||||
<FlowPill>A₂</FlowPill>
|
||||
<FlowArrow label="⇄" />
|
||||
<FlowPill>A₃</FlowPill>
|
||||
</div>
|
||||
);
|
||||
case 'magentic':
|
||||
return <span className="text-[10px] italic text-zinc-700">Coming soon</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function InputField({
|
||||
label,
|
||||
value,
|
||||
@@ -219,12 +136,35 @@ export function PatternEditor({
|
||||
onSave,
|
||||
onBack,
|
||||
}: PatternEditorProps) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const issues = validatePatternDefinition(pattern);
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
|
||||
function emitChange(nextPattern: PatternDefinition) {
|
||||
onChange(syncPatternGraph(nextPattern));
|
||||
}
|
||||
|
||||
function emitGraphChange(nextGraph: PatternGraph) {
|
||||
onChange({ ...pattern, graph: nextGraph });
|
||||
}
|
||||
|
||||
function addAgent() {
|
||||
emitChange({
|
||||
...pattern,
|
||||
agents: [
|
||||
...pattern.agents,
|
||||
{
|
||||
id: `agent-${crypto.randomUUID()}`,
|
||||
name: `Agent ${pattern.agents.length + 1}`,
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
|
||||
emitChange({
|
||||
...pattern,
|
||||
@@ -232,12 +172,16 @@ export function PatternEditor({
|
||||
});
|
||||
}
|
||||
|
||||
function updateAgentModel(agent: PatternAgentDefinition, modelId: string) {
|
||||
const model = findModel(modelId, availableModels);
|
||||
updateAgent(agent.id, {
|
||||
model: modelId,
|
||||
reasoningEffort: resolveReasoningEffort(model, agent.reasoningEffort),
|
||||
function removeAgent(agentId: string) {
|
||||
if (pattern.agents.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.filter((a) => a.id !== agentId),
|
||||
});
|
||||
setSelectedNodeId(null);
|
||||
}
|
||||
|
||||
function updateApprovalPolicy(updater: (current: ApprovalPolicy | undefined) => ApprovalPolicy | undefined) {
|
||||
@@ -289,7 +233,7 @@ export function PatternEditor({
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header — consistent ← navigation */}
|
||||
{/* Header */}
|
||||
<div className="drag-region flex items-center justify-between border-b border-[var(--color-border)] px-5 pb-3 pt-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -329,267 +273,211 @@ export function PatternEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="mx-auto max-w-2xl space-y-8">
|
||||
{/* Body — graph canvas + inspector split */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left column: graph canvas + settings below */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Validation banner */}
|
||||
{issues.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{issues.map((issue, i) => (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] ${
|
||||
issue.level === 'error'
|
||||
? 'bg-red-500/10 text-red-300'
|
||||
: 'bg-amber-500/10 text-amber-300'
|
||||
}`}
|
||||
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-emerald-500/10 px-3 py-2 text-[13px] text-emerald-300">
|
||||
<CheckCircle className="size-3.5" />
|
||||
Pattern is valid and ready to use
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name + description */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
General
|
||||
</h4>
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => emitChange({ ...pattern, name: v })}
|
||||
placeholder="Pattern name"
|
||||
value={pattern.name}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
multiline
|
||||
onChange={(v) => emitChange({ ...pattern, description: v })}
|
||||
placeholder="What this pattern does..."
|
||||
value={pattern.description}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Mode selector cards */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Orchestration Mode
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.keys(modeInfo) as OrchestrationMode[]).map((mode) => {
|
||||
const info = modeInfo[mode];
|
||||
const Icon = info.icon;
|
||||
const selected = pattern.mode === mode;
|
||||
const disabled = mode === 'magentic';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex flex-col rounded-xl border p-3 text-left transition ${
|
||||
selected
|
||||
? 'border-indigo-500/40 bg-indigo-500/5 ring-1 ring-indigo-500/20'
|
||||
: disabled
|
||||
? 'cursor-not-allowed border-zinc-800/50 opacity-40'
|
||||
: 'border-zinc-800 hover:border-zinc-700 hover:bg-zinc-900/60'
|
||||
<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-red-500/10 text-red-300'
|
||||
: 'bg-amber-500/10 text-amber-300'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
key={mode}
|
||||
onClick={() => emitChange({ ...pattern, mode })}
|
||||
type="button"
|
||||
key={`${issue.field ?? 'v'}-${i}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className={`size-4 ${selected ? 'text-indigo-400' : 'text-zinc-500'}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[12px] font-semibold ${
|
||||
selected ? 'text-indigo-200' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-snug text-zinc-500">
|
||||
{info.description}
|
||||
</p>
|
||||
<div className="mt-2.5">
|
||||
<ModeFlowDiagram mode={mode} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
{issue.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-[12px] text-emerald-300">
|
||||
<CheckCircle className="size-3.5" />
|
||||
Pattern is valid
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agents */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Agents ({pattern.agents.length})
|
||||
</h4>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||
onClick={() =>
|
||||
emitChange({
|
||||
...pattern,
|
||||
agents: [
|
||||
...pattern.agents,
|
||||
{
|
||||
id: `agent-${crypto.randomUUID()}`,
|
||||
name: `Agent ${pattern.agents.length + 1}`,
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add agent
|
||||
</button>
|
||||
</div>
|
||||
{/* Graph canvas */}
|
||||
<div className="flex items-center justify-between px-5 pt-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Topology
|
||||
</h4>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||
onClick={addAgent}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{pattern.agents.map((agent, index) => (
|
||||
<div
|
||||
className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4"
|
||||
key={agent.id}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-5 items-center justify-center rounded bg-zinc-700/60 text-[10px] font-bold text-zinc-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-[12px] font-medium text-zinc-300">
|
||||
{agent.name || 'Unnamed agent'}
|
||||
</span>
|
||||
</div>
|
||||
{pattern.agents.length > 1 && (
|
||||
<div className="min-h-[300px] flex-1 px-5 py-3">
|
||||
<PatternGraphCanvas
|
||||
pattern={pattern}
|
||||
onGraphChange={emitGraphChange}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
selectedNodeId={selectedNodeId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollable settings below graph */}
|
||||
<div className="max-h-[45%] overflow-y-auto border-t border-zinc-800/50 px-5 py-5">
|
||||
<div className="space-y-8">
|
||||
{/* General */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
General
|
||||
</h4>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => emitChange({ ...pattern, name: v })}
|
||||
placeholder="Pattern name"
|
||||
value={pattern.name}
|
||||
/>
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => emitChange({ ...pattern, description: v })}
|
||||
placeholder="What this pattern does..."
|
||||
value={pattern.description}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mode selector */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Orchestration Mode
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(modeInfo) as OrchestrationMode[]).map((mode) => {
|
||||
const info = modeInfo[mode];
|
||||
const Icon = info.icon;
|
||||
const selected = pattern.mode === mode;
|
||||
const disabled = mode === 'magentic';
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1 text-[12px] text-zinc-600 transition hover:text-red-400"
|
||||
onClick={() =>
|
||||
emitChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.filter((a) => a.id !== agent.id),
|
||||
})
|
||||
}
|
||||
className={`flex flex-col rounded-xl border p-2.5 text-left transition ${
|
||||
selected
|
||||
? 'border-indigo-500/40 bg-indigo-500/5 ring-1 ring-indigo-500/20'
|
||||
: disabled
|
||||
? 'cursor-not-allowed border-zinc-800/50 opacity-40'
|
||||
: 'border-zinc-800 hover:border-zinc-700 hover:bg-zinc-900/60'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
key={mode}
|
||||
onClick={() => emitChange({ ...pattern, mode })}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon
|
||||
className={`size-3.5 ${selected ? 'text-indigo-400' : 'text-zinc-500'}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[11px] font-semibold ${
|
||||
selected ? 'text-indigo-200' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-snug text-zinc-500">
|
||||
{info.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Approval checkpoints */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Approval Checkpoints
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-zinc-600">
|
||||
Pause the run for human review before risky actions or publishing responses.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ApprovalCheckpointRow
|
||||
agents={pattern.agents}
|
||||
enabled={isCheckpointEnabled('tool-call')}
|
||||
kind="tool-call"
|
||||
label="Tool call approval"
|
||||
description="Require approval before the agent executes tool calls"
|
||||
onToggle={(enabled) => toggleCheckpoint('tool-call', enabled)}
|
||||
scopedAgentIds={checkpointAgentIds('tool-call')}
|
||||
onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)}
|
||||
/>
|
||||
<ApprovalCheckpointRow
|
||||
agents={pattern.agents}
|
||||
enabled={isCheckpointEnabled('final-response')}
|
||||
kind="final-response"
|
||||
label="Final response review"
|
||||
description="Review and approve assistant messages before publication"
|
||||
onToggle={(enabled) => toggleCheckpoint('final-response', enabled)}
|
||||
scopedAgentIds={checkpointAgentIds('final-response')}
|
||||
onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tool auto-approval */}
|
||||
{isCheckpointEnabled('tool-call') && (
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Tool Auto-Approval Defaults
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-zinc-600">
|
||||
Tools marked as auto-approved will skip manual review.
|
||||
Sessions can override these defaults from the Activity panel.
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 px-4 py-3">
|
||||
{approvalTools.length === 0 ? (
|
||||
<p className="py-2 text-center text-[11px] text-zinc-600">
|
||||
No tools available yet. Connect MCP servers or wait for runtime capabilities to load.
|
||||
</p>
|
||||
) : (
|
||||
<ToolApprovalGroupedList
|
||||
autoApprovedSet={autoApprovedSet}
|
||||
onToggle={toggleToolAutoApproval}
|
||||
tools={approvalTools}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => updateAgent(agent.id, { name: v })}
|
||||
value={agent.name}
|
||||
/>
|
||||
<ModelSelect
|
||||
models={availableModels}
|
||||
onChange={(value) => updateAgentModel(agent, value)}
|
||||
value={agent.model}
|
||||
/>
|
||||
<ReasoningEffortSelect
|
||||
label="Reasoning"
|
||||
onChange={(value) => updateAgent(agent.id, { reasoningEffort: value })}
|
||||
supportedEfforts={getSupportedReasoningEfforts(findModel(agent.model, availableModels))}
|
||||
value={resolveReasoningEffort(findModel(agent.model, availableModels), agent.reasoningEffort)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => updateAgent(agent.id, { description: v })}
|
||||
placeholder="What this agent does..."
|
||||
value={agent.description}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<InputField
|
||||
label="Instructions"
|
||||
multiline
|
||||
onChange={(v) => updateAgent(agent.id, { instructions: v })}
|
||||
placeholder="System prompt for this agent..."
|
||||
value={agent.instructions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval checkpoints */}
|
||||
<section className="space-y-4">
|
||||
{/* Right column: node inspector */}
|
||||
<div className="w-[320px] shrink-0 overflow-y-auto border-l border-zinc-800/50 bg-zinc-900/30">
|
||||
<div className="border-b border-zinc-800/50 px-4 py-3">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Approval Checkpoints
|
||||
Inspector
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-zinc-600">
|
||||
Pause the run for human review before risky actions or publishing responses.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<ApprovalCheckpointRow
|
||||
agents={pattern.agents}
|
||||
enabled={isCheckpointEnabled('tool-call')}
|
||||
kind="tool-call"
|
||||
label="Tool call approval"
|
||||
description="Require approval before the agent executes tool calls"
|
||||
onToggle={(enabled) => toggleCheckpoint('tool-call', enabled)}
|
||||
scopedAgentIds={checkpointAgentIds('tool-call')}
|
||||
onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)}
|
||||
/>
|
||||
<ApprovalCheckpointRow
|
||||
agents={pattern.agents}
|
||||
enabled={isCheckpointEnabled('final-response')}
|
||||
kind="final-response"
|
||||
label="Final response review"
|
||||
description="Review and approve assistant messages before publication"
|
||||
onToggle={(enabled) => toggleCheckpoint('final-response', enabled)}
|
||||
scopedAgentIds={checkpointAgentIds('final-response')}
|
||||
onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tool auto-approval defaults — only relevant when tool-call approval is on */}
|
||||
{isCheckpointEnabled('tool-call') && (
|
||||
<section className="space-y-4">
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Tool Auto-Approval Defaults
|
||||
</h4>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-zinc-600">
|
||||
Tools marked as auto-approved will skip manual review.
|
||||
Sessions can override these defaults from the Activity panel.
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 px-4 py-3">
|
||||
{approvalTools.length === 0 ? (
|
||||
<p className="py-2 text-center text-[11px] text-zinc-600">
|
||||
No tools available yet. Connect MCP servers or wait for runtime capabilities to load.
|
||||
</p>
|
||||
) : (
|
||||
<ToolApprovalGroupedList
|
||||
autoApprovedSet={autoApprovedSet}
|
||||
onToggle={toggleToolAutoApproval}
|
||||
tools={approvalTools}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<PatternGraphInspector
|
||||
availableModels={availableModels}
|
||||
agents={pattern.agents}
|
||||
graph={graph}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onAgentChange={updateAgent}
|
||||
onAgentRemove={removeAgent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||
import { CircleUser, Bot, Shuffle, Layers, Radio } from 'lucide-react';
|
||||
|
||||
import type { GraphNodeData } from '@renderer/lib/patternGraph';
|
||||
import type { PatternGraphNodeKind } from '@shared/domain/pattern';
|
||||
|
||||
const kindIcons: Record<PatternGraphNodeKind, typeof CircleUser> = {
|
||||
'user-input': CircleUser,
|
||||
'user-output': CircleUser,
|
||||
agent: Bot,
|
||||
distributor: Shuffle,
|
||||
collector: Layers,
|
||||
orchestrator: Radio,
|
||||
};
|
||||
|
||||
const kindColors: Record<PatternGraphNodeKind, { bg: string; border: string; text: string }> = {
|
||||
'user-input': { bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-400' },
|
||||
'user-output': { bg: 'bg-indigo-500/10', border: 'border-indigo-500/30', text: 'text-indigo-400' },
|
||||
agent: { bg: 'bg-zinc-800/80', border: 'border-zinc-600/40', text: 'text-zinc-200' },
|
||||
distributor: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
|
||||
collector: { bg: 'bg-amber-500/10', border: 'border-amber-500/30', text: 'text-amber-400' },
|
||||
orchestrator: { bg: 'bg-emerald-500/10', border: 'border-emerald-500/30', text: 'text-emerald-400' },
|
||||
};
|
||||
|
||||
function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: boolean }) {
|
||||
const Icon = kindIcons[data.kind] ?? Bot;
|
||||
const colors = kindColors[data.kind] ?? kindColors.agent;
|
||||
const isAgent = data.kind === 'agent';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md transition ${
|
||||
colors.bg
|
||||
} ${selected ? 'ring-2 ring-indigo-500/50' : ''} ${colors.border}`}
|
||||
>
|
||||
<Icon className={`size-4 shrink-0 ${colors.text}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate text-[12px] font-semibold ${colors.text}`}>
|
||||
{data.label}
|
||||
</div>
|
||||
{isAgent && typeof data.order === 'number' && (
|
||||
<div className="text-[10px] text-zinc-500">#{data.order + 1}</div>
|
||||
)}
|
||||
</div>
|
||||
{data.readOnly && (
|
||||
<span className="ml-1 rounded bg-zinc-700/50 px-1 py-0.5 text-[8px] font-medium text-zinc-500">
|
||||
SYS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SystemNode = memo(function SystemNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2 !border-zinc-600 !bg-zinc-400" />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className="!size-2 !border-zinc-600 !bg-zinc-400" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2 !border-indigo-400 !bg-indigo-500" />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className="!size-2 !border-indigo-400 !bg-indigo-500" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const graphNodeTypes = {
|
||||
systemNode: SystemNode,
|
||||
agentNode: AgentNode,
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
type Node,
|
||||
type OnConnect,
|
||||
type OnNodesChange,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import type { PatternDefinition, PatternGraph } from '@shared/domain/pattern';
|
||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||
import {
|
||||
addHandoffEdge,
|
||||
fromCanvasPositions,
|
||||
isConnectionAllowed,
|
||||
toCanvasEdges,
|
||||
toCanvasNodes,
|
||||
type GraphNodeData,
|
||||
} from '@renderer/lib/patternGraph';
|
||||
|
||||
import { graphNodeTypes } from './GraphNodes';
|
||||
|
||||
interface PatternGraphCanvasProps {
|
||||
pattern: PatternDefinition;
|
||||
onGraphChange: (graph: PatternGraph) => void;
|
||||
onNodeSelect: (nodeId: string | null) => void;
|
||||
selectedNodeId: string | null;
|
||||
}
|
||||
|
||||
export function PatternGraphCanvas({
|
||||
pattern,
|
||||
onGraphChange,
|
||||
onNodeSelect,
|
||||
selectedNodeId,
|
||||
}: PatternGraphCanvasProps) {
|
||||
const graph = useMemo(() => resolvePatternGraph(pattern), [pattern]);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(
|
||||
toCanvasNodes(graph, pattern.agents),
|
||||
);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
||||
toCanvasEdges(graph, pattern.mode),
|
||||
);
|
||||
|
||||
// Sync canvas when pattern changes externally
|
||||
useEffect(() => {
|
||||
setNodes(toCanvasNodes(graph, pattern.agents));
|
||||
setEdges(toCanvasEdges(graph, pattern.mode));
|
||||
}, [graph, pattern.agents, pattern.mode, setNodes, setEdges]);
|
||||
|
||||
const handleNodesChange: OnNodesChange<Node<GraphNodeData>> = useCallback(
|
||||
(changes) => {
|
||||
onNodesChange(changes);
|
||||
|
||||
const hasDragStart = changes.some(
|
||||
(c) => c.type === 'position' && 'dragging' in c && c.dragging,
|
||||
);
|
||||
const hasDragStop = changes.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(pattern, currentNodes);
|
||||
onGraphChange(updatedGraph);
|
||||
return currentNodes;
|
||||
});
|
||||
}
|
||||
},
|
||||
[onNodesChange, pattern, onGraphChange, setNodes],
|
||||
);
|
||||
|
||||
const handleConnect: OnConnect = useCallback(
|
||||
(connection) => {
|
||||
if (!isConnectionAllowed(connection, pattern.mode, graph)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connection.source && connection.target) {
|
||||
const updatedGraph = addHandoffEdge(graph, connection.source, connection.target);
|
||||
onGraphChange(updatedGraph);
|
||||
}
|
||||
},
|
||||
[graph, pattern.mode, onGraphChange],
|
||||
);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node<GraphNodeData>) => {
|
||||
onNodeSelect(node.id);
|
||||
},
|
||||
[onNodeSelect],
|
||||
);
|
||||
|
||||
const handlePaneClick = useCallback(() => {
|
||||
onNodeSelect(null);
|
||||
}, [onNodeSelect]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full rounded-xl border border-zinc-800 bg-zinc-950/50">
|
||||
<ReactFlow
|
||||
nodes={nodes.map((n) => ({
|
||||
...n,
|
||||
selected: n.id === selectedNodeId,
|
||||
}))}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
nodeTypes={graphNodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
defaultEdgeOptions={{
|
||||
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
||||
}}
|
||||
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#27272a" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Bot, CircleUser, Layers, Plus, Radio, Shuffle, Trash2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
findModel,
|
||||
getSupportedReasoningEfforts,
|
||||
resolveReasoningEffort,
|
||||
type ModelDefinition,
|
||||
} from '@shared/domain/models';
|
||||
import type { PatternAgentDefinition, PatternGraph, PatternGraphNodeKind } from '@shared/domain/pattern';
|
||||
import { findAgentForNode } from '@renderer/lib/patternGraph';
|
||||
import { ModelSelect, ReasoningEffortSelect } from '../AgentConfigFields';
|
||||
|
||||
interface PatternGraphInspectorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
agents: PatternAgentDefinition[];
|
||||
graph: PatternGraph;
|
||||
selectedNodeId: string | null;
|
||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||
onAgentRemove: (agentId: 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-zinc-700 bg-zinc-900 px-3 py-2 text-[13px] text-zinc-100 placeholder-zinc-600 outline-none transition focus:border-indigo-500/50';
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-zinc-400">{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>
|
||||
);
|
||||
}
|
||||
|
||||
const kindIcons: Record<PatternGraphNodeKind, typeof Bot> = {
|
||||
'user-input': CircleUser,
|
||||
'user-output': CircleUser,
|
||||
agent: Bot,
|
||||
distributor: Shuffle,
|
||||
collector: Layers,
|
||||
orchestrator: Radio,
|
||||
};
|
||||
|
||||
const kindLabels: Partial<Record<PatternGraphNodeKind, string>> = {
|
||||
'user-input': 'User Input',
|
||||
'user-output': 'User Output',
|
||||
distributor: 'Distributor',
|
||||
collector: 'Collector',
|
||||
orchestrator: 'Orchestrator',
|
||||
};
|
||||
|
||||
const kindDescriptions: Partial<Record<PatternGraphNodeKind, string>> = {
|
||||
'user-input': 'Entry point — receives user messages.',
|
||||
'user-output': 'Exit point — returns final response to the user.',
|
||||
distributor: 'Fans user input to all agents in parallel.',
|
||||
collector: 'Aggregates parallel agent responses.',
|
||||
orchestrator: 'Manages group chat round-robin turns.',
|
||||
};
|
||||
|
||||
function SystemNodeInspector({ kind }: { kind: PatternGraphNodeKind }) {
|
||||
const Icon = kindIcons[kind];
|
||||
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-indigo-500/10">
|
||||
<Icon className="size-4 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-zinc-200">{kindLabels[kind]}</div>
|
||||
<span className="rounded bg-zinc-700/50 px-1.5 py-0.5 text-[9px] font-medium text-zinc-500">
|
||||
System node
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-zinc-500">{kindDescriptions[kind]}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentNodeInspector({
|
||||
agent,
|
||||
availableModels,
|
||||
onAgentChange,
|
||||
onAgentRemove,
|
||||
}: {
|
||||
agent: PatternAgentDefinition;
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||
onAgentRemove: (agentId: string) => void;
|
||||
}) {
|
||||
const model = findModel(agent.model, availableModels);
|
||||
|
||||
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-zinc-800">
|
||||
<Bot className="size-4 text-zinc-300" />
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-zinc-200">{agent.name || 'Unnamed'}</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[12px] text-zinc-600 transition hover:text-red-400"
|
||||
onClick={() => onAgentRemove(agent.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label="Name"
|
||||
onChange={(v) => onAgentChange(agent.id, { name: v })}
|
||||
value={agent.name}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ModelSelect
|
||||
models={availableModels}
|
||||
onChange={(value) => {
|
||||
const m = findModel(value, availableModels);
|
||||
onAgentChange(agent.id, {
|
||||
model: value,
|
||||
reasoningEffort: resolveReasoningEffort(m, agent.reasoningEffort),
|
||||
});
|
||||
}}
|
||||
value={agent.model}
|
||||
/>
|
||||
<ReasoningEffortSelect
|
||||
label="Reasoning"
|
||||
onChange={(value) => onAgentChange(agent.id, { reasoningEffort: value })}
|
||||
supportedEfforts={getSupportedReasoningEfforts(model)}
|
||||
value={resolveReasoningEffort(model, agent.reasoningEffort)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
label="Description"
|
||||
onChange={(v) => onAgentChange(agent.id, { description: v })}
|
||||
placeholder="What this agent does..."
|
||||
value={agent.description}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Instructions"
|
||||
multiline
|
||||
onChange={(v) => onAgentChange(agent.id, { instructions: v })}
|
||||
placeholder="System prompt for this agent..."
|
||||
value={agent.instructions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatternGraphInspector({
|
||||
availableModels,
|
||||
agents,
|
||||
graph,
|
||||
selectedNodeId,
|
||||
onAgentChange,
|
||||
onAgentRemove,
|
||||
}: PatternGraphInspectorProps) {
|
||||
if (!selectedNodeId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<p className="text-center text-[12px] text-zinc-600">
|
||||
Select a node on the graph to inspect it
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const node = graph.nodes.find((n) => n.id === selectedNodeId);
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.kind !== 'agent') {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<SystemNodeInspector kind={node.kind} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = findAgentForNode(selectedNodeId, graph, agents);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<AgentNodeInspector
|
||||
agent={agent}
|
||||
availableModels={availableModels}
|
||||
onAgentChange={onAgentChange}
|
||||
onAgentRemove={onAgentRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { Node, Edge, Connection } from '@xyflow/react';
|
||||
|
||||
import type {
|
||||
OrchestrationMode,
|
||||
PatternAgentDefinition,
|
||||
PatternDefinition,
|
||||
PatternGraph,
|
||||
PatternGraphEdge,
|
||||
PatternGraphNode,
|
||||
PatternGraphNodeKind,
|
||||
} from '@shared/domain/pattern';
|
||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||
|
||||
/* ── Canvas node data ──────────────────────────────────────── */
|
||||
|
||||
export interface GraphNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
kind: PatternGraphNodeKind;
|
||||
agentId?: string;
|
||||
order?: number;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/* ── View-model projection ─────────────────────────────────── */
|
||||
|
||||
const SYSTEM_NODE_LABELS: Partial<Record<PatternGraphNodeKind, string>> = {
|
||||
'user-input': 'User Input',
|
||||
'user-output': 'User Output',
|
||||
distributor: 'Distributor',
|
||||
collector: 'Collector',
|
||||
orchestrator: 'Orchestrator',
|
||||
};
|
||||
|
||||
function isSystemNode(kind: PatternGraphNodeKind): boolean {
|
||||
return kind !== 'agent';
|
||||
}
|
||||
|
||||
function resolveNodeLabel(node: PatternGraphNode, agents: PatternAgentDefinition[]): string {
|
||||
if (node.kind === 'agent' && node.agentId) {
|
||||
const agent = agents.find((a) => a.id === node.agentId);
|
||||
return agent?.name || node.agentId;
|
||||
}
|
||||
|
||||
return SYSTEM_NODE_LABELS[node.kind] ?? node.kind;
|
||||
}
|
||||
|
||||
function resolveNodeType(kind: PatternGraphNodeKind): string {
|
||||
if (kind === 'agent') {
|
||||
return 'agentNode';
|
||||
}
|
||||
|
||||
return 'systemNode';
|
||||
}
|
||||
|
||||
export function toCanvasNodes(graph: PatternGraph, agents: PatternAgentDefinition[]): Node<GraphNodeData>[] {
|
||||
return graph.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: resolveNodeType(node.kind),
|
||||
position: { x: node.position.x, y: node.position.y },
|
||||
data: {
|
||||
label: resolveNodeLabel(node, agents),
|
||||
kind: node.kind,
|
||||
agentId: node.agentId,
|
||||
order: node.order,
|
||||
readOnly: isSystemNode(node.kind),
|
||||
},
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edge[] {
|
||||
return graph.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'default',
|
||||
animated: mode === 'handoff',
|
||||
deletable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function fromCanvasPositions(
|
||||
pattern: PatternDefinition,
|
||||
canvasNodes: Node<GraphNodeData>[],
|
||||
): PatternGraph {
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const positionMap = new Map(canvasNodes.map((n) => [n.id, n.position]));
|
||||
|
||||
return {
|
||||
nodes: 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: graph.edges,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Connection rules ──────────────────────────────────────── */
|
||||
|
||||
export function isConnectionAllowed(
|
||||
connection: Connection,
|
||||
mode: OrchestrationMode,
|
||||
graph: PatternGraph,
|
||||
): 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;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'single':
|
||||
case 'sequential':
|
||||
case 'magentic':
|
||||
return false;
|
||||
case 'concurrent':
|
||||
return false;
|
||||
case 'handoff':
|
||||
return sourceNode.kind === 'agent' && targetNode.kind === 'agent';
|
||||
case 'group-chat':
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Graph mutation helpers ─────────────────────────────────── */
|
||||
|
||||
function edgeId(source: string, target: string): string {
|
||||
return `edge-${source}-to-${target}`;
|
||||
}
|
||||
|
||||
export function addHandoffEdge(graph: PatternGraph, source: string, target: string): PatternGraph {
|
||||
const newEdge: PatternGraphEdge = {
|
||||
id: edgeId(source, target),
|
||||
source,
|
||||
target,
|
||||
};
|
||||
|
||||
if (graph.edges.some((e) => e.source === source && e.target === target)) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
return { ...graph, edges: [...graph.edges, newEdge] };
|
||||
}
|
||||
|
||||
export function removeEdge(graph: PatternGraph, removeEdgeId: string): PatternGraph {
|
||||
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
|
||||
}
|
||||
|
||||
/* ── Agent node for selected inspector ─────────────────────── */
|
||||
|
||||
export function findAgentForNode(
|
||||
nodeId: string,
|
||||
graph: PatternGraph,
|
||||
agents: PatternAgentDefinition[],
|
||||
): PatternAgentDefinition | undefined {
|
||||
const node = graph.nodes.find((n) => n.id === nodeId);
|
||||
if (!node?.agentId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return agents.find((a) => a.id === node.agentId);
|
||||
}
|
||||
@@ -140,6 +140,53 @@ textarea {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* React Flow dark theme overrides */
|
||||
.react-flow__node {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.react-flow__edge-path {
|
||||
stroke: #52525b;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.react-flow__edge.animated .react-flow__edge-path {
|
||||
stroke-dasharray: 5;
|
||||
animation: reactflow-dash 0.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes reactflow-dash {
|
||||
to {
|
||||
stroke-dashoffset: -10;
|
||||
}
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
border: 1px solid #27272a;
|
||||
background: #18181b;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.react-flow__controls button {
|
||||
background: #18181b;
|
||||
border-color: #27272a;
|
||||
color: #a1a1aa;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.react-flow__controls button:hover {
|
||||
background: #27272a;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
.react-flow__minimap {
|
||||
background: #09090b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Sidebar running-session pulse animation */
|
||||
@keyframes sidebar-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
||||
Reference in New Issue
Block a user