Parcourir la source

feat: import a graph from ComfyUI, and stop offering a link that cannot work

The Open link 403'd. ComfyUI refuses any request carrying Sec-Fetch-Site:
cross-site, which every browser sets when following a link from another origin
(server.py:162). No link attribute changes that - the header is the browser's.
Typing the same address works because that sends Sec-Fetch-Site: none. So the
address is shown to be copied rather than clicked.

A request from this server carries no such header, which is what the new proxy
is for: an authenticated GET on the browser's behalf, capped and timed out. It
grants nothing a workflow author lacks - a code node can already call any
address through smartbotic.http.request - and it is general, not tied to any
one service.

On top of it, a field can declare where its value can be imported from. The
ComfyUI graph field does, and the picker lists the runs ComfyUI remembers.
A run stores the graph exactly as it was submitted, which is the format this
node holds, so importing converts and guesses nothing: build the workflow in
ComfyUI, run it once, pick it here.

Runs are listed by workflow name, resolved from the id a run records against
the workflow files ComfyUI keeps, reading no more of them than it takes to name
every run on screen. Runs this system submitted carry no workflow, so they are
named by what they wrote.
fszontagh il y a 1 mois
Parent
commit
308d8e8225

+ 1 - 0
CMakeLists.txt

@@ -172,6 +172,7 @@ add_executable(smartbotic-webserver
     src/webserver/api/webhook_controller.cpp
     src/webserver/api/database_controller.cpp
     src/webserver/api/file_controller.cpp
+    src/webserver/api/proxy_controller.cpp
 )
 target_include_directories(smartbotic-webserver PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/src

+ 1 - 0
nodes/ai/comfyui-prompt.js

@@ -22,6 +22,7 @@ const configSchema = {
             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',
+            importFrom: 'comfyui',
             default: ''
         },
         outputNodeId: {

+ 112 - 0
src/webserver/api/proxy_controller.cpp

@@ -0,0 +1,112 @@
+#include "proxy_controller.hpp"
+
+#include <curl/curl.h>
+#include <nlohmann/json.hpp>
+
+#include "logging/logger.hpp"
+
+namespace smartbotic::webserver::api {
+
+namespace {
+
+// Enough for a workflow graph or an API listing, and short of anything that
+// would be worth streaming rather than holding in memory.
+constexpr size_t kMaxResponseBytes = 8 * 1024 * 1024;
+
+struct Sink {
+    std::string body;
+    bool truncated = false;
+};
+
+size_t appendBody(void* contents, size_t size, size_t nmemb, void* userp) {
+    auto* sink = static_cast<Sink*>(userp);
+    const size_t incoming = size * nmemb;
+
+    if (sink->body.size() + incoming > kMaxResponseBytes) {
+        sink->truncated = true;
+        return 0;   // stops the transfer
+    }
+
+    sink->body.append(static_cast<char*>(contents), incoming);
+    return incoming;
+}
+
+bool isFetchableUrl(const std::string& url) {
+    return url.rfind("http://", 0) == 0 || url.rfind("https://", 0) == 0;
+}
+
+} // namespace
+
+ProxyController::ProxyController(auth::AuthMiddleware& middleware)
+    : middleware_(middleware) {}
+
+void ProxyController::registerRoutes(httplib::Server& server) {
+    server.Get("/api/v1/proxy", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            fetch(req, res, ctx);
+        });
+    });
+}
+
+void ProxyController::fetch(const httplib::Request& req, httplib::Response& res,
+                            const auth::AuthContext& ctx) {
+    (void)ctx;
+
+    const std::string url = req.get_param_value("url");
+    if (url.empty()) {
+        sendError(res, "A url parameter is required", 400);
+        return;
+    }
+    if (!isFetchableUrl(url)) {
+        sendError(res, "Only http and https addresses can be fetched", 400);
+        return;
+    }
+
+    CURL* curl = curl_easy_init();
+    if (!curl) {
+        sendError(res, "Could not start the request", 500);
+        return;
+    }
+
+    Sink sink;
+    long status = 0;
+    char* content_type = nullptr;
+
+    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
+    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, appendBody);
+    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
+    curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L);
+    curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
+    curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
+    curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
+
+    const CURLcode result = curl_easy_perform(curl);
+    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
+    curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &content_type);
+    const std::string mime = content_type ? content_type : "application/octet-stream";
+    curl_easy_cleanup(curl);
+
+    if (sink.truncated) {
+        sendError(res, "The response was larger than this can carry", 502);
+        return;
+    }
+    if (result != CURLE_OK) {
+        sendError(res, std::string("Could not reach ") + url + ": " + curl_easy_strerror(result), 502);
+        return;
+    }
+
+    // The far end's status is reported rather than replaced, so a caller can
+    // tell "it said no" from "it could not be reached".
+    res.status = static_cast<int>(status);
+    res.set_content(sink.body, mime);
+
+    LOG_DEBUG("Proxied {} -> {} ({} bytes)", url, status, sink.body.size());
+}
+
+void ProxyController::sendError(httplib::Response& res, const std::string& message, int status) {
+    nlohmann::json body = {{"error", message}};
+    res.status = status;
+    res.set_content(body.dump(), "application/json");
+}
+
+} // namespace smartbotic::webserver::api

+ 36 - 0
src/webserver/api/proxy_controller.hpp

@@ -0,0 +1,36 @@
+#pragma once
+
+#include <httplib.h>
+#include <string>
+#include "../auth/auth_middleware.hpp"
+
+namespace smartbotic::webserver::api {
+
+// Fetches a URL on the browser's behalf.
+//
+// A page here cannot read most of the services a workflow talks to. Some send
+// no CORS headers; others refuse outright - ComfyUI, for one, answers 403 to
+// anything carrying Sec-Fetch-Site: cross-site, which every browser sets on a
+// request to another origin. A request from this server carries none of that,
+// so what a page cannot fetch, this can.
+//
+// It grants no reach that a workflow author does not already have: a code node
+// can call any address it likes through smartbotic.http.request. This is the
+// same capability, available while configuring a node rather than while running
+// one, and it stays behind the same authentication.
+class ProxyController {
+public:
+    explicit ProxyController(auth::AuthMiddleware& middleware);
+
+    void registerRoutes(httplib::Server& server);
+
+private:
+    void fetch(const httplib::Request& req, httplib::Response& res,
+               const auth::AuthContext& ctx);
+
+    void sendError(httplib::Response& res, const std::string& message, int status);
+
+    auth::AuthMiddleware& middleware_;
+};
+
+} // namespace smartbotic::webserver::api

+ 4 - 0
src/webserver/webserver_service.cpp

@@ -9,6 +9,7 @@
 #include "api/webhook_controller.hpp"
 #include "api/database_controller.hpp"
 #include "api/file_controller.hpp"
+#include "api/proxy_controller.hpp"
 #include "api/credential_controller.hpp"
 #include "nodes/node_store.hpp"
 #include "grpc/node_sync_service.hpp"
@@ -224,6 +225,9 @@ void WebServerService::setupRoutes() {
     execution_ctrl_->registerRoutes(server);
     file_ctrl_->registerRoutes(server);
 
+    proxy_ctrl_ = std::make_unique<api::ProxyController>(*auth_middleware_);
+    proxy_ctrl_->registerRoutes(server);
+
     node_ctrl_ = std::make_unique<api::NodeController>(
         *node_store_, *auth_middleware_, &node_sync_server_->service());
     node_ctrl_->registerRoutes(server);

+ 2 - 0
src/webserver/webserver_service.hpp

@@ -34,6 +34,7 @@ namespace smartbotic::webserver::api {
     class WorkflowGroupController;
     class ExecutionController;
     class FileController;
+    class ProxyController;
     class NodeController;
     class RunnerController;
     class WebhookController;
@@ -124,6 +125,7 @@ private:
     std::unique_ptr<api::WorkflowGroupController> workflow_group_ctrl_;
     std::unique_ptr<api::ExecutionController> execution_ctrl_;
     std::unique_ptr<api::FileController> file_ctrl_;
+    std::unique_ptr<api::ProxyController> proxy_ctrl_;
 
     // Execution events can arrive more than once for the same execution, so a
     // failure is remembered briefly to keep one failure from starting the error

+ 124 - 12
webui/src/components/workflow/NodeConfigModal.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useState } from 'react'
-import { X, Database, Maximize2, Minimize2, ExternalLink } from 'lucide-react'
+import { X, Database, Maximize2, Minimize2, ExternalLink, Download, Loader2 } from 'lucide-react'
 import { useQuery } from '@tanstack/react-query'
 import Editor from '@monaco-editor/react'
 import { useTheme } from '../../contexts/ThemeContext'
@@ -8,6 +8,7 @@ import { credentialsApi, CredentialInfo } from '../../api/credentials'
 import { ConditionBuilder, Condition, AvailableField } from '../ConditionBuilder'
 import { ExpressionInput } from '../ExpressionInput'
 import { AvailableDataPanel, FieldInfo } from '../AvailableDataPanel'
+import { IMPORT_SOURCES, ImportChoice } from '../../config/importSources'
 
 interface SelectedNodeData {
   id: string
@@ -68,6 +69,57 @@ export function NodeConfigModal({
     localStorage.setItem('nodeConfigMaximized', String(maximized))
   }, [maximized])
 
+  // Which field is being imported into, and what the service offered.
+  const [importing, setImporting] = useState<{
+    field: string
+    source: string
+    choices: ImportChoice[] | null
+    error: string
+  } | null>(null)
+
+  const openImport = async (field: string, sourceName: string) => {
+    const source = IMPORT_SOURCES[sourceName]
+    if (!source) return
+
+    const address = editingConfig[source.addressField]
+    if (!address) {
+      setImporting({
+        field,
+        source: sourceName,
+        choices: [],
+        error: `Fill in ${source.addressField} first - that is where this reads from.`,
+      })
+      return
+    }
+
+    setImporting({ field, source: sourceName, choices: null, error: '' })
+    try {
+      const choices = await source.list(address)
+      setImporting({ field, source: sourceName, choices, error: '' })
+    } catch (error: any) {
+      setImporting({
+        field,
+        source: sourceName,
+        choices: [],
+        error: error?.message || 'Could not read from the service',
+      })
+    }
+  }
+
+  const applyImport = async (choice: ImportChoice) => {
+    if (!importing) return
+    const source = IMPORT_SOURCES[importing.source]
+    const address = editingConfig[source.addressField]
+
+    try {
+      const value = await source.fetch(address, choice.id)
+      onConfigChange({ ...editingConfig, [importing.field]: value })
+      setImporting(null)
+    } catch (error: any) {
+      setImporting({ ...importing, error: error?.message || 'Could not read that one' })
+    }
+  }
+
   const sizeClasses = maximized
     ? 'w-[96vw] h-[92vh] max-w-none'
     : showDataPanel
@@ -81,7 +133,7 @@ export function NodeConfigModal({
         style={{ minWidth: '32rem', minHeight: '20rem' }}
       >
         {/* Main configuration area */}
-        <div className="flex-1 flex flex-col overflow-hidden">
+        <div className="relative flex-1 flex flex-col overflow-hidden">
           <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-slate-700">
             <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Edit Node: {selectedNodeData.name}</h2>
             <div className="flex items-center gap-2">
@@ -459,16 +511,28 @@ export function NodeConfigModal({
                       <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>
+                        {(prop.importFrom || prop.openUrlFrom) && (
+                          <div className="flex items-center gap-3 px-3 py-1.5 text-xs border-b border-gray-200 dark:border-slate-600 bg-gray-50 dark:bg-slate-900">
+                            {prop.importFrom && IMPORT_SOURCES[prop.importFrom] && (
+                              <button
+                                onClick={() => openImport(key, prop.importFrom)}
+                                className="flex items-center gap-1.5 text-primary-600 dark:text-primary-400 hover:underline"
+                              >
+                                <Download className="w-3.5 h-3.5" />
+                                {IMPORT_SOURCES[prop.importFrom].actionLabel}
+                              </button>
+                            )}
+                            {prop.openUrlFrom && editingConfig[prop.openUrlFrom] && (
+                              <span className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400">
+                                <ExternalLink className="w-3.5 h-3.5" />
+                                {/* Not a link: ComfyUI answers 403 to anything a
+                                    browser sends from another origin, so a click
+                                    would fail. The address is here to be copied
+                                    into the address bar, where it works. */}
+                                <code className="select-all">{editingConfig[prop.openUrlFrom]}</code>
+                              </span>
+                            )}
+                          </div>
                         )}
                         <Editor
                           height={maximized ? '60vh' : '360px'}
@@ -514,6 +578,54 @@ export function NodeConfigModal({
               })()
             )}
           </div>
+          {importing && (
+            <div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10">
+              <div className="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-lg max-h-[70vh] flex flex-col">
+                <div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-slate-700">
+                  <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
+                    {IMPORT_SOURCES[importing.source]?.actionLabel}
+                  </h3>
+                  <button
+                    onClick={() => setImporting(null)}
+                    className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded"
+                  >
+                    <X className="w-4 h-4 text-gray-500 dark:text-gray-400" />
+                  </button>
+                </div>
+
+                <div className="flex-1 overflow-y-auto p-3 space-y-1">
+                  {importing.error && (
+                    <p className="text-sm text-red-600 dark:text-red-400">{importing.error}</p>
+                  )}
+
+                  {!importing.error && importing.choices === null && (
+                    <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      Reading from the service
+                    </div>
+                  )}
+
+                  {!importing.error && importing.choices?.length === 0 && (
+                    <p className="text-sm text-gray-500 dark:text-gray-400">
+                      {IMPORT_SOURCES[importing.source]?.emptyMessage}
+                    </p>
+                  )}
+
+                  {importing.choices?.map((choice) => (
+                    <button
+                      key={choice.id}
+                      onClick={() => applyImport(choice)}
+                      className="w-full text-left px-3 py-2 rounded hover:bg-gray-100 dark:hover:bg-slate-700"
+                    >
+                      <div className="text-sm text-gray-900 dark:text-gray-100">{choice.label}</div>
+                      <div className="text-xs text-gray-500 dark:text-gray-400">{choice.detail}</div>
+                    </button>
+                  ))}
+                </div>
+              </div>
+            </div>
+          )}
+
           <div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-slate-700">
             <button
               onClick={onClose}

+ 158 - 0
webui/src/config/importSources.ts

@@ -0,0 +1,158 @@
+import { api } from '../api/client'
+
+// Some node values are authored somewhere else and copied here - a ComfyUI
+// graph, and whatever comes after it. Rather than teach the config form about
+// each service, a field names a source in its schema and the knowledge of that
+// one service lives here.
+//
+// Everything goes through the server's proxy: the services involved are on
+// other origins, and a browser cannot read most of them.
+
+export interface ImportChoice {
+  id: string
+  label: string
+  detail: string
+}
+
+export interface ImportSource {
+  // Shown on the button that opens the picker
+  actionLabel: string
+  // Which config field holds the address of the service
+  addressField: string
+  // What to say when the service has nothing to offer
+  emptyMessage: string
+  list: (address: string) => Promise<ImportChoice[]>
+  fetch: (address: string, id: string) => Promise<string>
+}
+
+function trimSlashes(url: string): string {
+  return url.replace(/\/+$/, '')
+}
+
+async function proxyGet(url: string): Promise<any> {
+  const response = await api.get('/proxy', { params: { url } })
+  return response.data
+}
+
+function formatWhen(value: number): string {
+  if (!value) return 'unknown time'
+  // ComfyUI records this in milliseconds. Anything small enough to predate 2001
+  // as milliseconds is taken to be seconds instead.
+  const ms = value > 1e12 ? value : value * 1000
+  return new Date(ms).toLocaleString()
+}
+
+
+// A run records which workflow produced it only as an id. The names live in
+// the workflow files ComfyUI keeps, so the ids seen in the history are looked
+// up there - reading no more files than it takes to name them all.
+async function resolveWorkflowNames(
+  base: string,
+  wanted: Set<string>
+): Promise<Map<string, string>> {
+  const names = new Map<string, string>()
+  if (wanted.size === 0) return names
+
+  let files: string[]
+  try {
+    files = await proxyGet(`${base}/api/userdata?dir=workflows&recurse=true&split=false`)
+  } catch {
+    return names   // without names the runs are still listed, just less well
+  }
+  if (!Array.isArray(files)) return names
+
+  const batchSize = 6
+  for (let i = 0; i < files.length && names.size < wanted.size; i += batchSize) {
+    const batch = files.slice(i, i + batchSize)
+    await Promise.all(
+      batch.map(async (path) => {
+        if (typeof path !== 'string') return
+        try {
+          const encoded = encodeURIComponent(`workflows/${path}`)
+          const workflow = await proxyGet(`${base}/api/userdata/${encoded}`)
+          if (workflow?.id && wanted.has(workflow.id)) {
+            names.set(workflow.id, path.replace(/\.json$/, ''))
+          }
+        } catch {
+          // A file that cannot be read simply goes unnamed.
+        }
+      })
+    )
+  }
+
+  return names
+}
+
+function firstInputValue(graph: Record<string, any>, key: string): string {
+  for (const node of Object.values(graph) as any[]) {
+    const value = node?.inputs?.[key]
+    if (typeof value === 'string' && value) return value
+  }
+  return ''
+}
+
+export const IMPORT_SOURCES: Record<string, ImportSource> = {
+  // ComfyUI keeps every graph it has run, stored exactly as it was submitted -
+  // which is the API format this node holds. So a run can be imported as it is,
+  // with nothing converted or guessed. Build the workflow in ComfyUI, run it
+  // once, and it is here.
+  comfyui: {
+    actionLabel: 'Import from ComfyUI',
+    addressField: 'baseUrl',
+    emptyMessage:
+      'ComfyUI remembers no runs. Run the workflow there once, and it can be imported exactly as it was submitted.',
+
+    async list(address) {
+      const base = trimSlashes(address)
+      const history = await proxyGet(`${base}/history?max_items=30`)
+      if (!history || typeof history !== 'object') return []
+
+      const runs = Object.entries(history).map(([id, entry]: [string, any]) => {
+        const prompt = entry?.prompt || []
+        const graph = prompt[2] || {}
+        const extra = prompt[3] || {}
+
+        return {
+          id,
+          workflowId: extra?.extra_pnginfo?.workflow?.id || '',
+          outputPrefix: firstInputValue(graph, 'filename_prefix'),
+          nodeCount: Object.keys(graph).length,
+          status: entry?.status?.status_str || '',
+          fromHere: extra.client_id === 'smartbotic-node',
+          startedAt: extra.create_time || 0,
+        }
+      })
+
+      runs.sort((a, b) => b.startedAt - a.startedAt)
+
+      const names = await resolveWorkflowNames(
+        base,
+        new Set(runs.map((run) => run.workflowId).filter(Boolean))
+      )
+
+      return runs.map((run) => ({
+        id: run.id,
+        // A run submitted by a node here belongs to no saved workflow, so it is
+        // named by what it wrote instead.
+        label: names.get(run.workflowId) || run.outputPrefix || `${run.nodeCount} nodes`,
+        detail: [
+          formatWhen(run.startedAt),
+          `${run.nodeCount} nodes`,
+          run.status,
+          run.fromHere ? 'submitted from here' : '',
+        ]
+          .filter(Boolean)
+          .join(' · '),
+      }))
+    },
+
+    async fetch(address, id) {
+      const entry = await proxyGet(`${trimSlashes(address)}/history/${id}`)
+      const graph = entry?.[id]?.prompt?.[2]
+      if (!graph) {
+        throw new Error('ComfyUI no longer remembers that run')
+      }
+      return JSON.stringify({ prompt: graph }, null, 2)
+    },
+  },
+}