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

fix(webui): serve the editor from the webserver, and a JSON editor for graphs

The code editor was fetched from a CDN, so a machine without internet got a box
that said "Loading..." forever. Monaco is now bundled and served with the rest
of the app - verified by a page load that makes no request outside the
webserver. Only the JavaScript and JSON pieces are included rather than every
language it ships, and it sits in its own chunk so it is cached across
redeploys instead of being downloaded with each one.

The ComfyUI graph field was being called a syntax error. It holds << >>
placeholders where values will go, so it is JSON with holes in it by design -
that is now recognised and reported as "JSON with placeholders", and the json
language service no longer underlines the holes. The field is a real JSON
editor, with a link to the ComfyUI it came from.
fszontagh 1 месяц назад
Родитель
Сommit
bb56385c75

+ 3 - 2
nodes/ai/comfyui-prompt.js

@@ -19,8 +19,9 @@ const configSchema = {
         workflowJson: {
         workflowJson: {
             type: 'string',
             type: 'string',
             title: 'Workflow Graph',
             title: 'Workflow Graph',
-            description: 'ComfyUI API-format graph. Placeholders use double angle brackets around a dot path, and a seed placeholder yields a random seed. Double braces are deliberately not used, because the engine already interpolates those in node config.',
-            format: 'textarea',
+            description: 'ComfyUI API-format graph, as produced by Save (API Format) in ComfyUI. Placeholders use double angle brackets around a dot path, and a seed placeholder yields a random seed. Double braces are deliberately not used, because the engine already interpolates those in node config.',
+            format: 'json',
+            openUrlFrom: 'baseUrl',
             default: ''
             default: ''
         },
         },
         outputNodeId: {
         outputNodeId: {

+ 1 - 0
webui/package.json

@@ -18,6 +18,7 @@
     "dagre": "^0.8.5",
     "dagre": "^0.8.5",
     "date-fns": "^2.30.0",
     "date-fns": "^2.30.0",
     "lucide-react": "^0.294.0",
     "lucide-react": "^0.294.0",
+    "monaco-editor": "^0.55.1",
     "react": "^18.2.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-router-dom": "^6.20.0",
     "react-router-dom": "^6.20.0",

+ 25 - 3
webui/src/components/ExpressionInput.tsx

@@ -19,7 +19,7 @@ interface ExpressionInputProps {
   className?: string
   className?: string
 }
 }
 
 
-type ContentKind = 'plain' | 'json' | 'broken-json' | 'markdown'
+type ContentKind = 'plain' | 'json' | 'templated-json' | 'broken-json' | 'markdown'
 
 
 // What someone typed into a field says what it is. A prompt, a JSON body and a
 // What someone typed into a field says what it is. A prompt, a JSON body and a
 // note are all "string" to the schema, so the shape of the text is the only
 // note are all "string" to the schema, so the shape of the text is the only
@@ -34,7 +34,20 @@ function detectContentKind(value: string): ContentKind {
       JSON.parse(text)
       JSON.parse(text)
       return 'json'
       return 'json'
     } catch {
     } catch {
-      return 'broken-json'
+      // A graph or request body written for this system holds placeholders
+      // where values will go, and a placeholder standing in for a number is not
+      // JSON until it is filled in. That is the field working as intended, not
+      // a mistake, so it is only called broken if it still fails once the
+      // placeholders are given a value.
+      const filled = text
+        .replace(/<<[^>]*>>/g, '0')
+        .replace(/\{\{[^}]*\}\}/g, '0')
+      try {
+        JSON.parse(filled)
+        return 'templated-json'
+      } catch {
+        return 'broken-json'
+      }
     }
     }
   }
   }
 
 
@@ -59,6 +72,10 @@ const KIND_LABELS: Record<ContentKind, { label: string; className: string } | nu
     label: 'JSON',
     label: 'JSON',
     className: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
     className: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
   },
   },
+  'templated-json': {
+    label: 'JSON with placeholders',
+    className: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
+  },
   'broken-json': {
   'broken-json': {
     label: 'JSON with a syntax error',
     label: 'JSON with a syntax error',
     className: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300',
     className: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300',
@@ -261,7 +278,7 @@ export function ExpressionInput({
 
 
   const kind = detectContentKind(value || '')
   const kind = detectContentKind(value || '')
   const kindLabel = KIND_LABELS[kind]
   const kindLabel = KIND_LABELS[kind]
-  const isCode = kind === 'json' || kind === 'broken-json'
+  const isCode = kind === 'json' || kind === 'templated-json' || kind === 'broken-json'
 
 
   // The field is always a textarea so it can grow with what is typed into it
   // The field is always a textarea so it can grow with what is typed into it
   // without swapping the element under the cursor, which would lose focus and
   // without swapping the element under the cursor, which would lose focus and
@@ -378,6 +395,11 @@ export function ExpressionInput({
               Format
               Format
             </button>
             </button>
           )}
           )}
+          {kind === 'templated-json' && (
+            <span className="text-gray-500 dark:text-gray-400">
+              Valid once the placeholders are filled in
+            </span>
+          )}
           {kind === 'broken-json' && (
           {kind === 'broken-json' && (
             <span className="text-gray-500 dark:text-gray-400">
             <span className="text-gray-500 dark:text-gray-400">
               Starts and ends like JSON but does not parse - check quotes and commas
               Starts and ends like JSON but does not parse - check quotes and commas

+ 16 - 3
webui/src/components/workflow/NodeConfigModal.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useState } from 'react'
 import { useEffect, useState } from 'react'
-import { X, Database, Maximize2, Minimize2 } from 'lucide-react'
+import { X, Database, Maximize2, Minimize2, ExternalLink } from 'lucide-react'
 import { useQuery } from '@tanstack/react-query'
 import { useQuery } from '@tanstack/react-query'
 import Editor from '@monaco-editor/react'
 import Editor from '@monaco-editor/react'
 import { useTheme } from '../../contexts/ThemeContext'
 import { useTheme } from '../../contexts/ThemeContext'
@@ -453,13 +453,26 @@ export function NodeConfigModal({
                           className="w-full mt-2 px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg font-mono text-xs bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
                           className="w-full mt-2 px-3 py-2 border border-gray-200 dark:border-slate-600 rounded-lg font-mono text-xs bg-white dark:bg-slate-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
                         />
                         />
                       </div>
                       </div>
-                    ) : prop.format === 'code' ? (
+                    ) : prop.format === 'code' || prop.format === 'json' ? (
                       /* Source code is read as much as it is written, so it gets
                       /* Source code is read as much as it is written, so it gets
                          a real editor: highlighting, line numbers and a gutter. */
                          a real editor: highlighting, line numbers and a gutter. */
                       <div className="border border-gray-200 dark:border-slate-600 rounded-lg overflow-hidden">
                       <div className="border border-gray-200 dark:border-slate-600 rounded-lg overflow-hidden">
+                        {/* Where the value was authored elsewhere, a way back to
+                            it beats asking the reader to remember the address. */}
+                        {prop.openUrlFrom && editingConfig[prop.openUrlFrom] && (
+                          <a
+                            href={editingConfig[prop.openUrlFrom]}
+                            target="_blank"
+                            rel="noreferrer"
+                            className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-primary-600 dark:text-primary-400 hover:underline border-b border-gray-200 dark:border-slate-600 bg-gray-50 dark:bg-slate-900"
+                          >
+                            <ExternalLink className="w-3.5 h-3.5" />
+                            Open {editingConfig[prop.openUrlFrom]}
+                          </a>
+                        )}
                         <Editor
                         <Editor
                           height={maximized ? '60vh' : '360px'}
                           height={maximized ? '60vh' : '360px'}
-                          language="javascript"
+                          language={prop.format === 'json' ? 'json' : 'javascript'}
                           theme={resolvedTheme === 'dark' ? 'vs-dark' : 'light'}
                           theme={resolvedTheme === 'dark' ? 'vs-dark' : 'light'}
                           value={editingConfig[key] ?? prop.default ?? ''}
                           value={editingConfig[key] ?? prop.default ?? ''}
                           onChange={(value) =>
                           onChange={(value) =>

+ 1 - 0
webui/src/main.tsx

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
 import { BrowserRouter } from 'react-router-dom'
 import { BrowserRouter } from 'react-router-dom'
 import { ThemeProvider } from './contexts/ThemeContext'
 import { ThemeProvider } from './contexts/ThemeContext'
 import App from './App'
 import App from './App'
+import './monaco'
 import './index.css'
 import './index.css'
 
 
 const queryClient = new QueryClient({
 const queryClient = new QueryClient({

+ 39 - 0
webui/src/monaco.ts

@@ -0,0 +1,39 @@
+// @monaco-editor/react fetches the editor from a CDN unless it is handed one.
+// A workflow tool that runs on your own machine should not stop working because
+// the machine is offline, so Monaco is bundled with the rest of the app and the
+// loader is pointed at it before any editor mounts.
+//
+// Only the pieces this app edits are pulled in. Importing monaco-editor whole
+// would add every language it ships - Solidity, PowerQuery, FreeMarker - for an
+// app that writes JavaScript and JSON.
+// The esm entry ships no type declarations of its own; the types live on the
+// package root, which is also the import that would drag in every language.
+// @ts-expect-error - untyped esm entry point, deliberately
+import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
+import { loader } from '@monaco-editor/react'
+import 'monaco-editor/esm/vs/editor/editor.all.js'
+
+import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
+import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution'
+// @ts-expect-error - untyped esm entry point, deliberately
+import { jsonDefaults } from 'monaco-editor/esm/vs/language/json/monaco.contribution'
+
+import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
+import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
+
+// Monaco does its parsing off the main thread and asks for a worker by
+// language. JSON has a language service of its own; everything else is served
+// by the plain editor worker, which still gives highlighting and editing.
+window.MonacoEnvironment = {
+  getWorker(_workerId: string, label: string) {
+    if (label === 'json') return new jsonWorker()
+    return new editorWorker()
+  },
+}
+
+// A graph or request body edited here holds << >> placeholders where values
+// will go, so it is JSON with holes in it by design. Validating it would
+// underline most of the document in red for no reason.
+jsonDefaults.setDiagnosticsOptions({ validate: false, allowComments: true })
+
+loader.config({ monaco })

+ 11 - 0
webui/vite.config.ts

@@ -25,5 +25,16 @@ export default defineConfig({
   build: {
   build: {
     outDir: 'dist',
     outDir: 'dist',
     sourcemap: true,
     sourcemap: true,
+    rollupOptions: {
+      output: {
+        // Monaco is bundled rather than fetched from a CDN so the app works
+        // offline, and it dwarfs the app itself. Kept in its own file it is
+        // downloaded once and stays cached while the app is redeployed.
+        manualChunks: {
+          monaco: ['monaco-editor/esm/vs/editor/editor.api'],
+        },
+      },
+    },
+    chunkSizeWarningLimit: 1500,
   },
   },
 })
 })