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