Преглед изворни кода

fix: shared controls for credentials, fixed choices and header maps

The Ollama node showed its credential as a raw id in a text box. It was not
alone: every node that took a credential rendered a picker only if it had
remembered to declare one, so forgetting the declaration was all it took to
put an unreadable id in front of someone.

Three controls now live on their own and are used everywhere a setting of
that shape appears - the node editor, the Configurator, and the rows of a
list setting:

CredentialSelect is chosen by what the field is, not by what the node
declared, so a node cannot lose its picker by omission. It also keeps a
credential that is no longer offered - deleted, or of a type the field
stopped taking - as a marked entry rather than rendering blank and letting
the next save wipe it away. When nothing matches it says so, instead of
offering an empty menu.

EnumSelect makes an unlabelled value readable rather than asking forty
nodes to spell out a label each: "stopAndError" reads as "Stop And Error",
while GET and UTF-8 are left alone because capitals mean somebody already
chose that spelling. A node that wants different wording still wins with
enumLabels.

KeyValueEditor replaces "Object configuration: Use JSON format" for header
maps - setting one header meant typing braces correctly, and a stray comma
was swallowed in silence. Only where the schema says the values are
strings: an options passthrough or a JSON Schema stays as JSON, because a
row of text boxes would quietly turn 7 into "7".

Alongside: ollama-chat and http-request now say which stored kinds can
actually work (a database credential has no header to send), http-request
hides its credential field until authentication is set to use one and names
its two auth options, and three sdcpp nodes that filtered on 'basic' alone
were brought in line with the nine that accept 'sdcpp' too.

Verified in the bundled UI at :8090: both nodes show named credentials,
Headers edits as rows and stores as an object, auth reads as "Stored
credential". 60/60.
fszontagh пре 1 месец
родитељ
комит
b1c7d69c93

+ 7 - 0
nodes/ai/ollama-chat.js

@@ -28,6 +28,13 @@ const configSchema = {
             type: 'string',
             title: 'Credential',
             description: 'Stored Bearer credential holding the ollama.com API key',
+            // Read as a header, so any header-shaped credential works. An IMAP
+            // or database credential has no header to send and would only be
+            // an option that fails.
+            dynamicOptions: {
+                source: 'credentials',
+                filter: { type: ['basic', 'bearer', 'api_key', 'oauth2'] }
+            },
             default: '',
             showWhen: { field: 'authType', value: 'credential' }
         },

+ 10 - 1
nodes/core/http-request.js

@@ -25,13 +25,22 @@ const configSchema = {
       type: 'string',
       title: 'Authentication',
       enum: ['none', 'credential'],
+      enumLabels: ['None', 'Stored credential'],
       default: 'none',
       description: 'Authentication method to use'
     },
     credentialId: {
       type: 'string',
       title: 'Credential',
-      description: 'Select a stored credential for authentication'
+      description: 'Select a stored credential for authentication',
+      // Read as a header, so any header-shaped credential works. An IMAP or
+      // database credential has no header to send and would only be an option
+      // that fails.
+      dynamicOptions: {
+        source: 'credentials',
+        filter: { type: ['basic', 'bearer', 'api_key', 'oauth2'] }
+      },
+      showWhen: { field: 'authType', value: 'credential' }
     },
     headers: {
       type: 'object',

+ 1 - 1
nodes/sdcpp/sdcpp-fetch-output.js

@@ -38,7 +38,7 @@ const configSchema = {
             description: 'A basic credential holding the sdcpp-restapi username and password',
             dynamicOptions: {
                 source: 'credentials',
-                filter: { type: ['basic'] }
+                filter: { type: ['sdcpp', 'basic'] }
             }
         },
         paths: {

+ 1 - 1
nodes/sdcpp/sdcpp-job-status.js

@@ -38,7 +38,7 @@ const configSchema = {
             description: 'A basic credential holding the sdcpp-restapi username and password',
             dynamicOptions: {
                 source: 'credentials',
-                filter: { type: ['basic'] }
+                filter: { type: ['sdcpp', 'basic'] }
             }
         },
         jobId: {

+ 1 - 1
nodes/sdcpp/sdcpp-job-wait.js

@@ -38,7 +38,7 @@ const configSchema = {
             description: 'A basic credential holding the sdcpp-restapi username and password',
             dynamicOptions: {
                 source: 'credentials',
-                filter: { type: ['basic'] }
+                filter: { type: ['sdcpp', 'basic'] }
             }
         },
         jobId: {

+ 8 - 7
webui/src/components/workflow/ArrayFieldEditor.tsx

@@ -1,4 +1,5 @@
 import { Plus, Trash2, AlertTriangle } from 'lucide-react'
+import { EnumSelect } from './EnumSelect'
 
 interface ArrayFieldEditorProps {
   fieldKey: string
@@ -64,13 +65,13 @@ export function ArrayFieldEditor({ fieldKey, prop, value, schema, onChange }: Ar
 
     if (Array.isArray(p?.enum)) {
       return (
-        <select className={inputClass} value={current ?? p.default ?? ''} onChange={(e) => write(e.target.value)}>
-          {p.enum.map((option: any, i: number) => (
-            <option key={String(option)} value={option}>
-              {p.enumLabels?.[i] || (option === '' ? '(none)' : String(option))}
-            </option>
-          ))}
-        </select>
+        <EnumSelect
+          prop={p}
+          value={current}
+          onChange={(v) => write(v)}
+          className={inputClass}
+          placeholder="(none)"
+        />
       )
     }
 

+ 25 - 29
webui/src/components/workflow/ConfiguratorFields.tsx

@@ -1,6 +1,10 @@
 import { useMemo, useState } from 'react'
 import { Plus, X, AlertTriangle, Link2 } from 'lucide-react'
 import { NodeDefinition } from '../../api/workflows'
+import { CredentialInfo } from '../../api/credentials'
+import { CredentialSelect } from './CredentialSelect'
+import { isCredentialKey } from './fieldGroups'
+import { EnumSelect } from './EnumSelect'
 
 export interface ConfigTarget {
   nodeId: string
@@ -21,7 +25,10 @@ interface ConfiguratorFieldsProps {
   nodeDefs: NodeDefinition[]
   // So a credential can be shown by the name it was saved under rather than as
   // the id it is stored as.
-  credentials: { id: string; name: string }[]
+  credentials: CredentialInfo[]
+  // Names for the types nodes registered, so a credential reads as what it is
+  // for rather than how it is stored.
+  credentialTypeLabels: Record<string, string>
   editingConfig: Record<string, any>
   onConfigChange: (config: Record<string, any>) => void
 }
@@ -46,6 +53,7 @@ export function ConfiguratorFields({
   targets,
   nodeDefs,
   credentials,
+  credentialTypeLabels,
   editingConfig,
   onConfigChange,
 }: ConfiguratorFieldsProps) {
@@ -124,41 +132,29 @@ export function ConfiguratorFields({
       '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'
 
     // A credential field lists the credentials by name. Typing an id here was
-    // never realistic, and reading one back is worse.
-    if (prop?.dynamicOptions?.source === 'credentials' || setting.name === 'credentialId') {
-      const wanted = prop?.dynamicOptions?.filter?.type
-      const choices = credentials.filter(
-        (c: any) => !Array.isArray(wanted) || wanted.includes(c.type)
-      )
+    // never realistic, and reading one back is worse. Same control as the node
+    // editor uses, so the filtering and the labels cannot drift apart.
+    if (isCredentialKey(setting.name, prop)) {
       return (
-        <select
-          className={common}
-          value={setting.value ?? ''}
-          onChange={(e) => setValue(index, e.target.value)}
-        >
-          <option value="">Choose a credential</option>
-          {choices.map((c) => (
-            <option key={c.id} value={c.id}>
-              {c.name}
-            </option>
-          ))}
-        </select>
+        <CredentialSelect
+          prop={prop}
+          value={setting.value}
+          credentials={credentials}
+          typeLabels={credentialTypeLabels}
+          onChange={(v) => setValue(index, v)}
+        />
       )
     }
 
     if (Array.isArray(prop?.enum)) {
       return (
-        <select
+        <EnumSelect
+          prop={prop}
+          value={setting.value}
+          onChange={(v) => setValue(index, v)}
           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>
+          placeholder="(leave to the node default)"
+        />
       )
     }
 

+ 103 - 0
webui/src/components/workflow/CredentialSelect.tsx

@@ -0,0 +1,103 @@
+import { AlertTriangle, KeyRound } from 'lucide-react'
+import { CredentialInfo } from '../../api/credentials'
+
+interface CredentialSelectProps {
+  prop: any
+  value: any
+  credentials: CredentialInfo[]
+  // Labels for the types nodes registered, so a credential reads as "SD.cpp
+  // server" rather than "basic".
+  typeLabels: Record<string, string>
+  onChange: (value: string) => void
+}
+
+/**
+ * The picker for a setting that holds a stored credential.
+ *
+ * A credential is kept as an id, and an id shown in a text box is unreadable -
+ * nobody knows which account "cred_9f2a..." is. Every node that takes one wants
+ * the same control, so it lives here rather than being declared node by node:
+ * a node that forgets to ask for a picker gets one anyway.
+ */
+
+/** Which stored types a field will accept, or null for any. */
+function wantedTypes(prop: any): string[] | null {
+  const filter = prop?.dynamicOptions?.filter?.type
+  if (!filter) return null
+  return Array.isArray(filter) ? filter : [filter]
+}
+
+export function credentialMatches(cred: CredentialInfo, prop: any): boolean {
+  const wanted = wantedTypes(prop)
+  if (!wanted) return true
+  // A filter naming a registered type also accepts the plain type it is stored
+  // as, and the other way round: "sdcpp" and "basic" hold exactly the same
+  // fields, so refusing one would make an existing credential unusable for no
+  // reason.
+  return (
+    wanted.includes(cred.type) ||
+    (!!cred.declaredType && wanted.includes(cred.declaredType))
+  )
+}
+
+export function CredentialSelect({
+  prop,
+  value,
+  credentials,
+  typeLabels,
+  onChange,
+}: CredentialSelectProps) {
+  const current = String(value ?? prop?.default ?? '')
+  const usable = credentials.filter((cred) => credentialMatches(cred, prop))
+  const label = (cred: CredentialInfo) =>
+    `${cred.name} (${typeLabels[cred.declaredType || cred.type] || cred.type})`
+
+  // A credential this node was set to but which is no longer offered - deleted,
+  // or of a type the field stopped accepting. A select whose value is not among
+  // its options renders blank, and the next save would write that blank away
+  // silently, so it is kept as an option and called out.
+  const orphaned = current !== '' && !usable.some((cred) => cred.id === current)
+  const known = credentials.find((cred) => cred.id === current)
+
+  return (
+    <div className="space-y-1.5">
+      <select
+        value={current}
+        onChange={(e) => onChange(e.target.value)}
+        className="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+      >
+        <option value="">Select credential...</option>
+        {usable.map((cred) => (
+          <option key={cred.id} value={cred.id}>
+            {label(cred)}
+          </option>
+        ))}
+        {orphaned && (
+          <option value={current}>
+            {known ? `${label(known)} - not offered here` : `${current} - no longer exists`}
+          </option>
+        )}
+      </select>
+
+      {orphaned && (
+        <div className="flex items-start gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
+          <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
+          {known
+            ? `This is a ${typeLabels[known.declaredType || known.type] || known.type} credential, which this field does not normally take. Kept so it is not lost.`
+            : 'The credential this was set to no longer exists. Pick another one.'}
+        </div>
+      )}
+
+      {usable.length === 0 && !orphaned && (
+        <div className="flex items-start gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
+          <KeyRound className="w-3.5 h-3.5 shrink-0 mt-px" />
+          {credentials.length === 0
+            ? 'No credentials stored yet. Add one under Credentials.'
+            : `Nothing stored of the kind this needs${
+                wantedTypes(prop) ? ` (${wantedTypes(prop)!.join(' or ')})` : ''
+              }. Add one under Credentials.`}
+        </div>
+      )}
+    </div>
+  )
+}

+ 83 - 0
webui/src/components/workflow/EnumSelect.tsx

@@ -0,0 +1,83 @@
+interface EnumSelectProps {
+  prop: any
+  value: any
+  onChange: (value: string) => void
+  className?: string
+  // Shown as the first entry when nothing is chosen yet.
+  placeholder?: string
+}
+
+/**
+ * The control for a setting that is one of a fixed set of values.
+ *
+ * A node can name its options with enumLabels. Most do not, and then the stored
+ * value is shown as-is - which is fine for GET or UTF-8 and poor for
+ * "stopAndError" or "client_credentials". Rather than ask forty nodes to
+ * declare a label each, an unlabelled value is made readable here, and a node
+ * that wants different wording still wins by declaring enumLabels.
+ */
+
+/**
+ * A stored value as a person would write it.
+ *
+ * Only values that are already all lower case are touched: anything carrying
+ * capitals is a name someone chose - GET, UTF-8, DPM++ - and rewriting those
+ * makes them worse, not better.
+ */
+// Words that are shouted, not capitalised. Without these, "json" comes out as
+// "Json", which reads as a name rather than a format.
+const ACRONYMS = new Set([
+  'ai', 'api', 'csv', 'html', 'http', 'https', 'id', 'imap', 'json', 'jwt',
+  'ocr', 'pdf', 'rss', 'smtp', 'sql', 'ssl', 'tls', 'ttl', 'uid', 'url',
+  'utc', 'uuid', 'xml', 'yaml',
+])
+
+export function humaniseOption(value: string): string {
+  if (value === '') return ''
+  if (/[A-Z]/.test(value)) return value
+  return value
+    .replace(/[_-]+/g, ' ')
+    .trim()
+    .split(/\s+/)
+    .map((word) =>
+      ACRONYMS.has(word) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1)
+    )
+    .join(' ')
+}
+
+/** What to show for one option of an enum, honouring the node's own labels. */
+export function optionLabel(prop: any, value: any, index: number): string {
+  const declared = prop?.enumLabels?.[index]
+  if (declared) return String(declared)
+  const raw = String(value ?? '')
+  // An empty option means "leave it to whatever the node does by default", and
+  // an empty menu entry looks like a rendering fault.
+  if (raw === '') return '(node default)'
+  return humaniseOption(raw)
+}
+
+export function EnumSelect({ prop, value, onChange, className, placeholder }: EnumSelectProps) {
+  const options: any[] = Array.isArray(prop?.enum) ? prop.enum : []
+  const current = value ?? prop?.default ?? ''
+  // Only offer "nothing chosen" while nothing is chosen. Leaving it in the list
+  // afterwards invites clearing a required setting by accident.
+  const showPlaceholder = current === '' && !options.includes('')
+
+  return (
+    <select
+      value={current}
+      onChange={(e) => onChange(e.target.value)}
+      className={
+        className ||
+        'w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500'
+      }
+    >
+      {showPlaceholder && <option value="">{placeholder || 'Select...'}</option>}
+      {options.map((option, index) => (
+        <option key={String(option)} value={option}>
+          {optionLabel(prop, option, index)}
+        </option>
+      ))}
+    </select>
+  )
+}

+ 128 - 0
webui/src/components/workflow/KeyValueEditor.tsx

@@ -0,0 +1,128 @@
+import { useEffect, useRef, useState } from 'react'
+import { Plus, Trash2, AlertTriangle } from 'lucide-react'
+
+interface KeyValueEditorProps {
+  prop: any
+  value: any
+  onChange: (next: Record<string, string>) => void
+}
+
+/**
+ * Editor for a setting that holds a map of names to values - HTTP headers being
+ * the one everybody meets.
+ *
+ * These used to be a textarea labelled "Object configuration: Use JSON format",
+ * so setting one header meant typing braces and quotes correctly, and a stray
+ * comma was dropped in silence.
+ *
+ * Only used where the schema says the values are strings. A map that carries
+ * numbers or nested objects - an options passthrough, a JSON Schema - is still
+ * edited as JSON, because a row of text boxes would quietly turn 7 into "7".
+ */
+function rowsFrom(value: any): [string, string][] {
+  return Object.entries(
+    value && typeof value === 'object' && !Array.isArray(value) ? value : {}
+  ).map(([k, v]) => [k, v == null ? '' : String(v)])
+}
+
+export function KeyValueEditor({ prop, value, onChange }: KeyValueEditorProps) {
+  // The rows are held here rather than read back from the value each render,
+  // because a half-typed row has no name yet and a nameless entry cannot exist
+  // in an object - it would be dropped on the way out and the new row would
+  // vanish as soon as it was added.
+  const [rows, setRows] = useState<[string, string][]>(() => rowsFrom(value))
+  const emitted = useRef<string>(JSON.stringify(value ?? {}))
+
+  // Follow the value when it changes underneath - switching to another node, or
+  // a config arriving from elsewhere - but not in response to our own writes.
+  useEffect(() => {
+    const incoming = JSON.stringify(value ?? {})
+    if (incoming !== emitted.current) {
+      emitted.current = incoming
+      setRows(rowsFrom(value))
+    }
+  }, [value])
+
+  const entries = rows
+
+  const write = (next: [string, string][]) => {
+    setRows(next)
+    const out: Record<string, string> = {}
+    for (const [k, v] of next) {
+      if (k.trim() !== '') out[k] = v
+    }
+    emitted.current = JSON.stringify(out)
+    onChange(out)
+  }
+
+  const inputClass =
+    'w-full px-2 py-1.5 border border-gray-300 dark:border-slate-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 text-sm'
+
+  // A name typed twice would silently keep only the last value.
+  const seen = new Set<string>()
+  const duplicates = new Set<string>()
+  for (const [k] of entries) {
+    if (k !== '' && seen.has(k)) duplicates.add(k)
+    seen.add(k)
+  }
+
+  return (
+    <div className="space-y-2">
+      {entries.length === 0 && (
+        <div className="text-sm text-gray-500 dark:text-gray-400">
+          {prop?.placeholder || 'Nothing set.'}
+        </div>
+      )}
+
+      {entries.map(([key, val], index) => (
+        <div key={index} className="flex items-start gap-2">
+          <input
+            type="text"
+            className={`${inputClass} flex-1 ${
+              duplicates.has(key) ? 'border-amber-400 dark:border-amber-500' : ''
+            }`}
+            value={key}
+            placeholder="Name"
+            onChange={(e) =>
+              write(entries.map((entry, i) => (i === index ? [e.target.value, entry[1]] : entry)))
+            }
+          />
+          <input
+            type="text"
+            className={`${inputClass} flex-[2]`}
+            value={val}
+            placeholder="Value"
+            onChange={(e) =>
+              write(entries.map((entry, i) => (i === index ? [entry[0], e.target.value] : entry)))
+            }
+          />
+          <button
+            type="button"
+            onClick={() => write(entries.filter((_, i) => i !== index))}
+            title="Remove"
+            className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded shrink-0"
+          >
+            <Trash2 className="w-4 h-4" />
+          </button>
+        </div>
+      ))}
+
+      {duplicates.size > 0 && (
+        <div className="flex items-start gap-1.5 text-[11px] text-amber-600 dark:text-amber-400">
+          <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
+          {[...duplicates].join(', ')} appears more than once. Only the last one
+          will be sent.
+        </div>
+      )}
+
+      <button
+        type="button"
+        onClick={() => write([...entries, ['', '']])}
+        className="flex items-center gap-2 px-3 py-1.5 text-sm text-primary-700 dark:text-primary-300 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800"
+      >
+        <Plus className="w-4 h-4" />
+        Add
+      </button>
+    </div>
+  )
+}

+ 31 - 53
webui/src/components/workflow/NodeConfigModal.tsx

@@ -6,6 +6,9 @@ import { useTheme } from '../../contexts/ThemeContext'
 import { ConfiguratorFields, ConfigTarget } from './ConfiguratorFields'
 import { NodeOptionsSelect } from './NodeOptionsSelect'
 import { groupFields, isCredentialKey } from './fieldGroups'
+import { CredentialSelect } from './CredentialSelect'
+import { EnumSelect } from './EnumSelect'
+import { KeyValueEditor } from './KeyValueEditor'
 import { ArrayFieldEditor } from './ArrayFieldEditor'
 import { NodeDefinition, workflowsApi, nodesApi } from '../../api/workflows'
 import { credentialsApi, CredentialInfo } from '../../api/credentials'
@@ -480,6 +483,7 @@ export function NodeConfigModal({
                   targets={configTargets}
                   nodeDefs={nodeDefs}
                   credentials={credentials}
+                  credentialTypeLabels={credentialTypeLabels}
                   editingConfig={editingConfig}
                   onConfigChange={onConfigChange}
                 />
@@ -716,59 +720,24 @@ export function NodeConfigModal({
                             </option>
                           ))}
                       </select>
-                    ) : prop.dynamicOptions?.source === 'credentials' ? (
-                      /* Dynamic credential selector */
-                      <select
-                        value={editingConfig[key] ?? prop.default ?? ''}
-                        onChange={(e) =>
-                          onConfigChange({ ...editingConfig, [key]: e.target.value })
-                        }
-                        className="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
-                      >
-                        <option value="">Select credential...</option>
-                        {credentials
-                          .filter(cred => {
-                            // Filter by credential type if specified. A node that
-                            // works with more than one type lists them all, so an
-                            // account stored once can serve both.
-                            // A filter naming a registered type also accepts
-                            // the plain credential it is stored as, and the
-                            // other way round: "sdcpp" and "basic" hold exactly
-                            // the same fields, so refusing one would only make
-                            // an existing credential unusable for no reason.
-                            const typeFilter = prop.dynamicOptions?.filter?.type
-                            if (!typeFilter) return true
-                            const wanted = Array.isArray(typeFilter) ? typeFilter : [typeFilter]
-                            return (
-                              wanted.includes(cred.type) ||
-                              (!!cred.declaredType && wanted.includes(cred.declaredType))
-                            )
-                          })
-                          .map((cred) => (
-                            <option key={cred.id} value={cred.id}>
-                              {cred.name} ({credentialTypeLabels[cred.declaredType || cred.type] || cred.type})
-                            </option>
-                          ))}
-                      </select>
+                    ) : isCredentialKey(key, prop) ? (
+                      /* Any setting that holds a stored credential, whether or
+                         not the node asked for a picker - an id in a text box
+                         is unreadable, and forgetting the declaration should
+                         not be how that happens. */
+                      <CredentialSelect
+                        prop={prop}
+                        value={editingConfig[key]}
+                        credentials={credentials}
+                        typeLabels={credentialTypeLabels}
+                        onChange={(v) => onConfigChange({ ...editingConfig, [key]: v })}
+                      />
                     ) : prop.enum ? (
-                      <select
-                        value={editingConfig[key] ?? prop.default ?? ''}
-                        onChange={(e) =>
-                          onConfigChange({ ...editingConfig, [key]: e.target.value })
-                        }
-                        className="w-full px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
-                      >
-                        {/* Only when the schema has not given "" a meaning of
-                            its own. Several settings use it for "auto" or
-                            "server default", and a placeholder above that would
-                            take its place and hide the label explaining it. */}
-                        {!prop.enum.includes('') && <option value="">Select...</option>}
-                        {prop.enum.map((opt: string, idx: number) => (
-                          <option key={opt} value={opt}>
-                            {prop.enumLabels?.[idx] || opt}
-                          </option>
-                        ))}
-                      </select>
+                      <EnumSelect
+                        prop={prop}
+                        value={editingConfig[key]}
+                        onChange={(v) => onConfigChange({ ...editingConfig, [key]: v })}
+                      />
                     ) : prop.type === 'array' ? (
                       <ArrayFieldEditor
                         fieldKey={key}
@@ -777,9 +746,18 @@ export function NodeConfigModal({
                         schema={nodeDef}
                         onChange={(next) => onConfigChange({ ...editingConfig, [key]: next })}
                       />
+                    ) : prop.type === 'object' && prop.additionalProperties?.type === 'string' ? (
+                      <KeyValueEditor
+                        prop={prop}
+                        value={editingConfig[key]}
+                        onChange={(next) => onConfigChange({ ...editingConfig, [key]: next })}
+                      />
                     ) : prop.type === 'object' ? (
+                      /* Anything else in here carries numbers, booleans or
+                         nesting - an options passthrough, a JSON Schema - and a
+                         row of text boxes would quietly turn 7 into "7". */
                       <div className="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-slate-900 px-3 py-2 rounded">
-                        Object configuration: Use JSON format
+                        Written as JSON
                         <textarea
                           value={JSON.stringify(editingConfig[key] || {}, null, 2)}
                           onChange={(e) => {