Procházet zdrojové kódy

Merge branch 'field-order-and-credential-names'

fszontagh před 1 měsícem
rodič
revize
d6c64bf192

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