mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
feat: add frontend orchestration mode UI to workflow designer
- Add OrchestrationModePanel with mode cards for all 5 modes (single, sequential, concurrent, handoff, group-chat) - Add handoff settings panel: triage agent selector, tool-call filtering, return-to-previous toggle, custom instructions - Add group-chat settings panel: selection strategy display, max rounds input - Add auto-inference indicator when mode is not explicitly set - Add scaffold dialog when changing mode with existing agents - Add mode badge (top-left pill) on workflow graph canvas - Wire orchestration mode panel into WorkflowSettingsPanel above general settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { WorkflowGraphCanvas } from './workflow/WorkflowGraphCanvas';
|
||||
import { ExportDropdown, ExportModal, ImportModal } from './workflow/WorkflowExportImportPanel';
|
||||
import { WorkflowGraphInspector } from './workflow/WorkflowGraphInspector';
|
||||
import { WorkflowNodePalette } from './workflow/WorkflowNodePalette';
|
||||
import { OrchestrationModePanel } from './workflow/OrchestrationModePanel';
|
||||
|
||||
interface WorkflowEditorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
@@ -655,6 +656,12 @@ function WorkflowSettingsPanel({
|
||||
<h4 className="mb-3 text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Settings
|
||||
</h4>
|
||||
|
||||
{/* Orchestration mode — prominent, above general settings */}
|
||||
<div className="mb-5">
|
||||
<OrchestrationModePanel workflow={workflow} onChange={onChange} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Name"
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Layers,
|
||||
MessageCircle,
|
||||
Repeat,
|
||||
Route,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type {
|
||||
HandoffModeSettings,
|
||||
GroupChatModeSettings,
|
||||
WorkflowDefinition,
|
||||
WorkflowNode,
|
||||
WorkflowOrchestrationMode,
|
||||
} from '@shared/domain/workflow';
|
||||
import {
|
||||
createDefaultModeSettings,
|
||||
inferWorkflowOrchestrationMode,
|
||||
isBuilderBasedMode,
|
||||
isGraphBasedMode,
|
||||
scaffoldGraphForMode,
|
||||
} from '@shared/domain/workflow';
|
||||
import { FormField, InfoCallout, SelectInput, ToggleSwitch } from '@renderer/components/ui';
|
||||
|
||||
/* ── Mode metadata ─────────────────────────────────────────── */
|
||||
|
||||
interface ModeOption {
|
||||
value: WorkflowOrchestrationMode;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Bot;
|
||||
accentClass: string;
|
||||
}
|
||||
|
||||
const modeOptions: ModeOption[] = [
|
||||
{
|
||||
value: 'single',
|
||||
label: 'Single Agent',
|
||||
description: 'One agent handles the full conversation directly.',
|
||||
icon: User,
|
||||
accentClass: 'text-emerald-400',
|
||||
},
|
||||
{
|
||||
value: 'sequential',
|
||||
label: 'Sequential',
|
||||
description: 'Agents execute in order, each seeing the full conversation history.',
|
||||
icon: ArrowRightLeft,
|
||||
accentClass: 'text-sky-400',
|
||||
},
|
||||
{
|
||||
value: 'concurrent',
|
||||
label: 'Concurrent',
|
||||
description: 'Agents execute in parallel via fan-out, results collected at barrier.',
|
||||
icon: Layers,
|
||||
accentClass: 'text-amber-400',
|
||||
},
|
||||
{
|
||||
value: 'handoff',
|
||||
label: 'Handoff',
|
||||
description: 'Triage agent routes requests to specialists via handoff tool calls.',
|
||||
icon: Route,
|
||||
accentClass: 'text-violet-400',
|
||||
},
|
||||
{
|
||||
value: 'group-chat',
|
||||
label: 'Group Chat',
|
||||
description: 'Agents take turns in a managed conversation loop.',
|
||||
icon: MessageCircle,
|
||||
accentClass: 'text-rose-400',
|
||||
},
|
||||
];
|
||||
|
||||
const modeMap = new Map(modeOptions.map((m) => [m.value, m]));
|
||||
|
||||
/* ── Scaffold confirmation dialog ──────────────────────────── */
|
||||
|
||||
function ScaffoldDialog({
|
||||
fromMode,
|
||||
toMode,
|
||||
onAccept,
|
||||
onDecline,
|
||||
}: {
|
||||
fromMode: string;
|
||||
toMode: WorkflowOrchestrationMode;
|
||||
onAccept: () => void;
|
||||
onDecline: () => void;
|
||||
}) {
|
||||
const target = modeMap.get(toMode);
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="scaffold-dialog-title"
|
||||
>
|
||||
<div
|
||||
className="mx-4 w-full max-w-md animate-[palette-enter_0.2s_ease-out] rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-2)] p-6 shadow-2xl"
|
||||
>
|
||||
<h3
|
||||
id="scaffold-dialog-title"
|
||||
className="mb-2 text-[14px] font-semibold text-[var(--color-text-primary)]"
|
||||
>
|
||||
Restructure graph for {target?.label ?? toMode}?
|
||||
</h3>
|
||||
<p className="mb-5 text-[13px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
This will update edges to match the <span className="font-medium text-[var(--color-text-primary)]">{target?.label ?? toMode}</span> topology while keeping your existing agents.
|
||||
{fromMode && (
|
||||
<span className="mt-1 block text-[12px] text-[var(--color-text-muted)]">
|
||||
Changing from {fromMode}.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDecline}
|
||||
className="rounded-lg px-4 py-2 text-[13px] text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
Keep current graph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
className="rounded-lg bg-[var(--color-accent)] px-4 py-2 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-accent-hover)]"
|
||||
>
|
||||
Restructure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Mode selector card ────────────────────────────────────── */
|
||||
|
||||
function ModeCard({
|
||||
option,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
option: ModeOption;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`group flex items-start gap-3 rounded-xl border px-3.5 py-3 text-left transition-all duration-200 ${
|
||||
selected
|
||||
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-muted)] shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_16px_rgba(36,92,249,0.06)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-1)] hover:border-[var(--color-border-glow)] hover:bg-[var(--color-surface-2)]'
|
||||
}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<div className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg ${
|
||||
selected ? 'bg-[var(--color-accent)]/15' : 'bg-[var(--color-surface-3)]'
|
||||
}`}>
|
||||
<Icon className={`size-3.5 ${selected ? 'text-[var(--color-accent)]' : option.accentClass}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-[13px] font-medium ${
|
||||
selected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)]'
|
||||
}`}>
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] leading-snug text-[var(--color-text-muted)]">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Handoff settings sub-panel ────────────────────────────── */
|
||||
|
||||
function HandoffSettingsPanel({
|
||||
settings,
|
||||
agentNodes,
|
||||
onChange,
|
||||
}: {
|
||||
settings: HandoffModeSettings;
|
||||
agentNodes: WorkflowNode[];
|
||||
onChange: (settings: HandoffModeSettings) => void;
|
||||
}) {
|
||||
const triageOptions = useMemo(() => [
|
||||
{ value: '', label: 'First agent (default)' },
|
||||
...agentNodes.map((node) => ({
|
||||
value: node.id,
|
||||
label: node.label || (node.config.kind === 'agent' ? node.config.name : node.id),
|
||||
})),
|
||||
], [agentNodes]);
|
||||
|
||||
const filteringOptions = [
|
||||
{ value: 'none', label: 'None — All tools available' },
|
||||
{ value: 'handoff-only', label: 'Handoff-only — Restrict to handoff tools' },
|
||||
{ value: 'all', label: 'All — Full tool filtering' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Route className="size-3.5 text-violet-400" />
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Handoff Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FormField label="Triage Agent">
|
||||
<SelectInput
|
||||
value={settings.triageAgentNodeId ?? ''}
|
||||
options={triageOptions}
|
||||
onChange={(v) => onChange({ ...settings, triageAgentNodeId: v || undefined })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Tool-Call Filtering">
|
||||
<SelectInput
|
||||
value={settings.toolCallFiltering}
|
||||
options={filteringOptions}
|
||||
onChange={(v) => onChange({ ...settings, toolCallFiltering: v as HandoffModeSettings['toolCallFiltering'] })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3.5 py-2.5">
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">Return to Previous</div>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">Allow specialists to hand back to the previous agent</p>
|
||||
</div>
|
||||
<button type="button" className="cursor-pointer" onClick={() => onChange({ ...settings, returnToPrevious: !settings.returnToPrevious })}>
|
||||
<ToggleSwitch enabled={settings.returnToPrevious} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FormField label="Custom Handoff Instructions">
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-glow)] focus:shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_12px_rgba(36,92,249,0.08)]"
|
||||
rows={3}
|
||||
placeholder="Override default handoff guidance (optional)"
|
||||
value={settings.handoffInstructions ?? ''}
|
||||
onChange={(e) => onChange({ ...settings, handoffInstructions: e.target.value || undefined })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Group-chat settings sub-panel ─────────────────────────── */
|
||||
|
||||
function GroupChatSettingsPanel({
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
settings: GroupChatModeSettings;
|
||||
onChange: (settings: GroupChatModeSettings) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="size-3.5 text-rose-400" />
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Group Chat Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FormField label="Selection Strategy">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3.5 py-2.5">
|
||||
<Repeat className="size-3.5 text-[var(--color-text-muted)]" />
|
||||
<span className="text-[13px] text-[var(--color-text-primary)]">Round Robin</span>
|
||||
<span className="ml-auto text-[11px] text-[var(--color-text-muted)]">Only strategy</span>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Max Rounds">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition-all duration-200 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-glow)] focus:shadow-[0_0_0_1px_rgba(36,92,249,0.15),0_0_12px_rgba(36,92,249,0.08)]"
|
||||
placeholder="5"
|
||||
value={settings.maxRounds}
|
||||
onChange={(e) => {
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
onChange({ ...settings, maxRounds: Number.isNaN(raw) ? 5 : Math.max(1, Math.min(100, raw)) });
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main orchestration mode panel ─────────────────────────── */
|
||||
|
||||
export function OrchestrationModePanel({
|
||||
workflow,
|
||||
onChange,
|
||||
}: {
|
||||
workflow: WorkflowDefinition;
|
||||
onChange: (workflow: WorkflowDefinition) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [scaffoldRequest, setScaffoldRequest] = useState<{
|
||||
newMode: WorkflowOrchestrationMode;
|
||||
fromLabel: string;
|
||||
} | null>(null);
|
||||
|
||||
const currentMode = workflow.settings.orchestrationMode;
|
||||
const inferredMode = useMemo(() => inferWorkflowOrchestrationMode(workflow), [workflow]);
|
||||
const effectiveMode = currentMode ?? inferredMode;
|
||||
const effectiveMeta = modeMap.get(effectiveMode);
|
||||
|
||||
const agentNodes = useMemo(
|
||||
() => workflow.graph.nodes.filter((n) => n.kind === 'agent'),
|
||||
[workflow.graph.nodes],
|
||||
);
|
||||
|
||||
const handoffSettings: HandoffModeSettings = useMemo(
|
||||
() => workflow.settings.modeSettings?.handoff ?? createDefaultModeSettings('handoff')!.handoff!,
|
||||
[workflow.settings.modeSettings?.handoff],
|
||||
);
|
||||
|
||||
const groupChatSettings: GroupChatModeSettings = useMemo(
|
||||
() => workflow.settings.modeSettings?.groupChat ?? createDefaultModeSettings('group-chat')!.groupChat!,
|
||||
[workflow.settings.modeSettings?.groupChat],
|
||||
);
|
||||
|
||||
const applyModeChange = useCallback(
|
||||
(newMode: WorkflowOrchestrationMode, restructure: boolean) => {
|
||||
const modeSettings = createDefaultModeSettings(newMode);
|
||||
const base: WorkflowDefinition = {
|
||||
...workflow,
|
||||
settings: {
|
||||
...workflow.settings,
|
||||
orchestrationMode: newMode,
|
||||
modeSettings: modeSettings
|
||||
? { ...workflow.settings.modeSettings, ...modeSettings }
|
||||
: workflow.settings.modeSettings,
|
||||
},
|
||||
};
|
||||
|
||||
if (restructure) {
|
||||
const existingAgents = workflow.graph.nodes.filter((n) => n.kind === 'agent');
|
||||
const newGraph = scaffoldGraphForMode(newMode, existingAgents.length > 0 ? existingAgents : undefined);
|
||||
onChange({ ...base, graph: newGraph });
|
||||
} else {
|
||||
onChange(base);
|
||||
}
|
||||
},
|
||||
[workflow, onChange],
|
||||
);
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(newMode: WorkflowOrchestrationMode) => {
|
||||
if (newMode === currentMode) return;
|
||||
|
||||
const hasAgents = agentNodes.length > 0;
|
||||
const fromLabel = currentMode
|
||||
? (modeMap.get(currentMode)?.label ?? currentMode)
|
||||
: `Auto (${modeMap.get(inferredMode)?.label ?? inferredMode})`;
|
||||
|
||||
if (hasAgents) {
|
||||
setScaffoldRequest({ newMode, fromLabel });
|
||||
} else {
|
||||
applyModeChange(newMode, true);
|
||||
}
|
||||
},
|
||||
[currentMode, inferredMode, agentNodes.length, applyModeChange],
|
||||
);
|
||||
|
||||
const handleHandoffChange = useCallback(
|
||||
(handoff: HandoffModeSettings) => {
|
||||
onChange({
|
||||
...workflow,
|
||||
settings: {
|
||||
...workflow.settings,
|
||||
modeSettings: { ...workflow.settings.modeSettings, handoff },
|
||||
},
|
||||
});
|
||||
},
|
||||
[workflow, onChange],
|
||||
);
|
||||
|
||||
const handleGroupChatChange = useCallback(
|
||||
(groupChat: GroupChatModeSettings) => {
|
||||
onChange({
|
||||
...workflow,
|
||||
settings: {
|
||||
...workflow.settings,
|
||||
modeSettings: { ...workflow.settings.modeSettings, groupChat },
|
||||
},
|
||||
});
|
||||
},
|
||||
[workflow, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Section header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-center justify-between"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Orchestration Mode
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{!currentMode && (
|
||||
<span className="rounded-md bg-[var(--color-surface-3)] px-2 py-0.5 text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||
Auto
|
||||
</span>
|
||||
)}
|
||||
{effectiveMeta && (
|
||||
<span className={`text-[11px] font-medium ${effectiveMeta.accentClass}`}>
|
||||
{effectiveMeta.label}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`size-3.5 text-[var(--color-text-muted)] transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-4">
|
||||
{/* Mode cards */}
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{modeOptions.map((option) => (
|
||||
<ModeCard
|
||||
key={option.value}
|
||||
option={option}
|
||||
selected={effectiveMode === option.value}
|
||||
onClick={() => handleModeSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auto-inference indicator */}
|
||||
{!currentMode && (
|
||||
<InfoCallout>
|
||||
Mode is auto-inferred from graph shape as <span className="font-medium text-[var(--color-text-primary)]">{effectiveMeta?.label}</span>.
|
||||
Select a mode explicitly to lock it.
|
||||
</InfoCallout>
|
||||
)}
|
||||
|
||||
{/* Builder-based mode info */}
|
||||
{isBuilderBasedMode(effectiveMode) && (
|
||||
<InfoCallout>
|
||||
In {effectiveMeta?.label} mode, the framework manages agent routing automatically. The graph defines participants.
|
||||
</InfoCallout>
|
||||
)}
|
||||
|
||||
{/* Graph-based mode info */}
|
||||
{isGraphBasedMode(effectiveMode) && currentMode && (
|
||||
<InfoCallout>
|
||||
This mode uses the graph topology for execution. Connect agents with the appropriate edge types.
|
||||
</InfoCallout>
|
||||
)}
|
||||
|
||||
{/* Mode-specific settings */}
|
||||
{effectiveMode === 'handoff' && (
|
||||
<HandoffSettingsPanel
|
||||
settings={handoffSettings}
|
||||
agentNodes={agentNodes}
|
||||
onChange={handleHandoffChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveMode === 'group-chat' && (
|
||||
<GroupChatSettingsPanel
|
||||
settings={groupChatSettings}
|
||||
onChange={handleGroupChatChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scaffold confirmation dialog */}
|
||||
{scaffoldRequest && (
|
||||
<ScaffoldDialog
|
||||
fromMode={scaffoldRequest.fromLabel}
|
||||
toMode={scaffoldRequest.newMode}
|
||||
onAccept={() => {
|
||||
applyModeChange(scaffoldRequest.newMode, true);
|
||||
setScaffoldRequest(null);
|
||||
}}
|
||||
onDecline={() => {
|
||||
applyModeChange(scaffoldRequest.newMode, false);
|
||||
setScaffoldRequest(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { LayoutGrid, Maximize2, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
|
||||
import type { WorkflowDefinition, WorkflowGraph } from '@shared/domain/workflow';
|
||||
import type { WorkflowDefinition, WorkflowGraph, WorkflowOrchestrationMode } from '@shared/domain/workflow';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import { inferWorkflowOrchestrationMode } from '@shared/domain/workflow';
|
||||
import {
|
||||
addWorkflowEdge,
|
||||
autoLayoutWorkflowGraph,
|
||||
@@ -60,6 +61,19 @@ function WorkflowGraphCanvasInner({
|
||||
const draggingRef = useRef(false);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
|
||||
const modeBadge = useMemo(() => {
|
||||
const mode = inferWorkflowOrchestrationMode(workflow);
|
||||
const explicit = !!workflow.settings.orchestrationMode;
|
||||
const meta: Record<WorkflowOrchestrationMode, { label: string; icon: string; color: string }> = {
|
||||
single: { label: 'Single', icon: '●', color: 'text-emerald-400' },
|
||||
sequential: { label: 'Sequential', icon: '→', color: 'text-sky-400' },
|
||||
concurrent: { label: 'Concurrent', icon: '⇉', color: 'text-amber-400' },
|
||||
handoff: { label: 'Handoff', icon: '⇢', color: 'text-violet-400' },
|
||||
'group-chat': { label: 'Group Chat', icon: '⟳', color: 'text-rose-400' },
|
||||
};
|
||||
return { mode, explicit, ...meta[mode] };
|
||||
}, [workflow]);
|
||||
|
||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
|
||||
toCanvasNodes(graph, availableModels, workflows),
|
||||
);
|
||||
@@ -249,6 +263,21 @@ function WorkflowGraphCanvasInner({
|
||||
deleteKeyCode="Delete"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a1e2e" />
|
||||
<Panel position="top-left">
|
||||
<div 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 shadow-sm backdrop-blur">
|
||||
<span className={`text-[12px] font-semibold leading-none ${modeBadge.color}`}>
|
||||
{modeBadge.icon}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||
{modeBadge.label}
|
||||
</span>
|
||||
{!modeBadge.explicit && (
|
||||
<span className="rounded bg-[var(--color-surface-3)] px-1.5 py-px text-[10px] text-[var(--color-text-muted)]">
|
||||
auto
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
<MiniMap
|
||||
className="!rounded-lg !border !border-[var(--color-border)] !bg-[var(--color-surface-1)]"
|
||||
maskColor="rgba(0,0,0,0.6)"
|
||||
|
||||
Reference in New Issue
Block a user