Sfoglia il codice sorgente

fix: show a node's settings in the order they were written, and name credentials

FIELD ORDER

Settings appeared alphabetically, so Width was last and Height near the top,
and Server URL sat in the middle of the sampling options. Not a display bug:
nlohmann::json sorts an object's keys, so the order a node was written in was
gone long before the browser saw it.

The node file is now parsed a second time with nlohmann::ordered_json, which
keeps insertion order, and the property order is recorded as uiOrder - an ARRAY.
Keeping the schema itself ordered would not have been enough: it is written to
the database and read back, and nothing here controls whether that round trip
preserves an object's key order. An array has an order by definition.

GROUPING

Settings are now grouped, Connection first, then Credentials, then the rest in
the order the node declares them. A node can override this with uiGroups in its
schema - an ordered list of {title, fields} - and any field it forgets to
mention still appears under "Other", because a setting that silently vanished
from the form would be far worse than one in the wrong place.

CREDENTIAL NAMES

A credential was shown as cred_62748b40-e9d1-4659-b175-6dbc691cc0b1 wherever it
appeared. It now shows the name it was saved under, both on a field a
Configurator supplies and in the Configurator's own editor, where a credential
setting is a list of credentials by name rather than a box to paste an id into.

Verified in the browser: SD.cpp Generate now reads Connection / Server URL,
Credentials / Credential, then Operation, Prompt, Negative Prompt, Width,
Height, Steps - the order its file declares - and the supplied credential shows
as "SDCPP Restapi". Full suite 55/55.
fszontagh 1 mese fa
parent
commit
df9e6c8855

+ 23 - 0
src/webserver/nodes/node_store.cpp

@@ -316,6 +316,29 @@ StoredNode StoredNode::parseFromCode(const std::string& code, const std::string&
             if (!jsonStr.empty()) {
                 try {
                     node.config_schema = nlohmann::json::parse(jsonStr);
+
+                    // Record the order the settings were written in.
+                    //
+                    // nlohmann::json sorts an object's keys, so by the time a
+                    // schema reaches the editor "width" has drifted to the end
+                    // and "height" near the front, whatever the node author
+                    // wrote. ordered_json keeps insertion order, so parsing a
+                    // second time with it recovers the real order.
+                    //
+                    // It is stored as an ARRAY rather than by keeping the whole
+                    // schema ordered, because the schema is written to the
+                    // database and read back, and nothing here controls whether
+                    // that round trip preserves an object's key order. An array
+                    // has an order by definition and survives any of it.
+                    auto ordered = nlohmann::ordered_json::parse(jsonStr);
+                    if (ordered.contains("properties") && ordered["properties"].is_object()) {
+                        auto ui_order = nlohmann::json::array();
+                        for (auto it = ordered["properties"].begin();
+                             it != ordered["properties"].end(); ++it) {
+                            ui_order.push_back(it.key());
+                        }
+                        node.config_schema["uiOrder"] = ui_order;
+                    }
                 } catch (const nlohmann::json::exception& e) {
                     LOG_WARN("Failed to parse configSchema for {}: {}", node.id, e.what());
                 }

+ 27 - 0
webui/src/components/workflow/ConfiguratorFields.tsx

@@ -19,6 +19,9 @@ interface Setting {
 interface ConfiguratorFieldsProps {
   targets: ConfigTarget[]
   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 }[]
   editingConfig: Record<string, any>
   onConfigChange: (config: Record<string, any>) => void
 }
@@ -42,6 +45,7 @@ interface AvailableSetting {
 export function ConfiguratorFields({
   targets,
   nodeDefs,
+  credentials,
   editingConfig,
   onConfigChange,
 }: ConfiguratorFieldsProps) {
@@ -119,6 +123,29 @@ export function ConfiguratorFields({
     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'
 
+    // 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)
+      )
+      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>
+      )
+    }
+
     if (Array.isArray(prop?.enum)) {
       return (
         <select

+ 36 - 4
webui/src/components/workflow/NodeConfigModal.tsx

@@ -5,6 +5,7 @@ import Editor from '@monaco-editor/react'
 import { useTheme } from '../../contexts/ThemeContext'
 import { ConfiguratorFields, ConfigTarget } from './ConfiguratorFields'
 import { NodeOptionsSelect } from './NodeOptionsSelect'
+import { groupFields, isCredentialKey } from './fieldGroups'
 import { NodeDefinition } from '../../api/workflows'
 import { credentialsApi, CredentialInfo } from '../../api/credentials'
 import { ConditionBuilder, Condition, AvailableField } from '../ConditionBuilder'
@@ -378,6 +379,7 @@ export function NodeConfigModal({
                 <ConfiguratorFields
                   targets={configTargets}
                   nodeDefs={nodeDefs}
+                  credentials={credentials}
                   editingConfig={editingConfig}
                   onConfigChange={onConfigChange}
                 />
@@ -408,7 +410,11 @@ export function NodeConfigModal({
                   return currentValue === value
                 }
 
-                return Object.entries(properties).map(([key, prop]: [string, any]) => {
+                // Grouped and ordered rather than however the object arrived -
+                // see fieldGroups: a schema is carried as a JSON object, and
+                // those are stored sorted, so declaration order is long gone by
+                // the time it reaches here.
+                const renderField = ([key, prop]: [string, any]) => {
                   // Skip if showWhen condition is not met
                   if (!shouldShowField(prop)) return null
 
@@ -416,12 +422,19 @@ export function NodeConfigModal({
                   // make it clear this box is no longer the one that decides.
                   const supplied = suppliedConfig[key]
                   if (supplied) {
+                    // A credential is stored as an id and read as gibberish -
+                    // show the name it was saved under.
+                    const namedCredential =
+                      isCredentialKey(key, prop) && typeof supplied.value === 'string'
+                        ? credentials.find((c) => c.id === supplied.value)?.name
+                        : undefined
                     const shown =
-                      typeof supplied.value === 'boolean'
+                      namedCredential ??
+                      (typeof supplied.value === 'boolean'
                         ? supplied.value ? 'On' : 'Off'
                         : supplied.value === '' || supplied.value === undefined || supplied.value === null
                           ? '(empty)'
-                          : String(supplied.value)
+                          : String(supplied.value))
                     return (
                       <div key={key}>
                         <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -658,7 +671,26 @@ export function NodeConfigModal({
                       <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{prop.description}</p>
                     )}
                   </div>
-                )})
+                )}
+
+                return groupFields(properties, schema).map((group) => {
+                  const rendered = group.keys
+                    .map((key) => renderField([key, properties[key]]))
+                    .filter(Boolean)
+                  // A group whose every field is hidden by a showWhen rule
+                  // should take its heading with it.
+                  if (rendered.length === 0) return null
+                  return (
+                    <div key={group.title || 'fields'} className="space-y-4">
+                      {group.title && (
+                        <div className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 pt-2 border-t border-gray-100 dark:border-slate-700 first:border-0 first:pt-0">
+                          {group.title}
+                        </div>
+                      )}
+                      {rendered}
+                    </div>
+                  )
+                })
               })()
             )}
           </div>

+ 124 - 0
webui/src/components/workflow/fieldGroups.ts

@@ -0,0 +1,124 @@
+/**
+ * Deciding what order a node's settings appear in, and under which headings.
+ *
+ * The order they are written in the node file does not survive: a schema is
+ * carried as a JSON object all the way from the node file to the browser, and
+ * JSON objects here are stored sorted, so "width" arrives last and "height"
+ * near the top no matter how the node was written. Anything that wants a
+ * particular order has to say so in something that keeps its order - a list.
+ *
+ * A node can therefore declare uiGroups in its configSchema:
+ *
+ *   uiGroups: [
+ *     { title: 'Server',  fields: ['serverUrl', 'credentialId'] },
+ *     { title: 'Prompt',  fields: ['prompt', 'negativePrompt'] },
+ *     { title: 'Sampling', fields: ['width', 'height', 'steps'] }
+ *   ]
+ *
+ * A node that declares nothing still gets connection settings and credentials
+ * lifted to the top, because that is where someone looks first and it costs
+ * nothing to apply everywhere.
+ */
+
+export interface FieldGroup {
+  title: string
+  keys: string[]
+}
+
+// Fields that say WHERE a node talks to, in the order they are usually filled.
+// Listed explicitly rather than guessed from the name, so an unrelated field
+// that happens to contain "url" is not dragged to the top.
+const CONNECTION_KEYS = [
+  'serverUrl',
+  'baseUrl',
+  'apiUrl',
+  'url',
+  'host',
+  'port',
+  'useSsl',
+  'database',
+  'mailbox',
+]
+
+export function isCredentialKey(key: string, prop: any): boolean {
+  return (
+    prop?.dynamicOptions?.source === 'credentials' ||
+    key === 'credentialId' ||
+    key === 'credentials'
+  )
+}
+
+/**
+ * Group and order a node's settings for display.
+ *
+ * Every field appears exactly once. A field a node forgot to list in its
+ * uiGroups still shows up, under a heading of its own - a setting that silently
+ * vanished from the form because of a typo in the grouping would be far worse
+ * than one in the wrong place.
+ */
+export function groupFields(
+  properties: Record<string, any>,
+  schema: Record<string, any>
+): FieldGroup[] {
+  const all = Object.keys(properties)
+  const used = new Set<string>()
+  const groups: FieldGroup[] = []
+
+  const declared = Array.isArray(schema?.uiGroups) ? schema.uiGroups : null
+
+  if (declared) {
+    for (const group of declared) {
+      const keys: string[] = []
+      for (const key of Array.isArray(group?.fields) ? group.fields : []) {
+        // Skip names that no longer exist, so a renamed setting leaves a gap
+        // rather than an empty control.
+        if (properties[key] && !used.has(key)) {
+          keys.push(key)
+          used.add(key)
+        }
+      }
+      if (keys.length > 0) {
+        groups.push({ title: String(group?.title || ''), keys })
+      }
+    }
+
+    const leftover = all.filter((k) => !used.has(k)).sort()  // eslint-disable-line
+    if (leftover.length > 0) {
+      groups.push({ title: 'Other', keys: leftover })
+    }
+    return groups
+  }
+
+  // No grouping declared: lift the two things everyone looks for first, and
+  // leave the rest as it comes.
+  const connection = CONNECTION_KEYS.filter((k) => properties[k])
+  connection.forEach((k) => used.add(k))
+
+  const credentials = all.filter((k) => !used.has(k) && isCredentialKey(k, properties[k]))
+  credentials.forEach((k) => used.add(k))
+
+  // Declaration order, recorded as an array when the node file was parsed
+  // because the schema object itself arrives with its keys sorted. Anything the
+  // list does not mention goes after it, alphabetically, so a field added
+  // without reparsing still appears.
+  const declaredOrder: string[] = Array.isArray(schema?.uiOrder) ? schema.uiOrder : []
+  const rank = new Map<string, number>()
+  declaredOrder.forEach((key, index) => rank.set(key, index))
+  const rest = all
+    .filter((k) => !used.has(k))
+    .sort((a, b) => {
+      const ra = rank.has(a) ? rank.get(a)! : Number.MAX_SAFE_INTEGER
+      const rb = rank.has(b) ? rank.get(b)! : Number.MAX_SAFE_INTEGER
+      return ra === rb ? a.localeCompare(b) : ra - rb
+    })
+
+  if (connection.length > 0) groups.push({ title: 'Connection', keys: connection })
+  if (credentials.length > 0) groups.push({ title: 'Credentials', keys: credentials })
+  if (rest.length > 0) {
+    // Only call it "Settings" when it is not the whole form - a single unnamed
+    // heading above every field is noise.
+    groups.push({ title: groups.length > 0 ? 'Settings' : '', keys: rest })
+  }
+
+  return groups
+}