Преглед изворни кода

feat: brand marks for the provider nodes, and an icon on every row of the picker

Eleven ways of talking to an OpenAI-compatible API all carried the same
speech-bubble icon, and the Add Node panel drew only one icon per category
heading - so the list of them was eleven identical lines of text. The
logo is the one thing that tells them apart at a glance.

Six providers now show their real mark, vendored from simple-icons:
DeepSeek, Mistral, Ollama, OpenRouter, Perplexity and xAI. The paths are
copied into a generated file rather than imported, so the editor does not
carry three and a half thousand icons to show six.

Five have no mark to show. simple-icons does not ship OpenAI's - it was
removed at the trademark holder's request - and has never had Groq,
Together, Fireworks or DeepInfra. Drawing a replacement by hand would mean
shipping something that is not the company's mark while presenting it as
one, so those keep a Lucide icon, but a different one each: no two
providers look alike either way.

NodeIcon resolves both kinds from the same @icon name, so a node does not
have to know which it is asking for, and the canvas and the picker now go
through it together instead of drawing icons their own way.

Brand marks are drawn in currentColor rather than their brand colour, so
one sits in a row with the others rather than shouting over them and both
themes work from one copy. They are marked decorative: the node's name is
always right beside the icon, and announcing the brand as well read as
"DeepSeek DeepSeek Chat" - its <title> also landed in the row's text
where it duplicated the name a second time.

check-node-icons.js understands brand names too, so a node naming one is
not reported as missing - it caught exactly that while this was being
written.

Verified in the browser: all eleven provider rows now show eleven
different icons, six of them filled brand marks and five stroked Lucide
ones.
fszontagh пре 1 месец
родитељ
комит
c487e599f2

+ 1 - 1
nodes/ai/deepinfra-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Ask DeepInfra a question, with an image if there is one, and optionally get JSON back
- * @icon message-square
+ * @icon server
  */
 
 // Generated by scripts/gen-openai-compatible-nodes.py from one implementation

+ 1 - 1
nodes/ai/deepseek-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Ask DeepSeek a question, with an image if there is one, and optionally get JSON back
- * @icon message-square
+ * @icon deepseek
  */
 
 // Generated by scripts/gen-openai-compatible-nodes.py from one implementation

+ 1 - 1
nodes/ai/fireworks-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Ask Fireworks AI a question, with an image if there is one, and optionally get JSON back
- * @icon message-square
+ * @icon flame
  */
 
 // Generated by scripts/gen-openai-compatible-nodes.py from one implementation

+ 1 - 1
nodes/ai/groq-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Ask Groq a question, with an image if there is one, and optionally get JSON back
- * @icon message-square
+ * @icon zap
  */
 
 // Generated by scripts/gen-openai-compatible-nodes.py from one implementation

+ 1 - 1
nodes/ai/mistral-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Ask Mistral a question, with an image if there is one, and optionally get JSON back
- * @icon message-square
+ * @icon mistral
  */
 
 // Generated by scripts/gen-openai-compatible-nodes.py from one implementation

+ 1 - 1
nodes/ai/ollama-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Send a prompt to an Ollama model, optionally with an image for vision models, and return the text or parsed JSON response
- * @icon bot
+ * @icon ollama
  */
 
 const configSchema = {

+ 1 - 1
nodes/ai/openai-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Ask OpenAI a question, with an image if there is one, and optionally get JSON back
- * @icon message-square
+ * @icon sparkles
  */
 
 // Generated by scripts/gen-openai-compatible-nodes.py from one implementation

+ 1 - 1
nodes/ai/openrouter-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Ask OpenRouter a question, with an image if there is one, and optionally get JSON back
- * @icon message-square
+ * @icon openrouter
  */
 
 // Generated by scripts/gen-openai-compatible-nodes.py from one implementation

+ 1 - 1
nodes/ai/perplexity-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Ask Perplexity a question, with an image if there is one, and optionally get JSON back
- * @icon message-square
+ * @icon perplexity
  */
 
 // Generated by scripts/gen-openai-compatible-nodes.py from one implementation

+ 1 - 1
nodes/ai/together-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Ask Together AI a question, with an image if there is one, and optionally get JSON back
- * @icon message-square
+ * @icon users
  */
 
 // Generated by scripts/gen-openai-compatible-nodes.py from one implementation

+ 1 - 1
nodes/ai/xai-chat.js

@@ -4,7 +4,7 @@
  * @category ai
  * @version 1.0.0
  * @description Ask xAI a question, with an image if there is one, and optionally get JSON back
- * @icon message-square
+ * @icon xai
  */
 
 // Generated by scripts/gen-openai-compatible-nodes.py from one implementation

+ 16 - 1
scripts/check-node-icons.js

@@ -18,6 +18,7 @@ const path = require('path')
 
 const ROOT = path.resolve(__dirname, '..')
 const NODES_DIR = path.join(ROOT, 'nodes')
+const BRAND_ICONS_FILE = path.join(ROOT, 'webui/src/config/brandIcons.ts')
 
 // Kept in step with ICON_ALIASES in webui/src/config/nodeCategories.ts - this
 // script has no way to read the TypeScript one.
@@ -54,8 +55,19 @@ function iconsInUse() {
   return found
 }
 
+// The brand marks vendored by scripts/gen-brand-icons.js. A node may name one
+// of these instead of a Lucide icon, so they are not missing - they are just
+// resolved somewhere else.
+function brandIconNames() {
+  if (!fs.existsSync(BRAND_ICONS_FILE)) return new Set()
+  const src = fs.readFileSync(BRAND_ICONS_FILE, 'utf8')
+  const body = src.slice(src.indexOf('BRAND_ICONS'))
+  return new Set([...body.matchAll(/^\s*'([a-z0-9-]+)':/gm)].map((m) => m[1]))
+}
+
 function main() {
   const inUse = iconsInUse()
+  const brands = brandIconNames()
 
   let lucide
   try {
@@ -67,6 +79,7 @@ function main() {
 
   const unresolved = []
   for (const name of [...inUse.keys()].sort()) {
+    if (brands.has(name)) continue
     const component = ALIASES[name] || pascal(name)
     if (!lucide[component]) unresolved.push([name, component, inUse.get(name)])
   }
@@ -83,7 +96,9 @@ function main() {
 
   const files = collectNodeFiles(NODES_DIR)
   const withIcon = [...inUse.values()].reduce((n, ids) => n + ids.length, 0)
-  console.log(`${inUse.size} distinct icons across ${withIcon} nodes - all resolve`)
+  const brandsUsed = [...inUse.keys()].filter((n) => brands.has(n)).length
+  console.log(`${inUse.size} distinct icons across ${withIcon} nodes - all resolve ` +
+              `(${brandsUsed} brand marks, ${inUse.size - brandsUsed} from Lucide)`)
   if (withIcon < files.length) {
     console.log(`${files.length - withIcon} node file(s) declare no @icon and will show their initial`)
   }

+ 88 - 0
scripts/gen-brand-icons.js

@@ -0,0 +1,88 @@
+#!/usr/bin/env node
+/**
+ * Vendors the provider brand marks the editor uses, from simple-icons.
+ *
+ * The provider nodes - ten ways of talking to an OpenAI-compatible API - all
+ * carried the same speech-bubble icon, so a list of them was ten identical
+ * rows. A brand mark is the one thing that tells them apart at a glance.
+ *
+ * Only marks simple-icons actually ships are vendored. It removes logos when a
+ * trademark holder asks - OpenAI's is already gone - and drawing a replacement
+ * by hand would mean shipping something that is not the company's mark while
+ * claiming it is. Providers with no mark get a distinct Lucide icon instead,
+ * assigned in the node's own @icon.
+ *
+ *   npm --prefix webui install --no-save simple-icons@16
+ *   node scripts/gen-brand-icons.js
+ *
+ * The paths are copied out rather than imported so the editor does not carry
+ * three and a half thousand icons to show six.
+ */
+
+const fs = require('fs')
+const path = require('path')
+
+const ROOT = path.resolve(__dirname, '..')
+const OUT = path.join(ROOT, 'webui/src/config/brandIcons.ts')
+
+// The name a node writes in @icon -> the simple-icons export.
+// Left-hand side is ours and stays stable even if a slug changes upstream.
+const WANTED = {
+  ollama: 'siOllama',
+  mistral: 'siMistralai',
+  perplexity: 'siPerplexity',
+  deepseek: 'siDeepseek',
+  openrouter: 'siOpenrouter',
+  xai: 'siX',
+  anthropic: 'siAnthropic',
+  huggingface: 'siHuggingface',
+}
+
+function main() {
+  let si
+  try {
+    si = require(path.join(ROOT, 'webui/node_modules/simple-icons'))
+  } catch (e) {
+    console.error('simple-icons is not installed. Run:')
+    console.error('  npm --prefix webui install --no-save simple-icons@16')
+    process.exit(2)
+  }
+
+  const found = []
+  const missing = []
+  for (const [name, exportName] of Object.entries(WANTED)) {
+    const icon = si[exportName]
+    if (icon && icon.path) found.push([name, icon])
+    else missing.push([name, exportName])
+  }
+
+  if (missing.length) {
+    console.warn('Not shipped by simple-icons, so these keep their Lucide icon:')
+    for (const [name, exportName] of missing) console.warn(`  ${name} (${exportName})`)
+  }
+
+  const body = `// GENERATED by scripts/gen-brand-icons.js - do not edit by hand.
+//
+// Provider brand marks, copied from simple-icons so the editor does not have to
+// carry the whole set. Regenerate with:
+//   npm --prefix webui install --no-save simple-icons@16
+//   node scripts/gen-brand-icons.js
+//
+// Each path is a 24x24 viewBox. Drawn in currentColor rather than the brand
+// colour, so an icon sits in a row with the others instead of shouting over
+// them, and works in both themes.
+export interface BrandIcon {
+  title: string
+  path: string
+}
+
+export const BRAND_ICONS: Record<string, BrandIcon> = {
+${found.map(([name, icon]) => `  '${name}': { title: ${JSON.stringify(icon.title)}, path: ${JSON.stringify(icon.path)} },`).join('\n')}
+}
+`
+
+  fs.writeFileSync(OUT, body)
+  console.log(`Wrote ${path.relative(ROOT, OUT)}: ${found.length} brand marks`)
+}
+
+main()

+ 45 - 0
webui/src/components/workflow/NodeIcon.tsx

@@ -0,0 +1,45 @@
+import { BRAND_ICONS } from '../../config/brandIcons'
+import { getIconComponent } from '../../config/nodeCategories'
+
+/**
+ * A node's icon, whichever kind it is.
+ *
+ * Most nodes name a Lucide icon. The provider nodes name a brand instead -
+ * ten ways of talking to an OpenAI-compatible API all looked identical with a
+ * speech bubble each, and the logo is the one thing that tells them apart at a
+ * glance. Both are resolved from the same `@icon` name, so a node does not have
+ * to know which kind it is asking for.
+ *
+ * Brand marks are drawn in currentColor rather than their brand colour, so one
+ * sits in a list with the others rather than shouting over them, and so both
+ * themes work without a second copy.
+ */
+
+interface Props {
+  icon: string | undefined
+  className?: string
+}
+
+export function NodeIcon({ icon, className = 'w-4 h-4' }: Props) {
+  const brand = icon ? BRAND_ICONS[icon.toLowerCase()] : undefined
+
+  if (brand) {
+    return (
+      // Decorative: the node's name is always right next to it, so announcing
+      // the brand as well reads as "DeepSeek DeepSeek Chat", and its <title>
+      // lands in the row's text content where it duplicates the name again.
+      <svg
+        viewBox="0 0 24 24"
+        className={className}
+        fill="currentColor"
+        aria-hidden="true"
+        focusable="false"
+      >
+        <path d={brand.path} />
+      </svg>
+    )
+  }
+
+  const Icon = getIconComponent(icon)
+  return <Icon className={className} />
+}

+ 8 - 0
webui/src/components/workflow/NodePickerSidebar.tsx

@@ -1,5 +1,6 @@
 import { useState, useMemo } from 'react'
 import { X, Search, ChevronDown, ChevronRight, Plus, ChevronsDownUp, ChevronsUpDown, Link2, GripVertical } from 'lucide-react'
+import { NodeIcon } from './NodeIcon'
 import { NodeDefinition } from '../../api/workflows'
 import { getCategoryConfig, getSortedCategories } from '../../config/nodeCategories'
 
@@ -205,6 +206,13 @@ export function NodePickerSidebar({ nodeDefs, onAddNode, connectingFrom, onClose
                       >
                         <GripVertical className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-300 dark:text-slate-600 opacity-0 group-hover:opacity-100" />
                         <div className="flex items-center gap-2">
+                          {/* The rows used to carry only the category's icon at
+                              the top, so eleven chat providers were eleven
+                              identical lines of text. */}
+                          <NodeIcon
+                            icon={node.icon}
+                            className="w-4 h-4 shrink-0 text-gray-500 dark:text-gray-400"
+                          />
                           <span className="font-medium text-gray-800 dark:text-gray-100 text-sm">
                             {node.name}
                           </span>

+ 2 - 3
webui/src/components/workflow/WorkflowNode.tsx

@@ -1,6 +1,6 @@
 import { Handle, Position, NodeProps } from 'reactflow'
 import { Play, CheckCircle, XCircle, Loader2 } from 'lucide-react'
-import { getIconComponent } from '../../config/nodeCategories'
+import { NodeIcon } from './NodeIcon'
 import { NodeOutput, DynamicOutputs } from '../../api/workflows'
 
 export interface NodeExecutionState {
@@ -53,7 +53,6 @@ export function WorkflowNode({ data, selected }: NodeProps) {
   const isTrigger = data.isTrigger
   const disabled = data.disabled === true
   const lockedBy = data.lockedBy as { username: string; kind: string } | undefined
-  const NodeIcon = getIconComponent(data.icon)
   const execState = data.executionState as NodeExecutionState | undefined
   const outputs: NodeOutput[] = resolveOutputs(data.outputs, data.dynamicOutputs, data.config)
   const inputs: NodeOutput[] = data.inputs?.length
@@ -212,7 +211,7 @@ export function WorkflowNode({ data, selected }: NodeProps) {
               empty box. The same resolver the rest of the editor uses answers
               for all of them, and falls back to the initial. */}
           {data.icon
-            ? <NodeIcon className="w-4 h-4" />
+            ? <NodeIcon icon={data.icon} className="w-4 h-4" />
             : <span className="text-xs font-medium">{(data.config?._customLabel || data.label)?.charAt(0) || '?'}</span>}
         </div>
 

Разлика између датотеке није приказан због своје велике величине
+ 16 - 0
webui/src/config/brandIcons.ts


Неке датотеке нису приказане због велике количине промена