mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 21:33:58 +02:00
feat: add code-executor, function-executor, request-port inspectors and enhanced workflow settings
- Create CodeExecutorInspector with implementation directive editor, input/output type fields, and directive help callout - Create FunctionExecutorInspector with built-in function selector and key-value parameter editor - Create RequestPortInspector with port ID, request/response type fields, prompt editor, and info callout - Wire all three inspectors into WorkflowGraphInspector routing, keeping PlaceholderNodeInspector as fallback for unknown kinds - Enhance WorkflowSettingsPanel with OpenTelemetry and sensitive data filtering toggles, and a state scopes editor with initial values support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Fragment, useCallback, useMemo, useState } from 'react';
|
import { Fragment, useCallback, useMemo, useState } from 'react';
|
||||||
import { AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Info, Trash2 } from 'lucide-react';
|
import { AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Info, Plus, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
import type { ModelDefinition } from '@shared/domain/models';
|
import type { ModelDefinition } from '@shared/domain/models';
|
||||||
import type {
|
import type {
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
WorkflowEdge,
|
WorkflowEdge,
|
||||||
AgentNodeConfig,
|
AgentNodeConfig,
|
||||||
SubWorkflowConfig,
|
SubWorkflowConfig,
|
||||||
|
WorkflowStateScope,
|
||||||
} from '@shared/domain/workflow';
|
} from '@shared/domain/workflow';
|
||||||
import { validateWorkflowDefinition } from '@shared/domain/workflow';
|
import { validateWorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import { createId } from '@shared/utils/ids';
|
import { createId } from '@shared/utils/ids';
|
||||||
@@ -514,6 +515,71 @@ export function WorkflowEditor({
|
|||||||
|
|
||||||
/* ── Settings panel below canvas ───────────────────────────── */
|
/* ── Settings panel below canvas ───────────────────────────── */
|
||||||
|
|
||||||
|
function StateScopeInitialValues({
|
||||||
|
initialValues,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
initialValues: Record<string, unknown>;
|
||||||
|
onChange: (values: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const entries = Object.entries(initialValues);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]">Initial Values</span>
|
||||||
|
<button
|
||||||
|
className="flex size-5 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
|
||||||
|
onClick={() => {
|
||||||
|
const key = `key${entries.length + 1}`;
|
||||||
|
onChange({ ...initialValues, [key]: '' });
|
||||||
|
}}
|
||||||
|
title="Add initial value"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{entries.map(([key, value]) => (
|
||||||
|
<div className="flex items-center gap-1.5" key={key}>
|
||||||
|
<input
|
||||||
|
className="w-1/3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...initialValues };
|
||||||
|
const val = next[key];
|
||||||
|
delete next[key];
|
||||||
|
next[e.target.value] = val;
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
placeholder="key"
|
||||||
|
value={key}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1 text-[11px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange({ ...initialValues, [key]: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="value"
|
||||||
|
value={typeof value === 'string' ? value : JSON.stringify(value) ?? ''}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="flex size-5 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||||
|
onClick={() => {
|
||||||
|
const next = { ...initialValues };
|
||||||
|
delete next[key];
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
title="Remove value"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WorkflowSettingsPanel({
|
function WorkflowSettingsPanel({
|
||||||
workflow,
|
workflow,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -599,6 +665,126 @@ function WorkflowSettingsPanel({
|
|||||||
<ToggleSwitch enabled={workflow.settings.checkpointing.enabled} />
|
<ToggleSwitch enabled={workflow.settings.checkpointing.enabled} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Telemetry */}
|
||||||
|
<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)]">OpenTelemetry</div>
|
||||||
|
<p className="text-[12px] text-[var(--color-text-muted)]">Export telemetry data via OpenTelemetry</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...workflow,
|
||||||
|
settings: {
|
||||||
|
...workflow.settings,
|
||||||
|
telemetry: {
|
||||||
|
...workflow.settings.telemetry,
|
||||||
|
openTelemetry: !workflow.settings.telemetry?.openTelemetry,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ToggleSwitch enabled={workflow.settings.telemetry?.openTelemetry === true} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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)]">Filter Sensitive Data</div>
|
||||||
|
<p className="text-[12px] text-[var(--color-text-muted)]">Redact sensitive information from telemetry</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...workflow,
|
||||||
|
settings: {
|
||||||
|
...workflow.settings,
|
||||||
|
telemetry: {
|
||||||
|
...workflow.settings.telemetry,
|
||||||
|
sensitiveData: !workflow.settings.telemetry?.sensitiveData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ToggleSwitch enabled={workflow.settings.telemetry?.sensitiveData === true} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* State Scopes */}
|
||||||
|
<div className="col-span-2 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[12px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
State Scopes
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="flex size-6 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
|
||||||
|
onClick={() => {
|
||||||
|
const scopes = [...(workflow.settings.stateScopes ?? []), { name: '', description: '', initialValues: {} }];
|
||||||
|
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
|
||||||
|
}}
|
||||||
|
title="Add state scope"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(workflow.settings.stateScopes ?? []).map((scope, idx) => (
|
||||||
|
<div
|
||||||
|
className="space-y-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-3"
|
||||||
|
key={idx}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||||
|
onChange={(e) => {
|
||||||
|
const scopes = [...(workflow.settings.stateScopes ?? [])];
|
||||||
|
scopes[idx] = { ...scopes[idx], name: e.target.value };
|
||||||
|
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
|
||||||
|
}}
|
||||||
|
placeholder="Scope name"
|
||||||
|
value={scope.name}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||||
|
onClick={() => {
|
||||||
|
const scopes = (workflow.settings.stateScopes ?? []).filter((_, i) => i !== idx);
|
||||||
|
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
|
||||||
|
}}
|
||||||
|
title="Remove scope"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||||
|
onChange={(e) => {
|
||||||
|
const scopes = [...(workflow.settings.stateScopes ?? [])];
|
||||||
|
scopes[idx] = { ...scopes[idx], description: e.target.value };
|
||||||
|
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
|
||||||
|
}}
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
value={scope.description ?? ''}
|
||||||
|
/>
|
||||||
|
<StateScopeInitialValues
|
||||||
|
initialValues={scope.initialValues ?? {}}
|
||||||
|
onChange={(initialValues) => {
|
||||||
|
const scopes = [...(workflow.settings.stateScopes ?? [])];
|
||||||
|
scopes[idx] = { ...scopes[idx], initialValues };
|
||||||
|
onChange({ ...workflow, settings: { ...workflow.settings, stateScopes: scopes } });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { AlertCircle, Code, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CodeExecutorConfig,
|
||||||
|
WorkflowNode,
|
||||||
|
WorkflowNodeConfig,
|
||||||
|
WorkflowValidationIssue,
|
||||||
|
} from '@shared/domain/workflow';
|
||||||
|
|
||||||
|
interface CodeExecutorInspectorProps {
|
||||||
|
node: WorkflowNode;
|
||||||
|
validationIssues?: WorkflowValidationIssue[];
|
||||||
|
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||||
|
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||||
|
onNodeRemove: (nodeId: 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeExecutorInspector({
|
||||||
|
node,
|
||||||
|
validationIssues,
|
||||||
|
onNodeChange,
|
||||||
|
onNodeConfigChange,
|
||||||
|
onNodeRemove,
|
||||||
|
}: CodeExecutorInspectorProps) {
|
||||||
|
const config = node.config as CodeExecutorConfig;
|
||||||
|
const nodeIssues = validationIssues?.filter((i) => i.nodeId === node.id) ?? [];
|
||||||
|
|
||||||
|
const patchConfig = useCallback(
|
||||||
|
(patch: Partial<CodeExecutorConfig>) => {
|
||||||
|
onNodeConfigChange(node.id, { ...config, ...patch });
|
||||||
|
},
|
||||||
|
[node.id, config, onNodeConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<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-sky-500/10">
|
||||||
|
<Code className="size-4 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{node.label || 'Code Executor'}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<InputField
|
||||||
|
label="Label"
|
||||||
|
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||||
|
placeholder="Display label"
|
||||||
|
value={node.label}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Implementation */}
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||||
|
Implementation
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 font-mono text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50 min-h-20 resize-y"
|
||||||
|
onChange={(e) => patchConfig({ implementation: e.target.value })}
|
||||||
|
placeholder="e.g. return-input, return-text:hello, state:set:scope:key:{"value":1}"
|
||||||
|
value={config.implementation ?? ''}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Directives help */}
|
||||||
|
<div className="rounded-lg border border-sky-500/20 bg-sky-500/5 px-3 py-2 text-[12px] text-sky-400">
|
||||||
|
<div className="mb-1.5 font-medium">Supported directives</div>
|
||||||
|
<ul className="space-y-0.5 text-[11px]">
|
||||||
|
<li>
|
||||||
|
<code className="font-mono">return-input</code> — Forward incoming payload
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="font-mono">return-text:<text></code> — Emit literal text
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="font-mono">return-json:<json></code> — Emit parsed JSON
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="font-mono">state:set:<scope>:<key>:<json></code> — Set state value
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="font-mono">state:get:<scope>:<key></code> — Read state value
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Type */}
|
||||||
|
<InputField
|
||||||
|
label="Input Type"
|
||||||
|
onChange={(v) => patchConfig({ inputType: v || undefined })}
|
||||||
|
placeholder="Optional type annotation"
|
||||||
|
value={config.inputType ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Output Type */}
|
||||||
|
<InputField
|
||||||
|
label="Output Type"
|
||||||
|
onChange={(v) => patchConfig({ outputType: v || undefined })}
|
||||||
|
placeholder="Optional type annotation"
|
||||||
|
value={config.outputType ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Validation issues */}
|
||||||
|
{nodeIssues.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{nodeIssues.map((issue, i) => (
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] ${
|
||||||
|
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 shrink-0" />
|
||||||
|
<span>{issue.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { AlertCircle, FunctionSquare, Plus, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FunctionExecutorConfig,
|
||||||
|
WorkflowNode,
|
||||||
|
WorkflowNodeConfig,
|
||||||
|
WorkflowValidationIssue,
|
||||||
|
} from '@shared/domain/workflow';
|
||||||
|
|
||||||
|
interface FunctionExecutorInspectorProps {
|
||||||
|
node: WorkflowNode;
|
||||||
|
validationIssues?: WorkflowValidationIssue[];
|
||||||
|
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||||
|
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||||
|
onNodeRemove: (nodeId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
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>
|
||||||
|
<input
|
||||||
|
className={base}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtInFunctions = [
|
||||||
|
{ value: 'identity', label: 'identity — Pass through input' },
|
||||||
|
{ value: 'return-parameter', label: 'return-parameter — Return a parameter value' },
|
||||||
|
{ value: 'concat-text', label: 'concat-text — Concatenate text values' },
|
||||||
|
{ value: 'state:get', label: 'state:get — Read state value' },
|
||||||
|
{ value: 'state:set', label: 'state:set — Set state value' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function stringifyValue(v: unknown): string {
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return JSON.stringify(v) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FunctionExecutorInspector({
|
||||||
|
node,
|
||||||
|
validationIssues,
|
||||||
|
onNodeChange,
|
||||||
|
onNodeConfigChange,
|
||||||
|
onNodeRemove,
|
||||||
|
}: FunctionExecutorInspectorProps) {
|
||||||
|
const config = node.config as FunctionExecutorConfig;
|
||||||
|
const nodeIssues = validationIssues?.filter((i) => i.nodeId === node.id) ?? [];
|
||||||
|
const parameters = config.parameters ?? {};
|
||||||
|
const paramEntries = Object.entries(parameters);
|
||||||
|
|
||||||
|
const [newKey, setNewKey] = useState('');
|
||||||
|
|
||||||
|
const patchConfig = useCallback(
|
||||||
|
(patch: Partial<FunctionExecutorConfig>) => {
|
||||||
|
onNodeConfigChange(node.id, { ...config, ...patch });
|
||||||
|
},
|
||||||
|
[node.id, config, onNodeConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleParamChange = useCallback(
|
||||||
|
(oldKey: string, newParamKey: string, value: string) => {
|
||||||
|
const next = { ...parameters };
|
||||||
|
if (newParamKey !== oldKey) {
|
||||||
|
delete next[oldKey];
|
||||||
|
}
|
||||||
|
next[newParamKey] = value;
|
||||||
|
patchConfig({ parameters: next });
|
||||||
|
},
|
||||||
|
[parameters, patchConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleParamRemove = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
const next = { ...parameters };
|
||||||
|
delete next[key];
|
||||||
|
patchConfig({ parameters: next });
|
||||||
|
},
|
||||||
|
[parameters, patchConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleParamAdd = useCallback(() => {
|
||||||
|
const key = newKey.trim() || `param${paramEntries.length + 1}`;
|
||||||
|
patchConfig({ parameters: { ...parameters, [key]: '' } });
|
||||||
|
setNewKey('');
|
||||||
|
}, [newKey, paramEntries.length, parameters, patchConfig]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<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-violet-500/10">
|
||||||
|
<FunctionSquare className="size-4 text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{node.label || 'Function Executor'}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<InputField
|
||||||
|
label="Label"
|
||||||
|
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||||
|
placeholder="Display label"
|
||||||
|
value={node.label}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Function reference */}
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Function</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) => patchConfig({ functionRef: e.target.value })}
|
||||||
|
value={config.functionRef}
|
||||||
|
>
|
||||||
|
<option value="">Select a function…</option>
|
||||||
|
{builtInFunctions.map((fn) => (
|
||||||
|
<option key={fn.value} value={fn.value}>
|
||||||
|
{fn.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Parameters editor */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||||
|
Parameters
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="flex size-6 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 hover:text-[var(--color-accent)]"
|
||||||
|
onClick={handleParamAdd}
|
||||||
|
title="Add parameter"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{paramEntries.length === 0 && (
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">No parameters defined.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{paramEntries.map(([key, value]) => (
|
||||||
|
<div className="flex items-center gap-1.5" key={key}>
|
||||||
|
<input
|
||||||
|
className="w-1/3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||||
|
onChange={(e) => handleParamChange(key, e.target.value, stringifyValue(value))}
|
||||||
|
placeholder="key"
|
||||||
|
value={key}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-2 py-1.5 text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||||
|
onChange={(e) => handleParamChange(key, key, e.target.value)}
|
||||||
|
placeholder="value"
|
||||||
|
value={stringifyValue(value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="flex size-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||||
|
onClick={() => handleParamRemove(key)}
|
||||||
|
title="Remove parameter"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation issues */}
|
||||||
|
{nodeIssues.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{nodeIssues.map((issue, i) => (
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] ${
|
||||||
|
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 shrink-0" />
|
||||||
|
<span>{issue.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { AlertCircle, Radio, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
RequestPortConfig,
|
||||||
|
WorkflowNode,
|
||||||
|
WorkflowNodeConfig,
|
||||||
|
WorkflowValidationIssue,
|
||||||
|
} from '@shared/domain/workflow';
|
||||||
|
|
||||||
|
interface RequestPortInspectorProps {
|
||||||
|
node: WorkflowNode;
|
||||||
|
validationIssues?: WorkflowValidationIssue[];
|
||||||
|
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||||
|
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||||
|
onNodeRemove: (nodeId: 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequestPortInspector({
|
||||||
|
node,
|
||||||
|
validationIssues,
|
||||||
|
onNodeChange,
|
||||||
|
onNodeConfigChange,
|
||||||
|
onNodeRemove,
|
||||||
|
}: RequestPortInspectorProps) {
|
||||||
|
const config = node.config as RequestPortConfig;
|
||||||
|
const nodeIssues = validationIssues?.filter((i) => i.nodeId === node.id) ?? [];
|
||||||
|
|
||||||
|
const patchConfig = useCallback(
|
||||||
|
(patch: Partial<RequestPortConfig>) => {
|
||||||
|
onNodeConfigChange(node.id, { ...config, ...patch });
|
||||||
|
},
|
||||||
|
[node.id, config, onNodeConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<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-teal-500/10">
|
||||||
|
<Radio className="size-4 text-teal-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{node.label || 'Request Port'}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<InputField
|
||||||
|
label="Label"
|
||||||
|
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||||
|
placeholder="Display label"
|
||||||
|
value={node.label}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Port ID */}
|
||||||
|
<InputField
|
||||||
|
label="Port ID"
|
||||||
|
onChange={(v) => patchConfig({ portId: v })}
|
||||||
|
placeholder="Unique port identifier"
|
||||||
|
value={config.portId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Request Type */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<InputField
|
||||||
|
label="Request Type"
|
||||||
|
onChange={(v) => patchConfig({ requestType: v })}
|
||||||
|
placeholder="e.g. string, boolean, number, json"
|
||||||
|
value={config.requestType}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Supported types: string, boolean, number, json
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Response Type */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<InputField
|
||||||
|
label="Response Type"
|
||||||
|
onChange={(v) => patchConfig({ responseType: v })}
|
||||||
|
placeholder="e.g. string, boolean, number, json"
|
||||||
|
value={config.responseType}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Supported types: string, boolean, number, json
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt */}
|
||||||
|
<InputField
|
||||||
|
label="Prompt"
|
||||||
|
multiline
|
||||||
|
onChange={(v) => patchConfig({ prompt: v || undefined })}
|
||||||
|
placeholder="Optional question shown to the user"
|
||||||
|
value={config.prompt ?? ''}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info callout */}
|
||||||
|
<div className="rounded-lg border border-teal-500/20 bg-teal-500/5 px-3 py-2 text-[12px] text-teal-400">
|
||||||
|
This node pauses workflow execution and requests input from the user. The response is
|
||||||
|
coerced to the specified response type before continuing.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation issues */}
|
||||||
|
{nodeIssues.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{nodeIssues.map((issue, i) => (
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] ${
|
||||||
|
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 shrink-0" />
|
||||||
|
<span>{issue.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,10 @@ import type {
|
|||||||
AgentNodeConfig,
|
AgentNodeConfig,
|
||||||
} from '@shared/domain/workflow';
|
} from '@shared/domain/workflow';
|
||||||
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
|
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
|
||||||
|
import { CodeExecutorInspector } from '@renderer/components/workflow/CodeExecutorInspector';
|
||||||
import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor';
|
import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor';
|
||||||
|
import { FunctionExecutorInspector } from '@renderer/components/workflow/FunctionExecutorInspector';
|
||||||
|
import { RequestPortInspector } from '@renderer/components/workflow/RequestPortInspector';
|
||||||
import { SubWorkflowInspector } from '@renderer/components/workflow/SubWorkflowInspector';
|
import { SubWorkflowInspector } from '@renderer/components/workflow/SubWorkflowInspector';
|
||||||
|
|
||||||
interface WorkflowGraphInspectorProps {
|
interface WorkflowGraphInspectorProps {
|
||||||
@@ -457,6 +460,49 @@ export function WorkflowGraphInspector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedNode.kind === 'code-executor') {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<CodeExecutorInspector
|
||||||
|
node={selectedNode}
|
||||||
|
validationIssues={validationIssues}
|
||||||
|
onNodeChange={onNodeChange}
|
||||||
|
onNodeConfigChange={onNodeConfigChange}
|
||||||
|
onNodeRemove={onNodeRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.kind === 'function-executor') {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<FunctionExecutorInspector
|
||||||
|
node={selectedNode}
|
||||||
|
validationIssues={validationIssues}
|
||||||
|
onNodeChange={onNodeChange}
|
||||||
|
onNodeConfigChange={onNodeConfigChange}
|
||||||
|
onNodeRemove={onNodeRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.kind === 'request-port') {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<RequestPortInspector
|
||||||
|
node={selectedNode}
|
||||||
|
validationIssues={validationIssues}
|
||||||
|
onNodeChange={onNodeChange}
|
||||||
|
onNodeConfigChange={onNodeConfigChange}
|
||||||
|
onNodeRemove={onNodeRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for any future unknown node kinds
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<PlaceholderNodeInspector
|
<PlaceholderNodeInspector
|
||||||
|
|||||||
Reference in New Issue
Block a user