Procházet zdrojové kódy

feat: a button that fills a node in from what the server has loaded

I built the wrong thing before. SD.cpp Model grew a runtime mode that followed
whatever the server had loaded, which put the answer somewhere the workflow
could not show, and left two nodes that looked like they overlapped.

What was asked for is a button. A node can now declare one:

  prefill: {
    label: 'Take from the server',
    node: 'sdcpp-health',
    needs: ['serverUrl'],
    map: { modelName: 'modelName', 'loadedComponents.vae': 'vae', ... }
  }

Pressing it runs the named node - through the same options endpoint the Browse
lists use, so it goes out with the node's own credential - and writes the answer
into the settings, where it can then be edited. The node still says exactly what
it will run with; the button only saves typing it.

A value the server does not have is left alone rather than written as empty,
which would blank something already set. The button names the settings it
filled, and refuses with a reason when it has no address to read from. It reads
that address through a connected Configurator, as everything else here does.

The runtime follow-the-loaded-model mode is gone, and the two nodes now mean
distinct things: SD.cpp Model chooses a model and hands it to another node,
SD.cpp Load Model makes sure one is loaded.

The idempotency fixture used that removed mode. It now reads the loaded model
through SD.cpp Health and passes it to Load Model by expression, which tests the
same thing without naming a model the server may not have.

Verified in the browser against the live server: Fill in on SD.cpp Load Model
reported "Filled in llm, vae, modelName, modelType" and set Model to
z_image_turbo_bf16.safetensors, Model Type to diffusion, VAE to ae.safetensors
and LLM to Qwen3-4b-Z-Engineer-V2.gguf. Full suite 57/57.
fszontagh před 1 měsícem
rodič
revize
ae0dbac4e3

+ 21 - 0
nodes/sdcpp/sdcpp-model-load.js

@@ -25,6 +25,27 @@ const credentialTypes = [
 
 const configSchema = {
     type: 'object',
+    // Pressing this asks the server what it has loaded and writes it into the
+    // settings below - the model and every component it reports - so a workflow
+    // can be built from a server that is already set up the way it should be,
+    // rather than by typing the same names again.
+    prefill: {
+        label: 'Take from the server',
+        description: 'Fill these in from the model the server currently has loaded',
+        node: 'sdcpp-health',
+        needs: ['serverUrl'],
+        map: {
+            modelName: 'modelName',
+            modelType: 'modelType',
+            'loadedComponents.vae': 'vae',
+            'loadedComponents.clip_l': 'clipL',
+            'loadedComponents.clip_g': 'clipG',
+            'loadedComponents.t5xxl': 't5xxl',
+            'loadedComponents.llm': 'llm',
+            'loadedComponents.taesd': 'taesd',
+            'loadedComponents.controlnet': 'controlnet'
+        }
+    },
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',

+ 20 - 57
nodes/sdcpp/sdcpp-model.js

@@ -25,6 +25,19 @@ const credentialTypes = [
 
 const configSchema = {
     type: 'object',
+    // Offered as a button in the editor. Pressing it asks the server what it
+    // has loaded and writes those values into the settings below, where they
+    // can then be changed. It fills the form in; it does not decide anything at
+    // run time, so what the workflow will do stays visible on the node.
+    prefill: {
+        label: 'Take from the server',
+        description: 'Fill these in from the model the server currently has loaded',
+        node: 'sdcpp-health',
+        needs: ['serverUrl'],
+        map: {
+            modelName: 'modelName'
+        }
+    },
     properties: {
         serverUrl: {
             type: 'string', title: 'Server URL',
@@ -36,30 +49,15 @@ const configSchema = {
             description: 'A basic credential holding the sdcpp-restapi username and password. Listing models needs one',
             dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
-        source: {
-            type: 'string', title: 'Model',
-            enum: ['choose', 'loaded'],
-            enumLabels: ['Pick one from the server', 'Whatever is loaded right now'],
-            default: 'choose',
-            description: 'Pick one, or take the model the server currently has loaded. Taking the loaded one keeps a workflow portable between servers, and means it never spends minutes swapping a model just to match what a node was set to'
-        },
-        includeComponents: {
-            type: 'boolean', title: 'Include Components',
-            default: false,
-            showWhen: { field: 'source', value: 'loaded' },
-            description: 'Also supply the VAE, CLIP, T5, LLM and TAESD the server has loaded, not only the model name. Only useful for a diffusion model, which loads its parts separately'
-        },
         modelType: {
             type: 'string', title: 'Kind',
-            showWhen: { field: 'source', value: 'choose' },
             enum: ['checkpoint', 'diffusion', 'vae', 'lora', 'clip', 't5', 'embedding',
                    'controlnet', 'llm', 'esrgan', 'taesd', 'motion_module', 'adetailer'],
             default: 'checkpoint',
             description: 'Which kind of model to choose from. esrgan is the upscaler kind'
         },
         modelName: {
-            type: 'string', title: 'Choose',
-            showWhen: { field: 'source', value: 'choose' },
+            type: 'string', title: 'Model',
             description: 'Chosen from what the server has. Change the Kind above to list a different sort',
             dynamicOptions: {
                 source: 'node',
@@ -88,8 +86,6 @@ const outputSchema = {
         _config: { type: 'object', description: 'The setting this supplies, as the engine consumes it' },
         modelName: { type: 'string', description: 'The chosen model' },
         modelType: { type: 'string' },
-        architecture: { type: 'string', description: 'What the server detected, when following the loaded model' },
-        loadedComponents: { type: 'object', description: 'The components the server has loaded, when following it' },
         setting: { type: 'string', description: 'Which setting it was supplied as' },
         models: { type: 'array', description: 'Everything of this kind the server has. Only filled when listing' }
     }
@@ -286,45 +282,14 @@ async function execute(config, input, context) {
         };
     }
 
-    let modelName;
-    let architecture = '';
-    let loadedComponents = {};
-    const values = {};
-
-    if ((config.source || 'choose') === 'loaded') {
-        // /health is unauthenticated, so this works before any credential is
-        // set up - useful when the point is to follow whatever the server has.
-        const health = readHealth(server, Math.min(timeout, 15000));
-        if (health.model_loaded !== true || !health.model_name) {
-            throw new Error('SD.cpp Model: this node is set to use the model the server has ' +
-                'loaded, but ' + server + ' has none. Load one first, or choose a model here');
-        }
-        modelName = health.model_name;
-        architecture = health.model_architecture || '';
-        loadedComponents = health.loaded_components || {};
-
-        if (config.includeComponents === true) {
-            for (let i = 0; i < COMPONENT_SETTINGS.length; i++) {
-                const entry = COMPONENT_SETTINGS[i];
-                for (let k = 0; k < entry.keys.length; k++) {
-                    const found = loadedComponents[entry.keys[k]];
-                    // A component the server does not have comes back empty or
-                    // absent; supplying that would blank the target's own value.
-                    if (found) {
-                        values[entry.setting] = found;
-                        break;
-                    }
-                }
-            }
-        }
-    } else {
-        modelName = String(config.modelName || '').trim();
-        if (!modelName) {
-            throw new Error('SD.cpp Model: choose a model. Pick one from the list, which is read ' +
-                'from the server named above');
-        }
+    const modelName = String(config.modelName || '').trim();
+    if (!modelName) {
+        throw new Error('SD.cpp Model: choose a model. Pick one from the list, which is read ' +
+            'from the server named above');
     }
 
+    const values = {};
+
     const setting = String(config.setting || '').trim() ||
                     SETTING_FOR_KIND[modelType] || 'modelName';
 
@@ -338,8 +303,6 @@ async function execute(config, input, context) {
         _config: values,
         modelName: modelName,
         modelType: modelType,
-        architecture: architecture,
-        loadedComponents: loadedComponents,
         setting: setting,
         models: []
     };

+ 0 - 68
tests/nodes/sdcpp-model-follow-loaded.json

@@ -1,68 +0,0 @@
-{
-  "name": "verify-sdcpp-model-follow-loaded",
-  "nodes": [
-    {
-      "id": "n1",
-      "name": "Trigger",
-      "type": "click-trigger",
-      "position": {
-        "x": 0,
-        "y": 0
-      },
-      "config": {}
-    },
-    {
-      "id": "pick",
-      "name": "Model",
-      "type": "sdcpp-model",
-      "position": {
-        "x": -200,
-        "y": 100
-      },
-      "config": {
-        "serverUrl": "http://mulan:8077",
-        "source": "loaded"
-      }
-    },
-    {
-      "id": "load",
-      "name": "Load",
-      "type": "sdcpp-model-load",
-      "position": {
-        "x": 0,
-        "y": 250
-      },
-      "config": {
-        "serverUrl": "http://mulan:8077",
-        "credentialId": "cred_62748b40-e9d1-4659-b175-6dbc691cc0b1"
-      }
-    }
-  ],
-  "connections": [
-    {
-      "sourceNodeId": "n1",
-      "sourceOutput": "main",
-      "targetNodeId": "load",
-      "targetInput": "data"
-    },
-    {
-      "sourceNodeId": "pick",
-      "sourceOutput": "main",
-      "targetNodeId": "load",
-      "targetInput": "config"
-    }
-  ],
-  "expectStatus": "completed",
-  "expect": {
-    "pick": {
-      "status": "completed"
-    },
-    "load": {
-      "status": "completed",
-      "output": {
-        "alreadyLoaded": true,
-        "loaded": false
-      }
-    }
-  }
-}

+ 16 - 86
tests/nodes/sdcpp-model-load-idempotent.json

@@ -1,95 +1,25 @@
 {
   "name": "verify-sdcpp-model-load-idempotent",
   "nodes": [
-    {
-      "id": "n1",
-      "name": "Trigger",
-      "type": "click-trigger",
-      "position": {
-        "x": 0,
-        "y": 0
-      },
-      "config": {}
-    },
-    {
-      "id": "srv",
-      "name": "SD.cpp Server",
-      "type": "configurator",
-      "position": {
-        "x": -260,
-        "y": 100
-      },
-      "config": {
-        "label": "SD.cpp Server",
-        "settings": [
-          {
-            "name": "serverUrl",
-            "value": "http://mulan:8077"
-          },
-          {
-            "name": "credentialId",
-            "value": "cred_62748b40-e9d1-4659-b175-6dbc691cc0b1"
-          }
-        ]
-      }
-    },
-    {
-      "id": "pick",
-      "name": "Model",
-      "type": "sdcpp-model",
-      "position": {
-        "x": -260,
-        "y": 220
-      },
-      "config": {
-        "source": "loaded"
-      }
-    },
-    {
-      "id": "load",
-      "name": "Load",
-      "type": "sdcpp-model-load",
-      "position": {
-        "x": 0,
-        "y": 320
-      },
-      "config": {}
-    }
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "srv", "name": "SD.cpp Server", "type": "configurator", "position": {"x": -260, "y": 100},
+     "config": {"label": "SD.cpp Server", "settings": [
+       {"name": "serverUrl", "value": "http://mulan:8077"},
+       {"name": "credentialId", "value": "cred_62748b40-e9d1-4659-b175-6dbc691cc0b1"}
+     ]}},
+    {"id": "health", "name": "Health", "type": "sdcpp-health", "position": {"x": 0, "y": 200},
+     "config": {}},
+    {"id": "load", "name": "Load", "type": "sdcpp-model-load", "position": {"x": 0, "y": 320},
+     "config": {"modelName": "{{$node[\"Health\"].modelName}}"}}
   ],
   "connections": [
-    {
-      "sourceNodeId": "n1",
-      "sourceOutput": "main",
-      "targetNodeId": "load",
-      "targetInput": "data"
-    },
-    {
-      "sourceNodeId": "srv",
-      "sourceOutput": "main",
-      "targetNodeId": "pick",
-      "targetInput": "config"
-    },
-    {
-      "sourceNodeId": "srv",
-      "sourceOutput": "main",
-      "targetNodeId": "load",
-      "targetInput": "config"
-    },
-    {
-      "sourceNodeId": "pick",
-      "sourceOutput": "main",
-      "targetNodeId": "load",
-      "targetInput": "config"
-    }
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "health", "targetInput": "data"},
+    {"sourceNodeId": "health", "sourceOutput": "main", "targetNodeId": "load", "targetInput": "data"},
+    {"sourceNodeId": "srv", "sourceOutput": "main", "targetNodeId": "health", "targetInput": "config"},
+    {"sourceNodeId": "srv", "sourceOutput": "main", "targetNodeId": "load", "targetInput": "config"}
   ],
   "expectStatus": "completed",
   "expect": {
-    "load": {
-      "status": "completed",
-      "output": {
-        "alreadyLoaded": true,
-        "loaded": false
-      }
-    }
+    "load": {"status": "completed", "output": {"alreadyLoaded": true, "loaded": false}}
   }
-}
+}

+ 121 - 2
webui/src/components/workflow/NodeConfigModal.tsx

@@ -7,7 +7,7 @@ import { ConfiguratorFields, ConfigTarget } from './ConfiguratorFields'
 import { NodeOptionsSelect } from './NodeOptionsSelect'
 import { groupFields, isCredentialKey } from './fieldGroups'
 import { ArrayFieldEditor } from './ArrayFieldEditor'
-import { NodeDefinition, workflowsApi } from '../../api/workflows'
+import { NodeDefinition, workflowsApi, nodesApi } from '../../api/workflows'
 import { credentialsApi, CredentialInfo } from '../../api/credentials'
 import { ConditionBuilder, Condition, AvailableField } from '../ConditionBuilder'
 import { ExpressionInput } from '../ExpressionInput'
@@ -109,6 +109,12 @@ export function NodeConfigModal({
   }, [maximized])
 
   // Which field is being imported into, and what the service offered.
+  // "Take from the server" and anything like it: a node can declare a button
+  // that asks a service what it is set up with and writes that into the form.
+  const [prefilling, setPrefilling] = useState<{ busy: boolean; message: string; error: boolean }>(
+    { busy: false, message: '', error: false }
+  )
+
   const [importing, setImporting] = useState<{
     field: string
     source: string
@@ -116,6 +122,46 @@ export function NodeConfigModal({
     error: string
   } | null>(null)
 
+  const runPrefill = async (prefill: any, effective: Record<string, any>) => {
+    setPrefilling({ busy: true, message: '', error: false })
+    try {
+      const output: any = await nodesApi.options(
+        prefill.node,
+        workflowId,
+        { ...effective, ...(prefill.config || {}) }
+      )
+
+      const readPath = (obj: any, path: string) =>
+        String(path).split('.').reduce((acc: any, part) => (acc == null ? acc : acc[part]), obj)
+
+      const next: Record<string, any> = { ...editingConfig }
+      const filled: string[] = []
+      for (const [from, to] of Object.entries((prefill.map || {}) as Record<string, string>)) {
+        const value = readPath(output, from)
+        // A component the server does not have comes back empty or absent.
+        // Writing that would blank a value the person had already set.
+        if (value === undefined || value === null || value === '') continue
+        next[to] = value
+        filled.push(to)
+      }
+
+      if (filled.length === 0) {
+        setPrefilling({
+          busy: false,
+          error: true,
+          message: 'The server had nothing to fill these in with - it may have no model loaded.',
+        })
+        return
+      }
+
+      onConfigChange(next)
+      setPrefilling({ busy: false, error: false, message: `Filled in ${filled.join(', ')}.` })
+    } catch (error: any) {
+      const detail = error?.response?.data?.error || error?.message || 'Could not reach the server'
+      setPrefilling({ busy: false, error: true, message: detail })
+    }
+  }
+
   const openImport = async (field: string, sourceName: string) => {
     const source = IMPORT_SOURCES[sourceName]
     if (!source) return
@@ -421,6 +467,23 @@ export function NodeConfigModal({
                 }
                 const schema = nodeDef.configSchema as Record<string, any>
                 const properties = schema.properties || {}
+                const prefill = schema.prefill
+
+                // What the node would run with: its own settings, plus anything
+                // a Configurator supplies. The server address this reads from
+                // usually lives on the Configurator.
+                const effectiveForPrefill = {
+                  ...Object.fromEntries(
+                    Object.entries(suppliedConfig).map(([k, v]) => [k, v.value])
+                  ),
+                  ...editingConfig,
+                }
+                const prefillMissing: string[] = ((prefill?.needs || []) as string[]).filter(
+                  (k) => {
+                    const v = (effectiveForPrefill as any)[k]
+                    return v === undefined || v === null || v === ''
+                  }
+                )
 
                 // Check if field should be shown based on showWhen condition
                 const shouldShowField = (prop: any) => {
@@ -722,7 +785,7 @@ export function NodeConfigModal({
                   </div>
                 )}
 
-                return groupFields(properties, schema).map((group) => {
+                const groups = groupFields(properties, schema).map((group) => {
                   const rendered = group.keys
                     .map((key) => renderField([key, properties[key]]))
                     .filter(Boolean)
@@ -740,6 +803,62 @@ export function NodeConfigModal({
                     </div>
                   )
                 })
+
+                if (!prefill) return groups
+
+                return (
+                  <>
+                    <div className="rounded-lg border border-violet-200 dark:border-violet-800 bg-violet-50/50 dark:bg-violet-900/10 p-3">
+                      <div className="flex items-center justify-between gap-3">
+                        <div className="min-w-0">
+                          <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
+                            {prefill.label || 'Take from the server'}
+                          </div>
+                          {prefill.description && (
+                            <div className="text-[11px] text-gray-500 dark:text-gray-400">
+                              {prefill.description}
+                            </div>
+                          )}
+                        </div>
+                        <button
+                          type="button"
+                          onClick={() => runPrefill(prefill, effectiveForPrefill)}
+                          disabled={prefilling.busy || prefillMissing.length > 0}
+                          title={
+                            prefillMissing.length > 0
+                              ? `Fill in ${prefillMissing.join(' and ')} first`
+                              : 'Read the current settings from the server'
+                          }
+                          className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300 hover:bg-violet-100 dark:hover:bg-violet-900/30 disabled:opacity-50 disabled:cursor-not-allowed"
+                        >
+                          {prefilling.busy ? (
+                            <Loader2 className="w-4 h-4 animate-spin" />
+                          ) : (
+                            <Download className="w-4 h-4" />
+                          )}
+                          Fill in
+                        </button>
+                      </div>
+                      {prefillMissing.length > 0 && (
+                        <div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
+                          Fill in {prefillMissing.join(' and ')} first - that is where this reads from.
+                        </div>
+                      )}
+                      {prefilling.message && (
+                        <div
+                          className={`mt-1 text-[11px] ${
+                            prefilling.error
+                              ? 'text-red-600 dark:text-red-400'
+                              : 'text-green-700 dark:text-green-400'
+                          }`}
+                        >
+                          {prefilling.message}
+                        </div>
+                      )}
+                    </div>
+                    {groups}
+                  </>
+                )
               })()
             )}
           </div>