Эх сурвалжийг харах

feat: pick a Configurator's settings from the nodes it is connected to

The Configurator was unusable as shipped: its settings were a bare JSON array,
so using it meant knowing that a field is spelled "serverUrl". Nobody knows
that, which defeats the point of the node.

Settings are now READ from the connected nodes' schemas. The editor lists what
each connected node accepts, by its human title with the real name underneath,
says which of them use it, and badges the ones several share - which are exactly
the settings a Configurator exists for, so they sort first. Choosing one seeds
it with the schema default and renders the right widget: a dropdown for an enum,
a checkbox for a boolean, a number box for a number.

Per-setting targeting. Two nodes often share a setting name while only one
should be overwritten, so each setting lists the connected nodes that accept it
with a checkbox each. Empty means every connected node, which is the ordinary
case and stays that way if a target is added later. The node emits _configFor
and the engine filters by target id.

On the target node, a supplied setting is now shown filled in and disabled,
labelled with the Configurator that supplies it. Editing it there would have
been silently discarded at run time - the overlay is applied last - and a box
that quietly ignores what is typed into it is worse than no box.

Ports. The config input is a violet handle on the LEFT, away from the data
handles along the top. A Configurator does not get one - it supplies settings,
it does not receive them - and its output leaves from the RIGHT to meet that
left-hand input, instead of dropping out of the bottom into a data flow it is
not part of. That also removes the third port it had.

A setting whose name no connected node accepts is called out in the editor as
well as at run time, since it is usually left over from a node this was
previously connected to.

Verified in the browser against the sdcpp workflow: the picker lists Timeout
shared by both nodes and Credential, Model Name and the rest from SD.cpp Model
alone; Server URL on SD.cpp Health shows disabled, pre-filled with
http://mulan:8077 and attributed to "SD.cpp Server". A per-target fixture
proves a restricted setting reaches only the node it names. Full suite 53/53.
fszontagh 1 сар өмнө
parent
commit
a91852e9d8

+ 21 - 1
nodes/core/configurator.js

@@ -26,6 +26,12 @@ const configSchema = {
                         type: 'string',
                         title: 'Value',
                         description: 'A literal value, or an expression such as {{data.result.prompt}}. Expressions are resolved here, once, before the value reaches the target'
+                    },
+                    targets: {
+                        type: 'array',
+                        title: 'Apply To',
+                        description: 'Ids of the connected nodes this setting applies to. Leave empty to apply it to every connected node - which is the usual case. Naming some is for when two nodes share a setting name but only one of them should be overwritten',
+                        items: { type: 'string' }
                     }
                 }
             }
@@ -49,6 +55,7 @@ const outputSchema = {
     type: 'object',
     properties: {
         _config: { type: 'object', description: 'The settings, as the engine consumes them' },
+        _configFor: { type: 'object', description: 'Per-setting restriction to particular target nodes, when one was set' },
         settings: { type: 'object', description: 'The same settings, readable in the run log' },
         count: { type: 'number', description: 'How many settings were supplied' },
         label: { type: 'string' }
@@ -91,6 +98,9 @@ async function execute(config, input, context) {
     const label = config.label || '';
 
     const values = {};
+    // Only the restricted ones appear here. A setting with no restriction is
+    // absent, which the engine reads as "every connected node".
+    const restrictedTo = {};
     let count = 0;
 
     for (let i = 0; i < settings.length; i++) {
@@ -115,6 +125,11 @@ async function execute(config, input, context) {
         }
 
         values[name] = coerce(entry.value);
+
+        if (Array.isArray(entry.targets) && entry.targets.length > 0) {
+            restrictedTo[name] = entry.targets.slice();
+        }
+
         count++;
     }
 
@@ -127,12 +142,17 @@ async function execute(config, input, context) {
 
     // _config is what the engine reads. The plain copy alongside it is what a
     // person reads in the run log, where the underscore-prefixed key is stripped.
-    return {
+    const result = {
         _config: values,
         settings: values,
         count: count,
         label: label
     };
+    if (Object.keys(restrictedTo).length > 0) {
+        result._configFor = restrictedTo;
+        result.restrictedTo = restrictedTo;
+    }
+    return result;
 }
 
 module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 25 - 0
src/runner/workflow_engine.cpp

@@ -1655,12 +1655,37 @@ nlohmann::json WorkflowEngine::collectConfigOverlay(
             continue;
         }
 
+        // A setting may name the nodes it applies to. Two nodes often share a
+        // setting name - serverUrl across every sdcpp node - and overwriting
+        // only one of them has to be possible. No entry means every connected
+        // node, which is the ordinary case.
+        const nlohmann::json* restricted = nullptr;
+        if (output.contains("_configFor") && output["_configFor"].is_object()) {
+            restricted = &output["_configFor"];
+        }
+
         for (auto value_it = values->begin(); value_it != values->end(); ++value_it) {
             const std::string& key = value_it.key();
             if (!key.empty() && key[0] == '_') {
                 continue;  // engine plumbing, never a setting
             }
 
+            if (restricted != nullptr && restricted->contains(key)) {
+                const auto& only_for = (*restricted)[key];
+                if (only_for.is_array() && !only_for.empty()) {
+                    bool applies = false;
+                    for (const auto& wanted : only_for) {
+                        if (wanted.is_string() && wanted.get<std::string>() == node_id) {
+                            applies = true;
+                            break;
+                        }
+                    }
+                    if (!applies) {
+                        continue;
+                    }
+                }
+            }
+
             auto existing = supplied_by.find(key);
             if (existing != supplied_by.end() && existing->second != conn.source_node_id) {
                 // Two live Configurators setting the same key is a contradiction

+ 25 - 0
tests/nodes/configurator-per-target.json

@@ -0,0 +1,25 @@
+{
+  "name": "verify-configurator-per-target",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "cfg", "name": "Shared", "type": "configurator", "position": {"x": -200, "y": 100},
+     "config": {"settings": [
+       {"name": "mode", "value": "stop"},
+       {"name": "message", "value": "only for A", "targets": ["tA"]}
+     ]}},
+    {"id": "tA", "name": "TargetA", "type": "stop-and-error", "position": {"x": -100, "y": 250},
+     "config": {"message": "A's own", "mode": "error"}},
+    {"id": "tB", "name": "TargetB", "type": "stop-and-error", "position": {"x": 150, "y": 250},
+     "config": {"message": "B's own", "mode": "error"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "cfg", "targetInput": "data"},
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "tA", "targetInput": "data"},
+    {"sourceNodeId": "cfg", "sourceOutput": "main", "targetNodeId": "tA", "targetInput": "config"},
+    {"sourceNodeId": "cfg", "sourceOutput": "main", "targetNodeId": "tB", "targetInput": "config"}
+  ],
+  "expectStatus": "completed",
+  "expect": {
+    "tA": {"status": "completed", "output": {"stopped": true, "reason": "only for A"}}
+  }
+}

+ 382 - 0
webui/src/components/workflow/ConfiguratorFields.tsx

@@ -0,0 +1,382 @@
+import { useMemo, useState } from 'react'
+import { Plus, X, AlertTriangle, Link2 } from 'lucide-react'
+import { NodeDefinition } from '../../api/workflows'
+
+export interface ConfigTarget {
+  nodeId: string
+  nodeName: string
+  nodeType: string
+}
+
+interface Setting {
+  name: string
+  value: any
+  // Empty or absent means every connected node. Naming some is for when two
+  // nodes share a setting name but only one should be overwritten.
+  targets?: string[]
+}
+
+interface ConfiguratorFieldsProps {
+  targets: ConfigTarget[]
+  nodeDefs: NodeDefinition[]
+  editingConfig: Record<string, any>
+  onConfigChange: (config: Record<string, any>) => void
+}
+
+interface AvailableSetting {
+  name: string
+  title: string
+  description?: string
+  prop: any
+  usedBy: string[]
+}
+
+/**
+ * The settings editor for a Configurator node.
+ *
+ * A Configurator's whole purpose is that its author does not have to know a
+ * setting is spelled "serverUrl". So the list of settings is not typed in - it
+ * is read from the schemas of whichever nodes this Configurator is connected
+ * to, and each one shows which of those nodes will actually use it.
+ */
+export function ConfiguratorFields({
+  targets,
+  nodeDefs,
+  editingConfig,
+  onConfigChange,
+}: ConfiguratorFieldsProps) {
+  const [picking, setPicking] = useState(false)
+
+  const settings: Setting[] = Array.isArray(editingConfig.settings)
+    ? editingConfig.settings
+    : []
+
+  // Every setting offered by any connected node, with the nodes that accept it.
+  // A setting several targets share - serverUrl, credentialId - is the reason
+  // this node exists, so those are worth surfacing first.
+  const available = useMemo(() => {
+    const found = new Map<string, AvailableSetting>()
+
+    for (const target of targets) {
+      const def = nodeDefs.find((d) => d.id === target.nodeType)
+      const schema = def?.configSchema as Record<string, any> | undefined
+      const properties = schema?.properties
+      if (!properties || typeof properties !== 'object') continue
+
+      for (const [name, prop] of Object.entries(properties as Record<string, any>)) {
+        const existing = found.get(name)
+        if (existing) {
+          existing.usedBy.push(target.nodeName)
+        } else {
+          found.set(name, {
+            name,
+            title: prop?.title || name,
+            description: prop?.description,
+            prop,
+            usedBy: [target.nodeName],
+          })
+        }
+      }
+    }
+
+    return Array.from(found.values()).sort((a, b) => {
+      // Shared settings first - they are what a Configurator is for - then
+      // alphabetically so the list does not reshuffle as targets change.
+      if (a.usedBy.length !== b.usedBy.length) return b.usedBy.length - a.usedBy.length
+      return a.title.localeCompare(b.title)
+    })
+  }, [targets, nodeDefs])
+
+  const availableByName = useMemo(() => {
+    const map = new Map<string, AvailableSetting>()
+    for (const entry of available) map.set(entry.name, entry)
+    return map
+  }, [available])
+
+  const chosen = new Set(settings.map((s) => s.name))
+  const unchosen = available.filter((entry) => !chosen.has(entry.name))
+
+  const update = (next: Setting[]) => onConfigChange({ ...editingConfig, settings: next })
+
+  const addSetting = (entry: AvailableSetting) => {
+    // Seed with the schema default so the row starts from something valid
+    // rather than an empty box that would overwrite the target with nothing.
+    const seed = entry.prop?.default !== undefined ? entry.prop.default : ''
+    update([...settings, { name: entry.name, value: seed }])
+    setPicking(false)
+  }
+
+  const removeSetting = (index: number) => {
+    update(settings.filter((_, i) => i !== index))
+  }
+
+  const setValue = (index: number, value: any) => {
+    update(settings.map((s, i) => (i === index ? { ...s, value } : s)))
+  }
+
+  const renderValueInput = (setting: Setting, index: number, prop: any) => {
+    const type = prop?.type
+    const common =
+      'w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 text-sm'
+
+    if (Array.isArray(prop?.enum)) {
+      return (
+        <select
+          className={common}
+          value={setting.value ?? ''}
+          onChange={(e) => setValue(index, e.target.value)}
+        >
+          {prop.enum.map((option: any) => (
+            <option key={String(option)} value={option}>
+              {option === '' ? '(leave to the node default)' : String(option)}
+            </option>
+          ))}
+        </select>
+      )
+    }
+
+    if (type === 'boolean') {
+      return (
+        <label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
+          <input
+            type="checkbox"
+            checked={setting.value === true}
+            onChange={(e) => setValue(index, e.target.checked)}
+            className="rounded border-gray-300 dark:border-slate-600"
+          />
+          {setting.value === true ? 'On' : 'Off'}
+        </label>
+      )
+    }
+
+    if (type === 'number') {
+      return (
+        <input
+          type="number"
+          className={common}
+          value={setting.value ?? ''}
+          onChange={(e) =>
+            // An empty box is not zero. Keeping it as an empty string lets the
+            // runtime leave the target's own value alone rather than
+            // overwriting it with a number nobody chose.
+            setValue(index, e.target.value === '' ? '' : Number(e.target.value))
+          }
+        />
+      )
+    }
+
+    if (prop?.format === 'textarea') {
+      return (
+        <textarea
+          className={`${common} font-mono`}
+          rows={3}
+          value={setting.value ?? ''}
+          onChange={(e) => setValue(index, e.target.value)}
+        />
+      )
+    }
+
+    return (
+      <input
+        type="text"
+        className={common}
+        value={setting.value ?? ''}
+        onChange={(e) => setValue(index, e.target.value)}
+        placeholder="A value, or an expression such as {{data.result.prompt}}"
+      />
+    )
+  }
+
+  if (targets.length === 0) {
+    return (
+      <div className="rounded-lg border border-dashed border-gray-300 dark:border-slate-600 p-4 text-sm text-gray-600 dark:text-gray-400">
+        <div className="flex items-center gap-2 mb-1 font-medium text-gray-700 dark:text-gray-300">
+          <Link2 className="w-4 h-4" />
+          Not connected to anything yet
+        </div>
+        Drag from this node's output to the violet dot on the left of another
+        node. Its settings then appear here to choose from, so you never have to
+        know what they are called.
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="text-xs text-gray-500 dark:text-gray-400">
+        Supplying settings to{' '}
+        <span className="font-medium text-gray-700 dark:text-gray-300">
+          {targets.map((t) => t.nodeName).join(', ')}
+        </span>
+        . Anything set here replaces that node's own value.
+      </div>
+
+      {settings.length === 0 && (
+        <div className="text-sm text-gray-500 dark:text-gray-400">
+          No settings chosen yet.
+        </div>
+      )}
+
+      {settings.map((setting, index) => {
+        const entry = availableByName.get(setting.name)
+        return (
+          <div
+            key={`${setting.name}-${index}`}
+            className="rounded-lg border border-gray-200 dark:border-slate-700 p-3"
+          >
+            <div className="flex items-start justify-between gap-2 mb-1">
+              <div className="min-w-0">
+                <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
+                  {entry?.title || setting.name}
+                </div>
+                <div className="text-[11px] text-gray-500 dark:text-gray-400 font-mono">
+                  {setting.name}
+                </div>
+              </div>
+              <button
+                onClick={() => removeSetting(index)}
+                title="Stop supplying this setting"
+                className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded"
+              >
+                <X className="w-4 h-4" />
+              </button>
+            </div>
+
+            {entry ? (
+              <div className="text-[11px] text-gray-500 dark:text-gray-400 mb-2">
+                Used by {entry.usedBy.join(', ')}
+              </div>
+            ) : (
+              // The name is not offered by any connected node. At runtime it is
+              // reported and ignored rather than applied, so saying so here is
+              // the difference between a visible mistake and a silent one.
+              <div className="flex items-start gap-1.5 text-[11px] text-amber-600 dark:text-amber-400 mb-2">
+                <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
+                No connected node has a setting called "{setting.name}" - it will
+                be ignored. It may be left over from a node this was previously
+                connected to.
+              </div>
+            )}
+
+            {renderValueInput(setting, index, entry?.prop)}
+
+            {/* Which of the connected nodes this particular setting reaches.
+                Only worth showing when there is a choice to make. */}
+            {entry && targets.length > 1 && (
+              <div className="mt-2 pt-2 border-t border-gray-100 dark:border-slate-700">
+                <div className="text-[11px] text-gray-500 dark:text-gray-400 mb-1">
+                  Apply to
+                </div>
+                <div className="flex flex-wrap gap-x-4 gap-y-1">
+                  {targets
+                    .filter((t) => {
+                      const def = nodeDefs.find((d) => d.id === t.nodeType)
+                      const props = (def?.configSchema as any)?.properties
+                      return props && Object.prototype.hasOwnProperty.call(props, setting.name)
+                    })
+                    .map((t) => {
+                      const restricted = Array.isArray(setting.targets) && setting.targets.length > 0
+                      const checked = !restricted || setting.targets!.includes(t.nodeId)
+                      return (
+                        <label
+                          key={t.nodeId}
+                          className="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-300"
+                        >
+                          <input
+                            type="checkbox"
+                            checked={checked}
+                            onChange={(e) => {
+                              const eligible = targets
+                                .filter((x) => {
+                                  const def = nodeDefs.find((d) => d.id === x.nodeType)
+                                  const props = (def?.configSchema as any)?.properties
+                                  return props && Object.prototype.hasOwnProperty.call(props, setting.name)
+                                })
+                                .map((x) => x.nodeId)
+                              const current = restricted ? setting.targets! : eligible
+                              const next = e.target.checked
+                                ? Array.from(new Set([...current, t.nodeId]))
+                                : current.filter((id) => id !== t.nodeId)
+                              // All of them selected is the same as no
+                              // restriction, and storing it that way keeps the
+                              // node working if a target is added later.
+                              const sameAsAll =
+                                next.length === eligible.length &&
+                                eligible.every((id) => next.includes(id))
+                              update(
+                                settings.map((sx, i) =>
+                                  i === index
+                                    ? { ...sx, targets: sameAsAll ? undefined : next }
+                                    : sx
+                                )
+                              )
+                            }}
+                            className="rounded border-gray-300 dark:border-slate-600"
+                          />
+                          {t.nodeName}
+                        </label>
+                      )
+                    })}
+                </div>
+              </div>
+            )}
+
+            {entry?.description && (
+              <div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
+                {entry.description}
+              </div>
+            )}
+          </div>
+        )
+      })}
+
+      {picking ? (
+        <div className="rounded-lg border border-gray-200 dark:border-slate-700 max-h-72 overflow-auto">
+          <div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-slate-700">
+            Settings offered by the connected nodes
+          </div>
+          {unchosen.length === 0 ? (
+            <div className="px-3 py-3 text-sm text-gray-500 dark:text-gray-400">
+              Every available setting is already listed.
+            </div>
+          ) : (
+            unchosen.map((entry) => (
+              <button
+                key={entry.name}
+                onClick={() => addSetting(entry)}
+                className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-slate-700 last:border-0"
+              >
+                <div className="flex items-center justify-between gap-2">
+                  <span className="text-sm text-gray-900 dark:text-gray-100">{entry.title}</span>
+                  {entry.usedBy.length > 1 && (
+                    <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300">
+                      shared by {entry.usedBy.length}
+                    </span>
+                  )}
+                </div>
+                <div className="text-[11px] text-gray-500 dark:text-gray-400">
+                  <span className="font-mono">{entry.name}</span> - used by{' '}
+                  {entry.usedBy.join(', ')}
+                </div>
+              </button>
+            ))
+          )}
+          <button
+            onClick={() => setPicking(false)}
+            className="w-full px-3 py-2 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700"
+          >
+            Cancel
+          </button>
+        </div>
+      ) : (
+        <button
+          onClick={() => setPicking(true)}
+          className="flex items-center gap-2 px-3 py-2 text-sm text-violet-700 dark:text-violet-300 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded-lg border border-violet-200 dark:border-violet-800"
+        >
+          <Plus className="w-4 h-4" />
+          Add a setting
+        </button>
+      )}
+    </div>
+  )
+}

+ 61 - 0
webui/src/components/workflow/NodeConfigModal.tsx

@@ -3,6 +3,7 @@ import { X, Database, Maximize2, Minimize2, ExternalLink, Download, Loader2 } fr
 import { useQuery } from '@tanstack/react-query'
 import Editor from '@monaco-editor/react'
 import { useTheme } from '../../contexts/ThemeContext'
+import { ConfiguratorFields, ConfigTarget } from './ConfiguratorFields'
 import { NodeDefinition } from '../../api/workflows'
 import { credentialsApi, CredentialInfo } from '../../api/credentials'
 import { ConditionBuilder, Condition, AvailableField } from '../ConditionBuilder'
@@ -25,6 +26,13 @@ interface StorageCollection {
 
 interface NodeConfigModalProps {
   selectedNodeData: SelectedNodeData
+  // Nodes this Configurator supplies settings to, so its editor can offer their
+  // settings by name instead of expecting them to be typed from memory.
+  configTargets: ConfigTarget[]
+  // Settings a connected Configurator supplies to THIS node, keyed by setting
+  // name. Those fields are shown filled in and disabled: editing them here
+  // would be silently discarded at run time.
+  suppliedConfig: Record<string, { value: any; from: string }>
   editingConfig: Record<string, any>
   nodeDefs: NodeDefinition[]
   storageCollections: StorageCollection[]
@@ -39,6 +47,8 @@ interface NodeConfigModalProps {
 
 export function NodeConfigModal({
   selectedNodeData,
+  configTargets,
+  suppliedConfig,
   editingConfig,
   nodeDefs,
   storageCollections,
@@ -346,6 +356,27 @@ export function NodeConfigModal({
                   </div>
                 </div>
               </div>
+            ) : selectedNodeData.type === 'configurator' ? (
+              <div className="space-y-4">
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Label
+                  </label>
+                  <input
+                    type="text"
+                    value={editingConfig.label ?? ''}
+                    onChange={(e) => onConfigChange({ ...editingConfig, label: e.target.value })}
+                    placeholder='What this set of settings is for, such as "Production server"'
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 text-sm"
+                  />
+                </div>
+                <ConfiguratorFields
+                  targets={configTargets}
+                  nodeDefs={nodeDefs}
+                  editingConfig={editingConfig}
+                  onConfigChange={onConfigChange}
+                />
+              </div>
             ) : (
               /* Dynamic config fields based on node definition */
               (() => {
@@ -376,6 +407,36 @@ export function NodeConfigModal({
                   // Skip if showWhen condition is not met
                   if (!shouldShowField(prop)) return null
 
+                  // Supplied from elsewhere: show what will actually be used and
+                  // make it clear this box is no longer the one that decides.
+                  const supplied = suppliedConfig[key]
+                  if (supplied) {
+                    const shown =
+                      typeof supplied.value === 'boolean'
+                        ? supplied.value ? 'On' : 'Off'
+                        : supplied.value === '' || supplied.value === undefined || supplied.value === null
+                          ? '(empty)'
+                          : String(supplied.value)
+                    return (
+                      <div key={key}>
+                        <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                          {prop.title || key}
+                        </label>
+                        <input
+                          type="text"
+                          value={shown}
+                          disabled
+                          readOnly
+                          className="w-full px-3 py-2 border border-violet-200 dark:border-violet-800 rounded-lg bg-violet-50 dark:bg-violet-900/20 text-gray-700 dark:text-gray-300 text-sm cursor-not-allowed"
+                        />
+                        <div className="mt-1 text-[11px] text-violet-700 dark:text-violet-300">
+                          Set by the Configurator "{supplied.from}". Change it there,
+                          or disconnect it to edit this node's own value.
+                        </div>
+                      </div>
+                    )
+                  }
+
                   return (
                   <div key={key}>
                     <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">

+ 26 - 3
webui/src/components/workflow/WorkflowNode.tsx

@@ -62,6 +62,11 @@ export function WorkflowNode({ data, selected }: NodeProps) {
 
   // Get loop info for loop nodes
   const isLoopNode = data.type === 'loop'
+  // A Configurator supplies settings; it does not receive them, so it gets no
+  // config input of its own. Its output goes out the right-hand side to meet
+  // the config input on the left of the node it feeds, rather than dropping out
+  // of the bottom into the data flow it is not part of.
+  const isConfigurator = data.type === 'configurator'
   const loopInfo = isLoopNode && execState?.output ? {
     currentIndex: execState.output.currentIndex,
     totalItems: execState.output.totalItems,
@@ -131,6 +136,22 @@ export function WorkflowNode({ data, selected }: NodeProps) {
         />
       ))}
 
+      {/* Configuration input, on the left so it cannot be confused with the
+          data handles along the top. A Configurator wired here replaces this
+          node's own settings; nothing else should be connected to it.
+          Triggers are excluded for the same reason they have no data input. */}
+      {!isTrigger && !isConfigurator && (
+        <Handle
+          key="config"
+          type="target"
+          position={Position.Left}
+          id="config"
+          title="Configuration - connect a Configurator node here"
+          className="!w-3 !h-3 !bg-violet-400 dark:!bg-violet-500 !border-2 !border-white dark:!border-slate-800"
+          style={{ top: '50%' }}
+        />
+      )}
+
       {/* Input labels, only when there is more than one to tell apart */}
       {!isTrigger && inputs.length > 1 && (
         <div className="absolute -top-5 left-0 w-full pointer-events-none">
@@ -267,12 +288,14 @@ export function WorkflowNode({ data, selected }: NodeProps) {
           <Handle
             key={output.name}
             type="source"
-            position={Position.Bottom}
+            position={isConfigurator ? Position.Right : Position.Bottom}
             id={output.name}
             className={`!w-3 !h-3 !border-2 ${isActiveHandle ? '!w-4 !h-4 !border-white dark:!border-slate-800 ring-2 ring-offset-1' : '!border-white dark:!border-slate-800'}`}
             style={{
-              backgroundColor: output.color || '#9ca3af',
-              left: `${calculateHandlePosition(idx, outputs.length)}%`,
+              backgroundColor: isConfigurator ? '#a78bfa' : (output.color || '#9ca3af'),
+              ...(isConfigurator
+                ? { top: '50%' }
+                : { left: `${calculateHandlePosition(idx, outputs.length)}%` }),
               ...(isActiveHandle ? {
                 ringColor: output.name === 'true' || output.name === 'loop' ? '#22c55e' : output.name === 'false' ? '#ef4444' : '#3b82f6',
                 boxShadow: `0 0 0 3px ${output.name === 'true' || output.name === 'loop' ? '#22c55e' : output.name === 'false' ? '#ef4444' : '#3b82f6'}`,

+ 48 - 0
webui/src/pages/WorkflowEditorPage.tsx

@@ -2290,6 +2290,54 @@ function WorkflowEditorInner() {
       {showNodeConfig && selectedNodeData && (
         <NodeConfigModal
           selectedNodeData={selectedNodeData}
+          suppliedConfig={
+            // What a connected Configurator will supply to the node being
+            // edited. Read from the canvas because the connection, not the
+            // node, is what decides - and it can change without this node
+            // being reopened.
+            (() => {
+              const supplied: Record<string, { value: any; from: string }> = {}
+              if (!selectedNodeData) return supplied
+              for (const edge of edges) {
+                if (edge.target !== selectedNodeData.id) continue
+                if ((edge.targetHandle || 'data') !== 'config') continue
+                const source = nodes.find((n) => n.id === edge.source)
+                if (!source) continue
+                const cfg = (source.data?.config || {}) as Record<string, any>
+                const list = Array.isArray(cfg.settings) ? cfg.settings : []
+                const from = (cfg.label as string) || (source.data?.label as string) || edge.source
+                for (const entry of list) {
+                  const name = String(entry?.name || '').trim()
+                  if (!name) continue
+                  // A setting restricted to particular nodes only counts here
+                  // when this node is one of them.
+                  const restricted = Array.isArray(entry.targets) && entry.targets.length > 0
+                  if (restricted && !entry.targets.includes(selectedNodeData.id)) continue
+                  supplied[name] = { value: entry.value, from }
+                }
+              }
+              return supplied
+            })()
+          }
+          configTargets={
+            // Which nodes this Configurator supplies, read from the canvas
+            // rather than stored on the node - the connection is the truth, and
+            // it can change without the node being reopened.
+            selectedNodeData?.type === 'configurator'
+              ? edges
+                  .filter(
+                    (e) => e.source === selectedNodeData.id && (e.targetHandle || 'data') === 'config'
+                  )
+                  .map((e) => {
+                    const target = nodes.find((n) => n.id === e.target)
+                    return {
+                      nodeId: e.target,
+                      nodeName: (target?.data?.label as string) || e.target,
+                      nodeType: (target?.data?.type as string) || '',
+                    }
+                  })
+              : []
+          }
           editingConfig={editingConfig}
           nodeDefs={nodeDefs}
           storageCollections={storageCollections}