Sfoglia il codice sorgente

Merge branch 'configurator-editor'

fszontagh 1 mese fa
parent
commit
bb3a6779a9

+ 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}