feat(workflows): Phase 2a+2c — extended node types + trigger UI
All checks were successful
Stuffle/agos-client/pipeline/head This commit was not built
All checks were successful
Stuffle/agos-client/pipeline/head This commit was not built
Phase 2a (frontend node types): - Extend WfNodeType from 9 to 17 types: add switch, loop, merge, delay, http_request, code, human_approval, notification - Extend WfNodeFamily from 4 to 7: add logic, tool, gate - Full inspector for each new type: Switch (cases editor), Loop (concurrency, iteration config), Merge (wait mode), Delay (duration), HTTP Request (method, URL, headers, body, auth), Code (language, editor, packages), Approval Gate (title, timeout, notify channels), Notification (template, channels) - Node palette: 6 categories (Input, AI/Engine, Control Flow, Integration, Governance, Output) - Graph validation rules for all new types - Diamond shape for logic nodes, new colors/icons Phase 2c (trigger UI): - Trigger selector: 5 types (Manual, Webhook, Schedule, Event, Chat) - Schedule trigger: cron expression + timezone + agent ID - Event trigger: multi-line event types - Chat trigger: /run command input Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -22,10 +22,12 @@ export function WorkflowNode({ id, data }: NodeProps) {
|
||||
const Icon = NODE_ICON[d.type] ?? Zap
|
||||
const isTerminal = d.type === 'start' || d.type === 'end'
|
||||
const shape = NODE_SHAPE_BY_FAMILY[family]
|
||||
const borderRadius = shape === 'pill' ? 999 : (shape === 'hex' ? 22 : 14)
|
||||
const borderRadius = shape === 'pill' ? 999 : (shape === 'hex' || shape === 'diamond' ? 22 : 14)
|
||||
const clipPath = shape === 'hex'
|
||||
? 'polygon(8% 0%, 92% 0%, 100% 50%, 92% 100%, 8% 100%, 0% 50%)'
|
||||
: undefined
|
||||
: shape === 'diamond'
|
||||
? 'polygon(50% 0%, 96% 50%, 50% 100%, 4% 50%)'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -148,8 +148,55 @@ export function validateGraph(definition: WfDefinition): ValidationResult {
|
||||
}
|
||||
|
||||
if (node.type === 'condition') {
|
||||
if (!node.description?.trim()) {
|
||||
errors.push({ nodeId: node.id, severity: 'error', message: 'Condition node requires a condition rule' })
|
||||
const mode = node.config?.condition_mode ?? 'expression'
|
||||
if (mode === 'expression' && !node.config?.expression?.trim()) {
|
||||
errors.push({ nodeId: node.id, severity: 'error', message: 'Condition node requires an expression' })
|
||||
}
|
||||
if (mode === 'llm_judge' && !node.config?.judge_prompt?.trim()) {
|
||||
errors.push({ nodeId: node.id, severity: 'error', message: 'Condition node (LLM judge) requires a judge prompt' })
|
||||
}
|
||||
const outgoing = outgoingCount.get(node.id) ?? 0
|
||||
if (outgoing > 0 && outgoing !== 2) {
|
||||
errors.push({ nodeId: node.id, severity: 'warning', message: 'Condition node should have exactly 2 outgoing edges (true/false)' })
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'switch') {
|
||||
if (!node.config?.expression?.trim()) {
|
||||
errors.push({ nodeId: node.id, severity: 'error', message: 'Switch node requires an expression' })
|
||||
}
|
||||
if (!node.config?.cases?.length) {
|
||||
errors.push({ nodeId: node.id, severity: 'error', message: 'Switch node requires at least one case' })
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'loop') {
|
||||
if (!node.config?.source_expression?.trim()) {
|
||||
errors.push({ nodeId: node.id, severity: 'error', message: 'Loop node requires a source expression (array)' })
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'merge') {
|
||||
if ((incomingCount.get(node.id) ?? 0) < 2) {
|
||||
errors.push({ nodeId: node.id, severity: 'warning', message: 'Merge node should have 2+ incoming edges' })
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'http_request') {
|
||||
if (!node.config?.http_url?.trim()) {
|
||||
errors.push({ nodeId: node.id, severity: 'error', message: 'HTTP Request node requires a URL' })
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'code') {
|
||||
if (!node.config?.code_source?.trim()) {
|
||||
errors.push({ nodeId: node.id, severity: 'error', message: 'Code node requires source code' })
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'human_approval') {
|
||||
if (!node.config?.approval_title?.trim()) {
|
||||
errors.push({ nodeId: node.id, severity: 'error', message: 'Approval gate requires a title' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
WfRuntimeParam,
|
||||
WfOutputFormat,
|
||||
WfOutputRouteDestination,
|
||||
WfNotifyChannel,
|
||||
} from '../../types'
|
||||
import {
|
||||
INPUT_NODE_TYPES,
|
||||
@@ -53,6 +54,70 @@ function serializeRuntimeParamOptions(param: WfRuntimeParam) {
|
||||
return (param.options ?? []).map(option => `${option.value}:${option.label}`).join('\n')
|
||||
}
|
||||
|
||||
/* ── Notify channels sub-editor ───────────────────────────────────────── */
|
||||
|
||||
function NotifyChannelsEditor({
|
||||
channels,
|
||||
onChange,
|
||||
}: {
|
||||
channels: WfNotifyChannel[]
|
||||
onChange: (channels: WfNotifyChannel[]) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div style={SECTION_TITLE_STYLE}>Notification channels</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
|
||||
{channels.map((ch, i) => (
|
||||
<div key={`ch_${i}`} style={{ display: 'grid', gridTemplateColumns: '100px 1fr auto', gap: 6, alignItems: 'end' }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Type</label>
|
||||
<SimpleSelect
|
||||
size="sm"
|
||||
value={ch.type}
|
||||
onChange={v => {
|
||||
const updated = [...channels]
|
||||
updated[i] = { ...ch, type: v as WfNotifyChannel['type'] }
|
||||
onChange(updated)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'slack', label: 'Slack' },
|
||||
{ value: 'agos_notification', label: 'In-app' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Target</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={ch.target}
|
||||
placeholder={ch.type === 'email' ? 'user@example.com' : ch.type === 'slack' ? '#channel' : 'user-id'}
|
||||
onChange={e => {
|
||||
const updated = [...channels]
|
||||
updated[i] = { ...ch, target: e.target.value }
|
||||
onChange(updated)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(channels.filter((_, j) => j !== i))}
|
||||
style={{ height: 34, borderRadius: 10, border: '1px solid color-mix(in srgb, var(--nbl-red) 28%, transparent)', background: 'color-mix(in srgb, var(--nbl-red) 10%, transparent)', color: 'var(--nbl-red)', cursor: 'pointer', padding: '0 10px', fontSize: 11 }}
|
||||
>
|
||||
Del
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onChange([...channels, { type: 'agos_notification', target: '' }])}
|
||||
style={{ padding: '7px 10px', borderRadius: 10, cursor: 'pointer', border: '1px solid var(--nbl-border)', background: 'var(--nbl-bg)', color: 'var(--nbl-text)', fontSize: 11, fontWeight: 700 }}
|
||||
>
|
||||
Add channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NodeInspectorProps {
|
||||
node: WfNode
|
||||
onUpdateNode: (patch: Partial<WfNode>) => void
|
||||
@@ -277,17 +342,514 @@ export function NodeInspector({
|
||||
|
||||
{/* Condition config */}
|
||||
{node.type === 'condition' && (
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Condition rule</label>
|
||||
<textarea
|
||||
className="nbl-textarea"
|
||||
rows={4}
|
||||
style={{ resize: 'vertical' }}
|
||||
value={node.description ?? ''}
|
||||
placeholder="Describe the branch condition"
|
||||
onChange={e => onUpdateNode({ description: e.target.value })}
|
||||
<>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Mode</label>
|
||||
<SimpleSelect
|
||||
size="sm"
|
||||
value={node.config?.condition_mode ?? 'expression'}
|
||||
onChange={v => updateConfig({ condition_mode: v as 'expression' | 'llm_judge' })}
|
||||
options={[
|
||||
{ value: 'expression', label: 'Expression' },
|
||||
{ value: 'llm_judge', label: 'LLM Judge' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{(node.config?.condition_mode ?? 'expression') === 'expression' ? (
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Expression</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={node.config?.expression ?? ''}
|
||||
placeholder={'{{reason_1.output.score}} > 0.8'}
|
||||
onChange={e => updateConfig({ expression: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Judge prompt</label>
|
||||
<textarea
|
||||
className="nbl-textarea"
|
||||
rows={4}
|
||||
style={{ resize: 'vertical' }}
|
||||
value={node.config?.judge_prompt ?? ''}
|
||||
placeholder="Is this content appropriate? Answer yes/no."
|
||||
onChange={e => updateConfig({ judge_prompt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Switch config */}
|
||||
{node.type === 'switch' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Expression</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={node.config?.expression ?? ''}
|
||||
placeholder={'{{reason_1.output.category}}'}
|
||||
onChange={e => updateConfig({ expression: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ ...SECTION_TITLE_STYLE, marginTop: 4 }}>Cases</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{(node.config?.cases ?? []).map((c, i) => (
|
||||
<div key={`case_${i}`} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Label</label>
|
||||
<input className="nbl-input" value={c.label} onChange={e => {
|
||||
const cases = [...(node.config?.cases ?? [])]
|
||||
cases[i] = { ...c, label: e.target.value }
|
||||
updateConfig({ cases })
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Value</label>
|
||||
<input className="nbl-input" value={c.value} onChange={e => {
|
||||
const cases = [...(node.config?.cases ?? [])]
|
||||
cases[i] = { ...c, value: e.target.value }
|
||||
updateConfig({ cases })
|
||||
}} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateConfig({ cases: (node.config?.cases ?? []).filter((_, j) => j !== i) })}
|
||||
style={{ height: 34, borderRadius: 10, border: '1px solid color-mix(in srgb, var(--nbl-red) 28%, transparent)', background: 'color-mix(in srgb, var(--nbl-red) 10%, transparent)', color: 'var(--nbl-red)', cursor: 'pointer', padding: '0 10px', fontSize: 11 }}
|
||||
>
|
||||
Del
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
const cases = [...(node.config?.cases ?? [])]
|
||||
const idx = cases.length + 1
|
||||
cases.push({ label: `Case ${idx}`, value: '', handle: `case_${idx}` })
|
||||
updateConfig({ cases })
|
||||
}}
|
||||
style={{ padding: '7px 10px', borderRadius: 10, cursor: 'pointer', border: '1px solid var(--nbl-border)', background: 'var(--nbl-bg)', color: 'var(--nbl-text)', fontSize: 11, fontWeight: 700 }}
|
||||
>
|
||||
Add case
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Loop config */}
|
||||
{node.type === 'loop' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Source expression (array)</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={node.config?.source_expression ?? ''}
|
||||
placeholder={'{{input.items}}'}
|
||||
onChange={e => updateConfig({ source_expression: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Item variable</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={node.config?.item_variable ?? 'item'}
|
||||
onChange={e => updateConfig({ item_variable: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Index variable</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={node.config?.index_variable ?? 'index'}
|
||||
onChange={e => updateConfig({ index_variable: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Concurrency</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={node.config?.concurrency ?? 1}
|
||||
onChange={e => updateConfig({ concurrency: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Max iterations</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={node.config?.max_iterations ?? 1000}
|
||||
onChange={e => updateConfig({ max_iterations: Number(e.target.value) || 1000 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--nbl-text-muted)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={node.config?.collect_results !== false}
|
||||
onChange={e => updateConfig({ collect_results: e.target.checked })}
|
||||
/>
|
||||
Collect results as array
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Merge config */}
|
||||
{node.type === 'merge' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Wait mode</label>
|
||||
<SimpleSelect
|
||||
size="sm"
|
||||
value={node.config?.merge_mode ?? 'all'}
|
||||
onChange={v => updateConfig({ merge_mode: v as 'all' | 'any' | 'first_n' })}
|
||||
options={[
|
||||
{ value: 'all', label: 'All branches' },
|
||||
{ value: 'any', label: 'Any one branch' },
|
||||
{ value: 'first_n', label: 'First N branches' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{node.config?.merge_mode === 'first_n' && (
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>N (branches to wait for)</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={node.config?.merge_n ?? 1}
|
||||
onChange={e => updateConfig({ merge_n: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Timeout (seconds)</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={node.config?.merge_timeout_seconds ?? 300}
|
||||
onChange={e => updateConfig({ merge_timeout_seconds: Number(e.target.value) || 300 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>On timeout</label>
|
||||
<SimpleSelect
|
||||
size="sm"
|
||||
value={node.config?.merge_on_timeout ?? 'fail'}
|
||||
onChange={v => updateConfig({ merge_on_timeout: v as 'fail' | 'proceed_with_available' })}
|
||||
options={[
|
||||
{ value: 'fail', label: 'Fail' },
|
||||
{ value: 'proceed_with_available', label: 'Proceed' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delay config */}
|
||||
{node.type === 'delay' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Delay (seconds)</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
type="number"
|
||||
min={0}
|
||||
value={node.config?.delay_seconds ?? 5}
|
||||
onChange={e => updateConfig({ delay_seconds: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Until expression (optional)</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={node.config?.delay_until_expression ?? ''}
|
||||
placeholder="Wait until this evaluates to true"
|
||||
onChange={e => updateConfig({ delay_until_expression: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* HTTP Request config */}
|
||||
{node.type === 'http_request' && (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Method</label>
|
||||
<SimpleSelect
|
||||
size="sm"
|
||||
value={node.config?.http_method ?? 'GET'}
|
||||
onChange={v => updateConfig({ http_method: v as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' })}
|
||||
options={[
|
||||
{ value: 'GET', label: 'GET' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
{ value: 'PATCH', label: 'PATCH' },
|
||||
{ value: 'DELETE', label: 'DELETE' },
|
||||
{ value: 'HEAD', label: 'HEAD' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>URL</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={node.config?.http_url ?? ''}
|
||||
placeholder="https://api.example.com/{{input.path}}"
|
||||
onChange={e => updateConfig({ http_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Headers (JSON)</label>
|
||||
<textarea
|
||||
className="nbl-textarea"
|
||||
rows={3}
|
||||
style={{ resize: 'vertical' }}
|
||||
value={node.config?.http_headers ? JSON.stringify(node.config.http_headers, null, 2) : ''}
|
||||
placeholder={'{\n "Authorization": "Bearer {{params.token}}"\n}'}
|
||||
onChange={e => {
|
||||
try { updateConfig({ http_headers: JSON.parse(e.target.value) }) } catch { /* skip invalid JSON */ }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{(node.config?.http_method !== 'GET' && node.config?.http_method !== 'HEAD') && (
|
||||
<>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Content type</label>
|
||||
<SimpleSelect
|
||||
size="sm"
|
||||
value={node.config?.http_body_content_type ?? 'json'}
|
||||
onChange={v => updateConfig({ http_body_content_type: v as 'json' | 'form' | 'text' | 'xml' })}
|
||||
options={[
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'form', label: 'Form' },
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'xml', label: 'XML' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Body template</label>
|
||||
<textarea
|
||||
className="nbl-textarea"
|
||||
rows={5}
|
||||
style={{ resize: 'vertical' }}
|
||||
value={node.config?.http_body_template ?? ''}
|
||||
placeholder={'{\n "message": "{{reason_1.output}}"\n}'}
|
||||
onChange={e => updateConfig({ http_body_template: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Auth</label>
|
||||
<SimpleSelect
|
||||
size="sm"
|
||||
value={node.config?.http_auth_type ?? 'none'}
|
||||
onChange={v => updateConfig({ http_auth_type: v as 'none' | 'bearer' | 'basic' | 'api_key' | 'credential_ref' })}
|
||||
options={[
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'bearer', label: 'Bearer' },
|
||||
{ value: 'basic', label: 'Basic' },
|
||||
{ value: 'api_key', label: 'API Key' },
|
||||
{ value: 'credential_ref', label: 'Credential' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Timeout (ms)</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
type="number"
|
||||
min={1000}
|
||||
max={120000}
|
||||
value={node.config?.http_timeout_ms ?? 30000}
|
||||
onChange={e => updateConfig({ http_timeout_ms: Number(e.target.value) || 30000 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{node.config?.http_auth_type === 'credential_ref' && (
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Credential ID</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={node.config?.http_credential_id ?? ''}
|
||||
placeholder="credential-id from AGOS vault"
|
||||
onChange={e => updateConfig({ http_credential_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Response extract path (JSONPath)</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={node.config?.http_response_extract_path ?? ''}
|
||||
placeholder="$.data.results"
|
||||
onChange={e => updateConfig({ http_response_extract_path: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Code node config */}
|
||||
{node.type === 'code' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Language</label>
|
||||
<SimpleSelect
|
||||
size="sm"
|
||||
value={node.config?.code_language ?? 'python'}
|
||||
onChange={v => updateConfig({ code_language: v as 'javascript' | 'python' })}
|
||||
options={[
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Source code</label>
|
||||
<textarea
|
||||
className="nbl-textarea"
|
||||
rows={10}
|
||||
style={{ resize: 'vertical', fontFamily: 'var(--nbl-mono)', fontSize: 11 }}
|
||||
value={node.config?.code_source ?? ''}
|
||||
placeholder={node.config?.code_language === 'javascript'
|
||||
? '// Write your code here\nconst result = { output: "hello" };'
|
||||
: '# Write your code here\nresult = {"output": "hello"}'}
|
||||
onChange={e => updateConfig({ code_source: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Timeout (seconds)</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={node.config?.code_timeout_seconds ?? 60}
|
||||
onChange={e => updateConfig({ code_timeout_seconds: Number(e.target.value) || 60 })}
|
||||
/>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--nbl-text-muted)', alignSelf: 'end', paddingBottom: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={node.config?.code_network_allowed === true}
|
||||
onChange={e => updateConfig({ code_network_allowed: e.target.checked })}
|
||||
/>
|
||||
Network access
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Allowed packages (one per line)</label>
|
||||
<textarea
|
||||
className="nbl-textarea"
|
||||
rows={3}
|
||||
style={{ resize: 'vertical' }}
|
||||
value={(node.config?.code_packages ?? []).join('\n')}
|
||||
placeholder="requests\nnumpy"
|
||||
onChange={e => updateConfig({ code_packages: e.target.value.split('\n').map(s => s.trim()).filter(Boolean) })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Human Approval config */}
|
||||
{node.type === 'human_approval' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Approval title</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={node.config?.approval_title ?? ''}
|
||||
placeholder="Review and approve this step"
|
||||
onChange={e => updateConfig({ approval_title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Description</label>
|
||||
<textarea
|
||||
className="nbl-textarea"
|
||||
rows={3}
|
||||
style={{ resize: 'vertical' }}
|
||||
value={node.config?.approval_description ?? ''}
|
||||
placeholder="Context shown to the approver"
|
||||
onChange={e => updateConfig({ approval_description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Timeout (seconds)</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
type="number"
|
||||
min={60}
|
||||
max={604800}
|
||||
value={node.config?.approval_timeout_seconds ?? 86400}
|
||||
onChange={e => updateConfig({ approval_timeout_seconds: Number(e.target.value) || 86400 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>On timeout</label>
|
||||
<SimpleSelect
|
||||
size="sm"
|
||||
value={node.config?.approval_timeout_action ?? 'fail'}
|
||||
onChange={v => updateConfig({ approval_timeout_action: v as 'fail' | 'skip' | 'auto_approve' })}
|
||||
options={[
|
||||
{ value: 'fail', label: 'Fail' },
|
||||
{ value: 'skip', label: 'Skip' },
|
||||
{ value: 'auto_approve', label: 'Auto-approve' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--nbl-text-muted)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={node.config?.approval_allow_modification === true}
|
||||
onChange={e => updateConfig({ approval_allow_modification: e.target.checked })}
|
||||
/>
|
||||
Allow approver to modify data
|
||||
</label>
|
||||
<NotifyChannelsEditor
|
||||
channels={node.config?.approval_notify_channels ?? []}
|
||||
onChange={channels => updateConfig({ approval_notify_channels: channels })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notification config */}
|
||||
{node.type === 'notification' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Message template</label>
|
||||
<textarea
|
||||
className="nbl-textarea"
|
||||
rows={4}
|
||||
style={{ resize: 'vertical' }}
|
||||
value={node.config?.notify_template ?? ''}
|
||||
placeholder={'Workflow {{meta.workflow_id}} completed with result: {{output_1.result}}'}
|
||||
onChange={e => updateConfig({ notify_template: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<NotifyChannelsEditor
|
||||
channels={node.config?.notify_channels ?? []}
|
||||
onChange={channels => updateConfig({ notify_channels: channels })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Output config */}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Shown when no node is selected.
|
||||
*/
|
||||
|
||||
import type { WfDefinition, WfProviderType, WfTrigger, WfProvider } from '../../types'
|
||||
import type { WfDefinition, WfProviderType, WfTrigger, WfProvider, WfTriggerType } from '../../types'
|
||||
|
||||
const FIELD_LABEL_STYLE = {
|
||||
fontSize: 10,
|
||||
@@ -20,7 +20,7 @@ const SECTION_TITLE_STYLE = {
|
||||
letterSpacing: '0.08em',
|
||||
} as const
|
||||
|
||||
type TriggerMode = 'manual' | 'webhook'
|
||||
type TriggerMode = WfTriggerType
|
||||
|
||||
interface WorkflowInspectorProps {
|
||||
definition: WfDefinition
|
||||
@@ -48,26 +48,31 @@ export function WorkflowInspector({
|
||||
{/* Trigger */}
|
||||
<div>
|
||||
<div style={SECTION_TITLE_STYLE}>Trigger</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginTop: 8 }}>
|
||||
{(['manual', 'webhook'] as TriggerMode[]).map(mode => {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6, marginTop: 8 }}>
|
||||
{([
|
||||
{ mode: 'manual' as TriggerMode, label: 'Manual' },
|
||||
{ mode: 'webhook' as TriggerMode, label: 'Webhook' },
|
||||
{ mode: 'schedule' as TriggerMode, label: 'Schedule' },
|
||||
{ mode: 'event' as TriggerMode, label: 'Event' },
|
||||
{ mode: 'chat' as TriggerMode, label: 'Chat' },
|
||||
]).map(({ mode, label }) => {
|
||||
const active = trigger.type === mode
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => onUpdateTrigger({ type: mode, enabled: true })}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
padding: '8px 6px',
|
||||
borderRadius: 10,
|
||||
cursor: 'pointer',
|
||||
border: active ? '1px solid var(--nbl-agos)' : '1px solid var(--nbl-border)',
|
||||
background: active ? 'color-mix(in srgb, var(--nbl-agos) 12%, var(--nbl-bg))' : 'var(--nbl-bg)',
|
||||
color: active ? 'var(--nbl-agos)' : 'var(--nbl-text-muted)',
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{mode}
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -105,7 +110,7 @@ export function WorkflowInspector({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook config (conditional) */}
|
||||
{/* Webhook config */}
|
||||
{trigger.type === 'webhook' && (
|
||||
<>
|
||||
<div>
|
||||
@@ -114,7 +119,7 @@ export function WorkflowInspector({
|
||||
className="nbl-input"
|
||||
value={trigger.config?.slug ?? ''}
|
||||
placeholder="auto-generated if empty"
|
||||
onChange={e => onUpdateTrigger({ config: { slug: e.target.value } })}
|
||||
onChange={e => onUpdateTrigger({ config: { ...trigger.config, slug: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -123,12 +128,75 @@ export function WorkflowInspector({
|
||||
className="nbl-input"
|
||||
value={trigger.config?.agent_id ?? ''}
|
||||
placeholder="running agent id"
|
||||
onChange={e => onUpdateTrigger({ config: { agent_id: e.target.value } })}
|
||||
onChange={e => onUpdateTrigger({ config: { ...trigger.config, agent_id: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Schedule config */}
|
||||
{trigger.type === 'schedule' && (
|
||||
<>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Cron expression</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={trigger.config?.cron ?? ''}
|
||||
placeholder="0 9 * * 1 (Mon 9am UTC)"
|
||||
onChange={e => onUpdateTrigger({ config: { ...trigger.config, cron: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Timezone</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={trigger.config?.timezone ?? 'UTC'}
|
||||
placeholder="UTC"
|
||||
onChange={e => onUpdateTrigger({ config: { ...trigger.config, timezone: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Agent ID</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={trigger.config?.agent_id ?? ''}
|
||||
placeholder="Agent to run the workflow"
|
||||
onChange={e => onUpdateTrigger({ config: { ...trigger.config, agent_id: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Event config */}
|
||||
{trigger.type === 'event' && (
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Event types (one per line)</label>
|
||||
<textarea
|
||||
className="nbl-textarea"
|
||||
rows={3}
|
||||
style={{ resize: 'vertical' }}
|
||||
value={(trigger.config?.event_types ?? []).join('\n')}
|
||||
placeholder={'agent.task.completed\nworkflow.execution.failed'}
|
||||
onChange={e => onUpdateTrigger({
|
||||
config: { ...trigger.config, event_types: e.target.value.split('\n').map(s => s.trim()).filter(Boolean) },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat trigger config */}
|
||||
{trigger.type === 'chat' && (
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Chat command</label>
|
||||
<input
|
||||
className="nbl-input"
|
||||
value={trigger.config?.command ?? ''}
|
||||
placeholder="/run my-workflow"
|
||||
onChange={e => onUpdateTrigger({ config: { ...trigger.config, command: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template reference */}
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Template reference</label>
|
||||
|
||||
@@ -42,12 +42,23 @@ const NODE_TYPE_ENTRIES: NodeTypeEntry[] = [
|
||||
{ type: 'text_input', family: 'input', label: 'Text Input', description: 'Collect text for downstream reasoning or tools', icon: NODE_ICON.text_input, color: NODE_COLOR.text_input, idPrefix: 'text' },
|
||||
{ type: 'file_input', family: 'input', label: 'File Input', description: 'Provide a file path or artifact reference', icon: NODE_ICON.file_input, color: NODE_COLOR.file_input, idPrefix: 'file' },
|
||||
{ type: 'image_input', family: 'input', label: 'Image Input', description: 'Provide an image path for downstream nodes', icon: NODE_ICON.image_input, color: NODE_COLOR.image_input, idPrefix: 'image' },
|
||||
// Engine
|
||||
// Engine (AI)
|
||||
{ type: 'llm_reason', family: 'engine', label: 'LLM Reasoning', description: 'Send a prompt to an LLM for reasoning', icon: NODE_ICON.llm_reason, color: NODE_COLOR.llm_reason, idPrefix: 'reason' },
|
||||
{ type: 'tool_call', family: 'engine', label: 'Tool Call', description: 'Execute a registered tool with structured input', icon: NODE_ICON.tool_call, color: NODE_COLOR.tool_call, idPrefix: 'tool' },
|
||||
{ type: 'condition', family: 'engine', label: 'Condition', description: 'Branch execution based on a condition', icon: NODE_ICON.condition, color: NODE_COLOR.condition, idPrefix: 'condition' },
|
||||
// Logic / Control Flow
|
||||
{ type: 'condition', family: 'logic', label: 'Condition', description: 'Binary branch (if/else) based on expression or LLM judge', icon: NODE_ICON.condition, color: NODE_COLOR.condition, idPrefix: 'cond' },
|
||||
{ type: 'switch', family: 'logic', label: 'Switch', description: 'Multi-way branch with named cases', icon: NODE_ICON.switch, color: NODE_COLOR.switch, idPrefix: 'switch' },
|
||||
{ type: 'loop', family: 'logic', label: 'Loop', description: 'Iterate over a list with configurable concurrency', icon: NODE_ICON.loop, color: NODE_COLOR.loop, idPrefix: 'loop' },
|
||||
{ type: 'merge', family: 'logic', label: 'Merge', description: 'Wait for multiple branches to complete', icon: NODE_ICON.merge, color: NODE_COLOR.merge, idPrefix: 'merge' },
|
||||
{ type: 'delay', family: 'logic', label: 'Delay', description: 'Wait for a duration or condition before continuing', icon: NODE_ICON.delay, color: NODE_COLOR.delay, idPrefix: 'delay' },
|
||||
// Integration / Tool
|
||||
{ type: 'http_request', family: 'tool', label: 'HTTP Request', description: 'Make an HTTP request to an external API', icon: NODE_ICON.http_request, color: NODE_COLOR.http_request, idPrefix: 'http' },
|
||||
{ type: 'code', family: 'tool', label: 'Code', description: 'Execute sandboxed JavaScript or Python code', icon: NODE_ICON.code, color: NODE_COLOR.code, idPrefix: 'code' },
|
||||
// Gate
|
||||
{ type: 'human_approval', family: 'gate', label: 'Approval Gate', description: 'Pause for human review and approval', icon: NODE_ICON.human_approval, color: NODE_COLOR.human_approval, idPrefix: 'gate' },
|
||||
// Output
|
||||
{ type: 'output', family: 'output', label: 'Output', description: 'Create a first-class workflow output', icon: NODE_ICON.output, color: NODE_COLOR.output, idPrefix: 'output' },
|
||||
{ type: 'notification', family: 'output', label: 'Notification', description: 'Send notification via email, Slack, or in-app', icon: NODE_ICON.notification, color: NODE_COLOR.notification, idPrefix: 'notify' },
|
||||
]
|
||||
|
||||
const NODE_TYPE_MAP = new Map(NODE_TYPE_ENTRIES.map(e => [e.type, e]))
|
||||
@@ -106,7 +117,10 @@ export function getPaletteGroups(filter?: string): PaletteGroup[] {
|
||||
const groups: PaletteGroup[] = []
|
||||
const familyOrder: { family: WfNodeFamily; label: string }[] = [
|
||||
{ family: 'input', label: 'Input' },
|
||||
{ family: 'engine', label: 'Engine' },
|
||||
{ family: 'engine', label: 'AI / Engine' },
|
||||
{ family: 'logic', label: 'Control Flow' },
|
||||
{ family: 'tool', label: 'Integration' },
|
||||
{ family: 'gate', label: 'Governance' },
|
||||
{ family: 'output', label: 'Output' },
|
||||
]
|
||||
for (const { family, label } of familyOrder) {
|
||||
@@ -122,7 +136,19 @@ export function getPaletteGroups(filter?: string): PaletteGroup[] {
|
||||
export const INPUT_NODE_TYPES: WfNodeType[] = ['text_input', 'file_input', 'image_input']
|
||||
|
||||
/** Node types within the Engine family (for inspector type morph) */
|
||||
export const ENGINE_NODE_TYPES: WfNodeType[] = ['llm_reason', 'tool_call', 'condition']
|
||||
export const ENGINE_NODE_TYPES: WfNodeType[] = ['llm_reason', 'tool_call']
|
||||
|
||||
/** Node types within the Logic family */
|
||||
export const LOGIC_NODE_TYPES: WfNodeType[] = ['condition', 'switch', 'loop', 'merge', 'delay']
|
||||
|
||||
/** Node types within the Tool/Integration family */
|
||||
export const TOOL_NODE_TYPES: WfNodeType[] = ['http_request', 'code']
|
||||
|
||||
/** Node types within the Gate family */
|
||||
export const GATE_NODE_TYPES: WfNodeType[] = ['human_approval']
|
||||
|
||||
/** Node types within the Output family */
|
||||
export const OUTPUT_NODE_TYPES: WfNodeType[] = ['output', 'notification']
|
||||
|
||||
/** Output format options */
|
||||
export const OUTPUT_FORMAT_OPTIONS: { value: WfOutputFormat; label: string }[] = [
|
||||
@@ -201,7 +227,115 @@ export function buildDefaultNode(def: WfDefinition, type: WfNodeType, label: str
|
||||
}
|
||||
}
|
||||
if (type === 'condition') {
|
||||
return { id, type, family, label, description: '', runtime_params: [] }
|
||||
return {
|
||||
id, type, family, label,
|
||||
description: 'Branch based on expression or LLM judge',
|
||||
config: { condition_mode: 'expression', expression: '' },
|
||||
runtime_params: [],
|
||||
}
|
||||
}
|
||||
if (type === 'switch') {
|
||||
return {
|
||||
id, type, family, label,
|
||||
description: 'Multi-way branch with named cases',
|
||||
config: {
|
||||
condition_mode: 'expression',
|
||||
expression: '',
|
||||
cases: [
|
||||
{ label: 'Case A', value: 'a', handle: 'case_1' },
|
||||
{ label: 'Case B', value: 'b', handle: 'case_2' },
|
||||
],
|
||||
default_handle: 'default',
|
||||
},
|
||||
runtime_params: [],
|
||||
}
|
||||
}
|
||||
if (type === 'loop') {
|
||||
return {
|
||||
id, type, family, label,
|
||||
description: 'Iterate over a collection',
|
||||
config: {
|
||||
source_expression: '',
|
||||
item_variable: 'item',
|
||||
index_variable: 'index',
|
||||
concurrency: 1,
|
||||
max_iterations: 1000,
|
||||
collect_results: true,
|
||||
},
|
||||
runtime_params: [],
|
||||
}
|
||||
}
|
||||
if (type === 'merge') {
|
||||
return {
|
||||
id, type, family, label,
|
||||
description: 'Wait for branches to complete',
|
||||
config: {
|
||||
merge_mode: 'all',
|
||||
merge_timeout_seconds: 300,
|
||||
merge_on_timeout: 'fail',
|
||||
},
|
||||
runtime_params: [],
|
||||
}
|
||||
}
|
||||
if (type === 'delay') {
|
||||
return {
|
||||
id, type, family, label,
|
||||
description: 'Wait before continuing',
|
||||
config: { delay_seconds: 5 },
|
||||
runtime_params: [],
|
||||
}
|
||||
}
|
||||
if (type === 'http_request') {
|
||||
return {
|
||||
id, type, family, label,
|
||||
description: 'Make an HTTP request',
|
||||
config: {
|
||||
http_method: 'GET',
|
||||
http_url: '',
|
||||
http_body_content_type: 'json',
|
||||
http_auth_type: 'none',
|
||||
http_timeout_ms: 30000,
|
||||
},
|
||||
runtime_params: [],
|
||||
}
|
||||
}
|
||||
if (type === 'code') {
|
||||
return {
|
||||
id, type, family, label,
|
||||
description: 'Execute sandboxed code',
|
||||
config: {
|
||||
code_language: 'python',
|
||||
code_source: '# Write your code here\nresult = {"output": "hello"}',
|
||||
code_timeout_seconds: 60,
|
||||
code_network_allowed: false,
|
||||
},
|
||||
runtime_params: [],
|
||||
}
|
||||
}
|
||||
if (type === 'human_approval') {
|
||||
return {
|
||||
id, type, family, label,
|
||||
description: 'Pause for human review',
|
||||
config: {
|
||||
approval_title: 'Review required',
|
||||
approval_timeout_seconds: 86400,
|
||||
approval_timeout_action: 'fail',
|
||||
approval_allow_modification: false,
|
||||
approval_notify_channels: [],
|
||||
},
|
||||
runtime_params: [],
|
||||
}
|
||||
}
|
||||
if (type === 'notification') {
|
||||
return {
|
||||
id, type, family, label,
|
||||
description: 'Send a notification',
|
||||
config: {
|
||||
notify_channels: [{ type: 'agos_notification', target: '' }],
|
||||
notify_template: '',
|
||||
},
|
||||
runtime_params: [],
|
||||
}
|
||||
}
|
||||
if (type === 'output') {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
GitBranch, Play, CheckCircle2, Cpu, Zap, Type, FileText, Image as ImageIcon, ArrowDownToLine,
|
||||
ArrowLeftRight, Repeat, Merge, Timer, Globe, Code2, ShieldCheck, Bell,
|
||||
} from 'lucide-react'
|
||||
import type { ElementType } from 'react'
|
||||
import type { WfNodeType, WfNode, WfNodeFamily } from '../types'
|
||||
@@ -12,6 +13,9 @@ export const NODE_FAMILY_COLOR: Record<WfNodeFamily, string> = {
|
||||
system: 'var(--nbl-text-muted)',
|
||||
input: 'var(--nbl-green)',
|
||||
engine: 'var(--nbl-agos)',
|
||||
logic: 'var(--nbl-amber)',
|
||||
tool: 'var(--nbl-cyan)',
|
||||
gate: 'var(--nbl-pink, #f472b6)',
|
||||
output: 'var(--nbl-cyan)',
|
||||
}
|
||||
|
||||
@@ -24,7 +28,15 @@ export const NODE_COLOR: Record<WfNodeType, string> = {
|
||||
llm_reason: 'var(--nbl-agos)',
|
||||
tool_call: 'var(--nbl-cyan)',
|
||||
condition: 'var(--nbl-amber)',
|
||||
switch: 'var(--nbl-amber)',
|
||||
loop: 'var(--nbl-amber)',
|
||||
merge: 'var(--nbl-amber)',
|
||||
delay: 'var(--nbl-text-muted)',
|
||||
http_request: 'var(--nbl-cyan)',
|
||||
code: 'var(--nbl-agos)',
|
||||
human_approval: 'var(--nbl-pink, #f472b6)',
|
||||
output: 'var(--nbl-pink, #f472b6)',
|
||||
notification: 'var(--nbl-pink, #f472b6)',
|
||||
}
|
||||
|
||||
export const NODE_ICON: Record<WfNodeType, ElementType> = {
|
||||
@@ -36,7 +48,15 @@ export const NODE_ICON: Record<WfNodeType, ElementType> = {
|
||||
llm_reason: Cpu,
|
||||
tool_call: Zap,
|
||||
condition: GitBranch,
|
||||
switch: ArrowLeftRight,
|
||||
loop: Repeat,
|
||||
merge: Merge,
|
||||
delay: Timer,
|
||||
http_request: Globe,
|
||||
code: Code2,
|
||||
human_approval: ShieldCheck,
|
||||
output: ArrowDownToLine,
|
||||
notification: Bell,
|
||||
}
|
||||
|
||||
export const NODE_TYPE_LABEL: Record<WfNodeType, string> = {
|
||||
@@ -48,13 +68,24 @@ export const NODE_TYPE_LABEL: Record<WfNodeType, string> = {
|
||||
llm_reason: 'LLM Reasoning',
|
||||
tool_call: 'Tool Call',
|
||||
condition: 'Condition',
|
||||
switch: 'Switch',
|
||||
loop: 'Loop',
|
||||
merge: 'Merge',
|
||||
delay: 'Delay',
|
||||
http_request: 'HTTP Request',
|
||||
code: 'Code',
|
||||
human_approval: 'Approval Gate',
|
||||
output: 'Output',
|
||||
notification: 'Notification',
|
||||
}
|
||||
|
||||
export const NODE_SHAPE_BY_FAMILY: Record<WfNodeFamily, 'pill' | 'rect' | 'hex'> = {
|
||||
export const NODE_SHAPE_BY_FAMILY: Record<WfNodeFamily, 'pill' | 'rect' | 'hex' | 'diamond'> = {
|
||||
system: 'pill',
|
||||
input: 'pill',
|
||||
engine: 'rect',
|
||||
logic: 'diamond',
|
||||
tool: 'rect',
|
||||
gate: 'hex',
|
||||
output: 'hex',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
export type WfNodeType = 'llm_reason' | 'tool_call' | 'condition' | 'text_input' | 'file_input' | 'image_input' | 'output' | 'start' | 'end'
|
||||
export type WfNodeType =
|
||||
// System
|
||||
| 'start' | 'end'
|
||||
// Input
|
||||
| 'text_input' | 'file_input' | 'image_input'
|
||||
// Engine (AI)
|
||||
| 'llm_reason' | 'tool_call'
|
||||
// Logic / Control Flow
|
||||
| 'condition' | 'switch' | 'loop' | 'merge' | 'delay'
|
||||
// Integration
|
||||
| 'http_request' | 'code'
|
||||
// Gate
|
||||
| 'human_approval'
|
||||
// Output
|
||||
| 'output' | 'notification'
|
||||
|
||||
export type WfNodeFamily = 'system' | 'input' | 'engine' | 'output'
|
||||
export type WfNodeFamily = 'system' | 'input' | 'engine' | 'logic' | 'tool' | 'gate' | 'output'
|
||||
|
||||
export type WfRuntimeParamType =
|
||||
| 'text'
|
||||
@@ -38,7 +52,7 @@ export type WfOutputRoute = {
|
||||
|
||||
export type WfOutputFormat = 'json' | 'md' | 'csv' | 'raw' | 'custom'
|
||||
|
||||
export type WfTriggerType = 'manual' | 'webhook'
|
||||
export type WfTriggerType = 'manual' | 'webhook' | 'schedule' | 'event' | 'chat'
|
||||
|
||||
export type WfProviderType = 'agos-native' | 'n8n'
|
||||
|
||||
@@ -57,6 +71,13 @@ export type WfTriggerConfig = {
|
||||
slug?: string
|
||||
secret?: string
|
||||
agent_id?: string
|
||||
// Schedule
|
||||
cron?: string
|
||||
timezone?: string
|
||||
// Event
|
||||
event_types?: string[]
|
||||
// Chat
|
||||
command?: string
|
||||
}
|
||||
|
||||
export type WfTrigger = {
|
||||
@@ -65,18 +86,88 @@ export type WfTrigger = {
|
||||
config: WfTriggerConfig
|
||||
}
|
||||
|
||||
export type WfSwitchCase = {
|
||||
label: string
|
||||
value: string
|
||||
handle: string
|
||||
}
|
||||
|
||||
export type WfNotifyChannel = {
|
||||
type: 'email' | 'slack' | 'agos_notification'
|
||||
target: string
|
||||
}
|
||||
|
||||
export type WfNodeConfig = {
|
||||
// Input nodes
|
||||
source_key?: string
|
||||
input_kind?: 'text' | 'file' | 'image'
|
||||
required?: boolean
|
||||
default_value?: string
|
||||
placeholder?: string
|
||||
accept?: string
|
||||
// Output nodes
|
||||
output_format?: WfOutputFormat
|
||||
value_template?: string
|
||||
output_schema?: string
|
||||
output_routes?: WfOutputRoute[]
|
||||
// Tool call
|
||||
tool_input?: string
|
||||
// Condition / Switch
|
||||
condition_mode?: 'expression' | 'llm_judge'
|
||||
expression?: string
|
||||
judge_prompt?: string
|
||||
cases?: WfSwitchCase[]
|
||||
default_handle?: string
|
||||
// Loop
|
||||
source_expression?: string
|
||||
item_variable?: string
|
||||
index_variable?: string
|
||||
concurrency?: number
|
||||
max_iterations?: number
|
||||
collect_results?: boolean
|
||||
// Merge
|
||||
merge_mode?: 'all' | 'any' | 'first_n'
|
||||
merge_n?: number
|
||||
merge_timeout_seconds?: number
|
||||
merge_on_timeout?: 'fail' | 'proceed_with_available'
|
||||
// Delay
|
||||
delay_seconds?: number
|
||||
delay_until_expression?: string
|
||||
// HTTP Request
|
||||
http_method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'
|
||||
http_url?: string
|
||||
http_headers?: Record<string, string>
|
||||
http_body_template?: string
|
||||
http_body_content_type?: 'json' | 'form' | 'text' | 'xml'
|
||||
http_auth_type?: 'none' | 'bearer' | 'basic' | 'api_key' | 'credential_ref'
|
||||
http_credential_id?: string
|
||||
http_timeout_ms?: number
|
||||
http_response_extract_path?: string
|
||||
// Code
|
||||
code_language?: 'javascript' | 'python'
|
||||
code_source?: string
|
||||
code_packages?: string[]
|
||||
code_timeout_seconds?: number
|
||||
code_network_allowed?: boolean
|
||||
// Human approval
|
||||
approval_title?: string
|
||||
approval_description?: string
|
||||
approval_timeout_seconds?: number
|
||||
approval_timeout_action?: 'fail' | 'skip' | 'auto_approve'
|
||||
approval_allow_modification?: boolean
|
||||
approval_notify_channels?: WfNotifyChannel[]
|
||||
// Notification
|
||||
notify_channels?: WfNotifyChannel[]
|
||||
notify_template?: string
|
||||
}
|
||||
|
||||
export type WfNodeExecution = {
|
||||
timeout_seconds?: number
|
||||
retry_max?: number
|
||||
retry_backoff_type?: 'fixed' | 'exponential'
|
||||
retry_backoff_seconds?: number
|
||||
on_error?: 'fail' | 'skip' | 'default_value'
|
||||
default_value?: unknown
|
||||
}
|
||||
|
||||
export type WfNode = {
|
||||
@@ -87,15 +178,24 @@ export type WfNode = {
|
||||
description?: string
|
||||
tool?: string
|
||||
prompt?: string
|
||||
model?: string
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
structured_output_schema?: string
|
||||
config?: WfNodeConfig
|
||||
execution?: WfNodeExecution
|
||||
runtime_params?: WfRuntimeParam[]
|
||||
position?: { x: number; y: number }
|
||||
}
|
||||
|
||||
export type WfEdge = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
source_handle?: string
|
||||
target_handle?: string
|
||||
label?: string
|
||||
condition?: string
|
||||
}
|
||||
|
||||
export type WfDefinition = {
|
||||
@@ -110,7 +210,10 @@ export type WfDefinition = {
|
||||
export function getNodeFamily(type: WfNodeType): WfNodeFamily {
|
||||
if (type === 'start' || type === 'end') return 'system'
|
||||
if (type === 'text_input' || type === 'file_input' || type === 'image_input') return 'input'
|
||||
if (type === 'output') return 'output'
|
||||
if (type === 'condition' || type === 'switch' || type === 'loop' || type === 'merge' || type === 'delay') return 'logic'
|
||||
if (type === 'http_request' || type === 'code') return 'tool'
|
||||
if (type === 'human_approval') return 'gate'
|
||||
if (type === 'output' || type === 'notification') return 'output'
|
||||
return 'engine'
|
||||
}
|
||||
|
||||
@@ -244,15 +347,21 @@ function parseNodeConfig(
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** All known node types for direct metadata matching */
|
||||
const KNOWN_NODE_TYPES = new Set<string>([
|
||||
'start', 'end', 'text_input', 'file_input', 'image_input',
|
||||
'llm_reason', 'tool_call', 'condition', 'switch', 'loop', 'merge', 'delay',
|
||||
'http_request', 'code', 'human_approval', 'output', 'notification',
|
||||
])
|
||||
|
||||
function inferNodeType(
|
||||
tool: string,
|
||||
metadataType: string,
|
||||
input: Record<string, unknown> | undefined,
|
||||
): WfNodeType {
|
||||
if (metadataType === 'condition') return 'condition'
|
||||
if (metadataType === 'text_input' || metadataType === 'file_input' || metadataType === 'image_input' || metadataType === 'output') {
|
||||
return metadataType
|
||||
}
|
||||
// Direct match from metadata type
|
||||
if (KNOWN_NODE_TYPES.has(metadataType)) return metadataType as WfNodeType
|
||||
|
||||
if (tool === 'workflow_input') {
|
||||
const inputKind = String(input?.input_kind ?? '').trim()
|
||||
if (inputKind === 'file') return 'file_input'
|
||||
@@ -297,10 +406,13 @@ function nodeSpecToWfNode(spec: Record<string, unknown>): WfNode {
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_TRIGGER_TYPES = new Set<string>(['manual', 'webhook', 'schedule', 'event', 'chat'])
|
||||
|
||||
function parseTrigger(raw: Record<string, unknown>): WfTrigger {
|
||||
const trigger = raw.trigger as Record<string, unknown> | undefined
|
||||
const config = trigger?.config as Record<string, unknown> | undefined
|
||||
const type = trigger?.type === 'webhook' ? 'webhook' : 'manual'
|
||||
const rawType = String(trigger?.type ?? 'manual')
|
||||
const type: WfTriggerType = VALID_TRIGGER_TYPES.has(rawType) ? rawType as WfTriggerType : 'manual'
|
||||
return {
|
||||
type,
|
||||
enabled: trigger?.enabled !== false,
|
||||
@@ -308,6 +420,10 @@ function parseTrigger(raw: Record<string, unknown>): WfTrigger {
|
||||
slug: typeof config?.slug === 'string' ? config.slug : undefined,
|
||||
secret: typeof config?.secret === 'string' ? config.secret : undefined,
|
||||
agent_id: typeof config?.agent_id === 'string' ? config.agent_id : undefined,
|
||||
cron: typeof config?.cron === 'string' ? config.cron : undefined,
|
||||
timezone: typeof config?.timezone === 'string' ? config.timezone : undefined,
|
||||
event_types: Array.isArray(config?.event_types) ? config.event_types as string[] : undefined,
|
||||
command: typeof config?.command === 'string' ? config.command : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user