Bläddra i källkod

feat: browse a node's choices in the editor instead of typing them

A field can now declare dynamicOptions with source "node", and the editor
offers a Browse button that reads the real list from the service. SD.cpp Model
uses it: 40 checkpoints, 24 upscalers, 186 loras, whatever the server has,
rather than a text box that only accepts an exactly-typed file name.

The list comes from the node-options endpoint, so it runs the node itself with
its own credential. That is the only way to reach a service whose
authentication is more than a fixed header.

It resolves the config through the Configurator. The server address and
credential a listing needs usually live on a Configurator rather than on the
node being edited, so what gets sent is the supplied values with the node's own
on top - the same precedence the run itself uses. Without that the Browse
button would report a missing credential on a node that runs perfectly well.

Details that matter in use:

- Nothing loads until Browse is pressed. Opening a node should not fire a
  request at someone's GPU server, and a config form that pauses on open feels
  broken.
- The value stays typeable. A model added after the list was read, or an
  expression, are both legitimate values.
- A filter box appears with the list, because 186 loras is not a scrollable
  list.
- When a needed setting is missing the button says which one, rather than
  failing with whatever the service returns for an unauthenticated request.
- A failure shows the node's own error - "no credential", "server refused" -
  which the endpoint passes through as the response body.

Verified in the browser against the live server: Browse listed all 40
checkpoints, filtering worked, and choosing SD1x/photon_v1.safetensors wrote it
into the field. Full suite 55/55.
fszontagh 1 månad sedan
förälder
incheckning
6bf4ccecdd

+ 10 - 0
webui/src/api/workflows.ts

@@ -231,6 +231,16 @@ export const nodesApi = {
     await api.delete(`/nodes/${id}`)
     await api.delete(`/nodes/${id}`)
   },
   },
 
 
+  /**
+   * Ask a node what choices it can offer for a field, by running it with the
+   * config the editor currently holds. Nothing is saved - the node runs once on
+   * a runner, with its own credential, and its output comes back.
+   */
+  options: async (nodeType: string, workflowId: string, config: Record<string, any>) => {
+    const response = await api.post(`/nodes/${nodeType}/options`, { workflowId, config })
+    return response.data?.output ?? {}
+  },
+
   reload: async (id: string) => {
   reload: async (id: string) => {
     const response = await api.post(`/nodes/${id}/reload`)
     const response = await api.post(`/nodes/${id}/reload`)
     return response.data
     return response.data

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

@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'
 import Editor from '@monaco-editor/react'
 import Editor from '@monaco-editor/react'
 import { useTheme } from '../../contexts/ThemeContext'
 import { useTheme } from '../../contexts/ThemeContext'
 import { ConfiguratorFields, ConfigTarget } from './ConfiguratorFields'
 import { ConfiguratorFields, ConfigTarget } from './ConfiguratorFields'
+import { NodeOptionsSelect } from './NodeOptionsSelect'
 import { NodeDefinition } from '../../api/workflows'
 import { NodeDefinition } from '../../api/workflows'
 import { credentialsApi, CredentialInfo } from '../../api/credentials'
 import { credentialsApi, CredentialInfo } from '../../api/credentials'
 import { ConditionBuilder, Condition, AvailableField } from '../ConditionBuilder'
 import { ConditionBuilder, Condition, AvailableField } from '../ConditionBuilder'
@@ -33,6 +34,9 @@ interface NodeConfigModalProps {
   // name. Those fields are shown filled in and disabled: editing them here
   // name. Those fields are shown filled in and disabled: editing them here
   // would be silently discarded at run time.
   // would be silently discarded at run time.
   suppliedConfig: Record<string, { value: any; from: string }>
   suppliedConfig: Record<string, { value: any; from: string }>
+  // Needed to ask a node for its choices: it decides which credentials that
+  // question may use.
+  workflowId: string
   editingConfig: Record<string, any>
   editingConfig: Record<string, any>
   nodeDefs: NodeDefinition[]
   nodeDefs: NodeDefinition[]
   storageCollections: StorageCollection[]
   storageCollections: StorageCollection[]
@@ -49,6 +53,7 @@ export function NodeConfigModal({
   selectedNodeData,
   selectedNodeData,
   configTargets,
   configTargets,
   suppliedConfig,
   suppliedConfig,
+  workflowId,
   editingConfig,
   editingConfig,
   nodeDefs,
   nodeDefs,
   storageCollections,
   storageCollections,
@@ -466,6 +471,24 @@ export function NodeConfigModal({
                         placeholder={prop.description}
                         placeholder={prop.description}
                         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"
                         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"
                       />
                       />
+                    ) : prop.dynamicOptions?.source === 'node' ? (
+                      <NodeOptionsSelect
+                        fieldKey={key}
+                        prop={prop}
+                        value={editingConfig[key]}
+                        nodeType={selectedNodeData.type}
+                        workflowId={workflowId}
+                        // A Configurator's values count as the node's own here:
+                        // the server address and credential this list is read
+                        // from often live there rather than on the node.
+                        effectiveConfig={{
+                          ...Object.fromEntries(
+                            Object.entries(suppliedConfig).map(([k, v]) => [k, v.value])
+                          ),
+                          ...editingConfig,
+                        }}
+                        onChange={(next) => onConfigChange({ ...editingConfig, [key]: next })}
+                      />
                     ) : prop.dynamicOptions?.source === 'storage.collections' ? (
                     ) : prop.dynamicOptions?.source === 'storage.collections' ? (
                       /* Dynamic collection selector */
                       /* Dynamic collection selector */
                       <select
                       <select

+ 195 - 0
webui/src/components/workflow/NodeOptionsSelect.tsx

@@ -0,0 +1,195 @@
+import { useState } from 'react'
+import { Loader2, RefreshCw, Search, AlertTriangle, List } from 'lucide-react'
+import { nodesApi } from '../../api/workflows'
+
+interface DynamicNodeOptions {
+  source: 'node'
+  // Which node type answers the question. Defaults to the node being edited,
+  // which is the usual case - a node that knows its own service.
+  node?: string
+  // Merged over the effective config when asking, to tell the node it is being
+  // asked rather than run: { listOnly: true }.
+  config?: Record<string, any>
+  // Where the list sits in the node's output, and which keys to read from each
+  // entry.
+  itemsPath?: string
+  valueKey?: string
+  labelKey?: string
+  // Settings the node cannot answer without. Named so the reason for not
+  // loading can say which one is missing instead of failing obscurely.
+  needs?: string[]
+}
+
+interface NodeOptionsSelectProps {
+  fieldKey: string
+  prop: any
+  value: any
+  nodeType: string
+  workflowId: string
+  // The node's own config plus anything a connected Configurator supplies, so
+  // a server address and credential that live on a Configurator still reach
+  // the node being asked.
+  effectiveConfig: Record<string, any>
+  onChange: (value: string) => void
+}
+
+interface Choice {
+  value: string
+  label: string
+}
+
+export function NodeOptionsSelect({
+  prop,
+  value,
+  nodeType,
+  workflowId,
+  effectiveConfig,
+  onChange,
+}: NodeOptionsSelectProps) {
+  const dynamic = (prop?.dynamicOptions || {}) as DynamicNodeOptions
+  const [choices, setChoices] = useState<Choice[] | null>(null)
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState('')
+  const [filter, setFilter] = useState('')
+
+  const missing = (dynamic.needs || []).filter((key) => {
+    const v = effectiveConfig[key]
+    return v === undefined || v === null || v === ''
+  })
+
+  const load = async () => {
+    setLoading(true)
+    setError('')
+    try {
+      const askConfig = { ...effectiveConfig, ...(dynamic.config || {}) }
+      const output = await nodesApi.options(dynamic.node || nodeType, workflowId, askConfig)
+
+      const items = output?.[dynamic.itemsPath || 'items']
+      if (!Array.isArray(items)) {
+        setChoices([])
+        setError('The node did not return a list')
+        return
+      }
+
+      const valueKey = dynamic.valueKey || 'value'
+      const labelKey = dynamic.labelKey || valueKey
+      setChoices(
+        items.map((item: any) =>
+          item && typeof item === 'object'
+            ? { value: String(item[valueKey] ?? ''), label: String(item[labelKey] ?? item[valueKey] ?? '') }
+            : { value: String(item), label: String(item) }
+        ).filter((c: Choice) => c.value !== '')
+      )
+    } catch (e: any) {
+      // The node's own error is the useful one - "no credential", "server
+      // refused" - and the endpoint passes it through as the response body.
+      const detail = e?.response?.data?.error || e?.message || 'Could not read the list'
+      setError(detail)
+      setChoices([])
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const shown = (choices || []).filter((c) =>
+    filter ? c.label.toLowerCase().includes(filter.toLowerCase()) : true
+  )
+
+  const inputClass =
+    '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'
+
+  return (
+    <div className="space-y-2">
+      <div className="flex gap-2">
+        {/* The value stays typeable. A model can be added to the server after
+            this list was read, and an expression is a legitimate value here. */}
+        <input
+          type="text"
+          value={value ?? ''}
+          onChange={(e) => onChange(e.target.value)}
+          placeholder={prop?.placeholder || 'Choose from the list, or type a name'}
+          className={inputClass}
+        />
+        <button
+          type="button"
+          onClick={load}
+          disabled={loading || missing.length > 0}
+          title={
+            missing.length > 0
+              ? `Fill in ${missing.join(' and ')} first - the list is read from there`
+              : 'Read the list from the server'
+          }
+          className="shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-violet-200 dark:border-violet-800 text-violet-700 dark:text-violet-300 hover:bg-violet-50 dark:hover:bg-violet-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
+        >
+          {loading ? (
+            <Loader2 className="w-4 h-4 animate-spin" />
+          ) : choices ? (
+            <RefreshCw className="w-4 h-4" />
+          ) : (
+            <List className="w-4 h-4" />
+          )}
+          {choices ? 'Refresh' : 'Browse'}
+        </button>
+      </div>
+
+      {missing.length > 0 && (
+        <div className="flex items-start gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
+          <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
+          Fill in {missing.join(' and ')} first - the list is read from there. A
+          Configurator supplying them counts.
+        </div>
+      )}
+
+      {error && (
+        <div className="flex items-start gap-1.5 text-[11px] text-red-600 dark:text-red-400">
+          <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
+          <span className="break-words">{error}</span>
+        </div>
+      )}
+
+      {choices && choices.length > 0 && (
+        <div className="rounded-lg border border-gray-200 dark:border-slate-700">
+          <div className="flex items-center gap-2 px-2 py-1.5 border-b border-gray-200 dark:border-slate-700">
+            <Search className="w-3.5 h-3.5 text-gray-400" />
+            <input
+              type="text"
+              value={filter}
+              onChange={(e) => setFilter(e.target.value)}
+              placeholder={`Filter ${choices.length}`}
+              className="flex-1 bg-transparent text-sm text-gray-900 dark:text-gray-100 outline-none"
+            />
+            <span className="text-[11px] text-gray-400">{shown.length}</span>
+          </div>
+          <div className="max-h-56 overflow-auto">
+            {shown.length === 0 ? (
+              <div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
+                Nothing matches "{filter}"
+              </div>
+            ) : (
+              shown.map((choice) => (
+                <button
+                  key={choice.value}
+                  type="button"
+                  onClick={() => onChange(choice.value)}
+                  className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-slate-700 ${
+                    choice.value === value
+                      ? 'bg-violet-50 dark:bg-violet-900/20 text-violet-800 dark:text-violet-200'
+                      : 'text-gray-800 dark:text-gray-200'
+                  }`}
+                >
+                  {choice.label}
+                </button>
+              ))
+            )}
+          </div>
+        </div>
+      )}
+
+      {choices && choices.length === 0 && !error && (
+        <div className="text-[11px] text-gray-500 dark:text-gray-400">
+          The server has nothing of this kind.
+        </div>
+      )}
+    </div>
+  )
+}

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

@@ -2290,6 +2290,7 @@ function WorkflowEditorInner() {
       {showNodeConfig && selectedNodeData && (
       {showNodeConfig && selectedNodeData && (
         <NodeConfigModal
         <NodeConfigModal
           selectedNodeData={selectedNodeData}
           selectedNodeData={selectedNodeData}
+          workflowId={id!}
           suppliedConfig={
           suppliedConfig={
             // What a connected Configurator will supply to the node being
             // What a connected Configurator will supply to the node being
             // edited. Read from the canvas because the connection, not the
             // edited. Read from the canvas because the connection, not the