Просмотр исходного кода

Merge branch 'prefill-from-server'

fszontagh 1 месяц назад
Родитель
Сommit
d68c8f25ca

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