From ba7e633f739a1ce64f854efa348c725d89e28188 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 17 Jun 2026 00:08:13 -0400 Subject: [PATCH] fix(comfyui): real ComfyUI link + workflow tags open the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard ComfyUI card derived its open-link from the /status `endpoint` field (":8188"), which old code turned into http://127.0.0.1:8188 — the *server's* loopback, dead from a browser. And the six workflow tags POSTed to /workflows/{name}/launch with names ("qwen-image"…) that don't match the real files, so they 404'd silently — the "tags aren't linked" report. - Backend: /api/config/urls now returns a `comfyui` key (env HAL0_COMFYUI_PUBLIC_URL, else http://:8188, proxy-aware, port-stripped behind a proxy) — mirroring the openwebui pattern so a reverse-proxy deploy can hand back a clean HTTPS URL and avoid the mixed-content block an HTTPS dashboard hits on a bare :8188 link. - Frontend: comfyBaseUrl now comes from useConfigUrls().comfyui (with a window.location:8188 fallback for the pre-config tick), not the dead loopback. The workflow tags become anchor links that open ComfyUI's editor (matching the block's "opens in ComfyUI ↗" label) instead of the broken headless launch. Each carries a forward-compatible ?workflow= breadcrumb (ComfyUI#9858) pointing at the curated workflow — ignored by current ComfyUI, auto-upgrades to a true deep-link if upstream ships it. True per-workflow auto-open via URL is upstream-blocked (comfyanonymous/ComfyUI#9858); tags open the editor where the curated workflows are now pickable. Backend launch route kept (still tested). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hal0/api/routes/config.py | 31 +++++++ tests/api/test_config_urls.py | 55 ++++++++++++ ui/src/api/hooks/useConfigUrls.ts | 4 + ui/src/dash/comfyui-pane.jsx | 86 ++++++++++++------- ui/tests/e2e/specs/comfyui-arbiter-v3.spec.ts | 29 +++---- 5 files changed, 159 insertions(+), 46 deletions(-) diff --git a/src/hal0/api/routes/config.py b/src/hal0/api/routes/config.py index 3f4b6536..3c11bc0b 100644 --- a/src/hal0/api/routes/config.py +++ b/src/hal0/api/routes/config.py @@ -52,6 +52,10 @@ def _validation_error_details(exc: ValidationError) -> dict[str, str]: # /etc/hal0/api.env; OpenWebUI's port is fixed at 3001 in the unit. _DEFAULT_API_PORT = 8080 _OPENWEBUI_PORT = 3001 +# ComfyUI's own web UI binds :8188 on the runtime host (the img slot's +# container publishes there). Like OpenWebUI it's browser-reachable on the +# LAN, so it gets a host:port fallback; HAL0_COMFYUI_PUBLIC_URL overrides it. +_COMFYUI_PORT = 8188 _OPENWEBUI_UNIT = "hal0-openwebui.service" @@ -140,6 +144,28 @@ def _host_without_port(host: str) -> str: return host.rsplit(":", 1)[0] if ":" in host else host +def _comfyui_link(request: Request) -> str: + """Resolve the link the dashboard should use to open ComfyUI. + + ``HAL0_COMFYUI_PUBLIC_URL`` (set in /etc/hal0/api.env) wins when + defined — it's how a reverse-proxy deploy declares a clean HTTPS + hostname (e.g. ``https://comfyui.thinmint.dev``). That matters because + an HTTPS dashboard opening the bare ``http://:8188`` fallback is + blocked by the browser as mixed content, so the workflow links appear + dead. Without the env var we fall back to ``http://:8188`` on the + request host (the port-stripped forwarded host behind a proxy), which + works LAN-direct. + """ + public = os.environ.get("HAL0_COMFYUI_PUBLIC_URL", "").strip().rstrip("/") + if public: + return public + if _behind_proxy(request): + host = request.headers.get("x-forwarded-host") or _resolve_host(request) + else: + host = _resolve_host(request) + return f"http://{_host_without_port(host)}:{_COMFYUI_PORT}" + + @router.get("/urls") async def get_urls(request: Request) -> dict[str, object]: """Return the canonical URLs the dashboard should advertise. @@ -153,6 +179,7 @@ async def get_urls(request: Request) -> dict[str, object]: "openwebui_enabled": true | false, "hermes": "" | "https://hermes.", "hermes_enabled": true | false, + "comfyui": "http://:8188" | "https://comfyui.", } ``HAL0_OPENWEBUI_PUBLIC_URL`` (set in /etc/hal0/api.env) wins @@ -182,6 +209,7 @@ async def get_urls(request: Request) -> dict[str, object]: hermes = os.environ.get("HAL0_HERMES_PUBLIC_URL", "").strip().rstrip("/") host = _resolve_host(request) enabled = await _openwebui_is_active() + comfyui = _comfyui_link(request) if public: if _behind_proxy(request): @@ -196,6 +224,7 @@ async def get_urls(request: Request) -> dict[str, object]: "openwebui_enabled": enabled, "hermes": hermes, "hermes_enabled": bool(hermes), + "comfyui": comfyui, } if _behind_proxy(request): @@ -208,6 +237,7 @@ async def get_urls(request: Request) -> dict[str, object]: "openwebui_enabled": enabled, "hermes": hermes, "hermes_enabled": bool(hermes), + "comfyui": comfyui, } return { "api": f"http://{host}:{_api_port()}", @@ -215,6 +245,7 @@ async def get_urls(request: Request) -> dict[str, object]: "openwebui_enabled": enabled, "hermes": hermes, "hermes_enabled": bool(hermes), + "comfyui": comfyui, } diff --git a/tests/api/test_config_urls.py b/tests/api/test_config_urls.py index 3bf050b6..29acab5d 100644 --- a/tests/api/test_config_urls.py +++ b/tests/api/test_config_urls.py @@ -147,3 +147,58 @@ def test_urls_hermes_advertised_even_behind_proxy( body = resp.json() assert body["hermes"] == "https://hermes.example.com", body assert body["hermes_enabled"] is True, body + + +def test_urls_comfyui_lan_direct_default_port_8188(client: TestClient) -> None: + """ComfyUI's own web UI is advertised at the request host on :8188. + + The dashboard is served from :8080; ComfyUI's frontend lives on the + runtime host's :8188, so a LAN-direct hit derives ``http://:8188``. + """ + resp = client.get("/api/config/urls", headers={"host": "hal0-test.lan:8080"}) + assert resp.status_code == 200 + body = resp.json() + assert body["comfyui"] == "http://hal0-test.lan:8188", body + + +def test_urls_comfyui_public_url_env_wins( + monkeypatch, + client: TestClient, +) -> None: + """HAL0_COMFYUI_PUBLIC_URL is the canonical override. + + This is how a reverse-proxy deploy points the ComfyUI link at a clean + HTTPS hostname (e.g. ``https://comfyui.thinmint.dev``) instead of the + mixed-content ``http://:8188`` that a browser on an HTTPS + dashboard would block. + """ + monkeypatch.setenv("HAL0_COMFYUI_PUBLIC_URL", "https://comfyui.thinmint.dev/") + resp = client.get( + "/api/config/urls", + headers={ + "x-forwarded-host": "hal0.thinmint.dev", + "x-forwarded-proto": "https", + }, + ) + assert resp.status_code == 200 + body = resp.json() + # Trailing slash stripped so links concat predictably. + assert body["comfyui"] == "https://comfyui.thinmint.dev", body + + +def test_urls_comfyui_behind_proxy_without_env_uses_port_8188(client: TestClient) -> None: + """Proxy deploys without the env var still get a host:8188 link. + + The port-stripped forwarded host keeps the link reachable on the LAN + even before an operator declares a dedicated ComfyUI subdomain. + """ + resp = client.get( + "/api/config/urls", + headers={ + "x-forwarded-host": "hal0.thinmint.dev", + "x-forwarded-proto": "https", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["comfyui"] == "http://hal0.thinmint.dev:8188", body diff --git a/ui/src/api/hooks/useConfigUrls.ts b/ui/src/api/hooks/useConfigUrls.ts index fb61d0b4..8b5bcbf7 100644 --- a/ui/src/api/hooks/useConfigUrls.ts +++ b/ui/src/api/hooks/useConfigUrls.ts @@ -21,6 +21,10 @@ export interface ConfigUrls { openwebui_enabled: boolean hermes: string hermes_enabled: boolean + // ComfyUI's own web UI. Defaults to http://:8188 (the runtime + // host's bound port); a reverse-proxy deploy sets HAL0_COMFYUI_PUBLIC_URL + // to a clean HTTPS hostname so the link isn't blocked as mixed content. + comfyui: string } export function useConfigUrls() { diff --git a/ui/src/dash/comfyui-pane.jsx b/ui/src/dash/comfyui-pane.jsx index 27d8bd1a..de1408a9 100644 --- a/ui/src/dash/comfyui-pane.jsx +++ b/ui/src/dash/comfyui-pane.jsx @@ -18,10 +18,10 @@ import { useComfyui, useComfyuiRenderCancel, useComfyuiRestart, - useComfyuiWorkflowLaunch, transformComfyuiStatus, COMFYUI_FALLBACK, } from '@/api/hooks/useComfyui' +import { useConfigUrls } from '@/api/hooks/useConfigUrls' const { useState, useEffect, useRef } = React @@ -226,34 +226,53 @@ function StepPips({ step, total }) { } // ── Workflows quick-launch ───────────────────────────────────────────────────── +// Each tag opens ComfyUI's editor (the block label is literally "opens in +// ComfyUI ↗"). `wf` is the real curated workflow file (graph format, in +// user/default/workflows) — passed as ComfyUI's proposed `?workflow=` +// param (upstream comfyanonymous/ComfyUI#9858). That param is harmlessly +// ignored by current ComfyUI (the link just opens the editor, where the +// converted workflows are pickable from the browser) and auto-upgrades to a +// true deep-link if/when #9858 lands. `upscale-4x` has no curated file yet, +// so it opens the editor root. const FLOWS_DEFAULT = [ - { ic: 'image', a: 'text', b: 'image', tag: 'qwen-image', name: 'qwen-image' }, - { ic: 'image', a: 'image', b: 'image', name: 'img2img' }, - { ic: 'video', a: 'text', b: 'video', tag: 'wan 2.2', name: 'wan2.2-t2v' }, - { ic: 'video', a: 'image', b: 'video', tag: 'i2v', name: 'wan2.2-i2v' }, - { ic: 'layers', a: 'still', b: 'animate', tag: 'chain', name: 'animate' }, + { ic: 'image', a: 'text', b: 'image', tag: 'qwen-image', name: 'qwen-image', wf: 'Qwen-Image-2512-BF16-4-Step-LoRA.json' }, + { ic: 'image', a: 'image', b: 'image', name: 'img2img', wf: 'Qwen-Image-Edit-2511-BF16-4-Step-LoRA.json' }, + { ic: 'video', a: 'text', b: 'video', tag: 'wan 2.2', name: 'wan2.2-t2v', wf: 'Wan2.2-T2V-A14B-FP16-4steps-lora-rank64-Seko-V2.json' }, + { ic: 'video', a: 'image', b: 'video', tag: 'i2v', name: 'wan2.2-i2v', wf: 'Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1-FP16.json' }, + { ic: 'layers', a: 'still', b: 'animate', tag: 'chain', name: 'animate', wf: 'Hunyuan-Video-1.5_720p_i2v-4-step-lora.json' }, { ic: 'cube', a: 'upscale',b: '4×', name: 'upscale-4x' }, ] -function WorkflowsBlock({ flows = FLOWS_DEFAULT, onLaunch }) { +function workflowHref(comfyBaseUrl, wf) { + if (!comfyBaseUrl) return undefined + return wf ? `${comfyBaseUrl}/?workflow=${encodeURIComponent(wf)}` : comfyBaseUrl +} + +function WorkflowsBlock({ flows = FLOWS_DEFAULT, comfyBaseUrl }) { return (
workflows
- {flows.map((f, i) => ( - - ))} + {flows.map((f, i) => { + const href = workflowHref(comfyBaseUrl, f.wf) + return ( + + + {f.a} + + {f.b} + {f.tag && {f.tag}} + + ) + })}
) @@ -393,7 +412,6 @@ export function ImageGenCard({ onCancel, onRestart, onLogs, - onLaunch, comfyBaseUrl, }) { const { engine, run, queue, gtt, ram, stats } = mock @@ -584,7 +602,7 @@ export function ImageGenCard({ {/* ── Workflows + Models lower section ── */}
- +
@@ -626,14 +644,21 @@ export function ComfyuiPane() { // Control mutations const cancelMutation = useComfyuiRenderCancel() const restartMutation = useComfyuiRestart() - const launchMutation = useComfyuiWorkflowLaunch() - // Derive the ComfyUI base URL from live status endpoint field - const comfyBaseUrl = liveStatus?.endpoint - ? liveStatus.endpoint.startsWith('http') - ? liveStatus.endpoint - : `http://127.0.0.1:8188` - : undefined + // Resolve the browser-reachable ComfyUI base URL. The authoritative source + // is /api/config/urls → `comfyui` (HAL0_COMFYUI_PUBLIC_URL, or + // http://:8188): the backend knows the real runtime host and + // can hand back a clean HTTPS link, avoiding the mixed-content block an + // HTTPS dashboard hits on a bare :8188 URL. We deliberately do NOT trust the + // /status `endpoint` field — it reports ":8188" which old code turned into + // http://127.0.0.1:8188 (the *server's* loopback, dead from a browser). The + // window.location fallback only covers the pre-config-load tick. + const { data: cfgUrls } = useConfigUrls() + const comfyBaseUrl = + cfgUrls?.comfyui || + (typeof window !== 'undefined' && window.location + ? `http://${window.location.hostname}:8188` + : undefined) // .comfy-pane kept for backward-compat (some mount selectors still use it). return ( @@ -656,7 +681,6 @@ export function ComfyuiPane() { }) .catch(() => alert('logs fetch failed')) }} - onLaunch={(name) => launchMutation.mutate(name)} comfyBaseUrl={comfyBaseUrl} /> diff --git a/ui/tests/e2e/specs/comfyui-arbiter-v3.spec.ts b/ui/tests/e2e/specs/comfyui-arbiter-v3.spec.ts index cb03fac6..2d6c2a8b 100644 --- a/ui/tests/e2e/specs/comfyui-arbiter-v3.spec.ts +++ b/ui/tests/e2e/specs/comfyui-arbiter-v3.spec.ts @@ -160,28 +160,27 @@ test.describe('ComfyUI V2 live-wired pane (Task 5.2)', () => { expect(posts[0]).toBe('POST') }) - // ── 5. Workflow chip fires POST /api/comfyui/workflows/{name}/launch ────── - - test('workflow chip click fires POST /workflows/{name}/launch', async ({ page }) => { - const launched: string[] = [] + // ── 5. Workflow chip is a link that opens ComfyUI ──────────────────────── + // The tags open ComfyUI's editor (matching the "opens in ComfyUI ↗" label). + // True per-workflow auto-open via URL is blocked upstream + // (comfyanonymous/ComfyUI#9858); the ?workflow= param is a + // forward-compatible breadcrumb that current ComfyUI ignores. + test('workflow chip is an anchor that opens ComfyUI in a new tab', async ({ page }) => { await page.route('**/api/comfyui/status', (route: any) => json(route, comfyV2Status())) - await page.route(/\/api\/comfyui\/workflows\/[^/]+\/launch/, (route: any) => { - const url = route.request().url() - // extract name from URL - const match = url.match(/\/workflows\/([^/]+)\/launch/) - if (match) launched.push(decodeURIComponent(match[1])) - return json(route, { status: 'queued', prompt_id: 'mock-id' }, 202) - }) await gotoImageTab(page) - // Click the first workflow chip (qwen-image) const chips = page.locator('.comfy-v2-pane .flow') await expect(chips).toHaveCount(6) - await chips.first().click() - await expect.poll(() => launched.length, { timeout: 5_000 }).toBeGreaterThan(0) - expect(launched[0]).toBe('qwen-image') + const first = chips.first() + // Renders as a link, opens in a new tab. + expect((await first.evaluate((el: Element) => el.tagName)).toLowerCase()).toBe('a') + await expect(first).toHaveAttribute('target', '_blank') + // Points at ComfyUI (:8188) and carries the curated workflow breadcrumb. + const href = await first.getAttribute('href') + expect(href).toContain(':8188') + expect(href).toContain('workflow=') }) // ── 6. Restart button fires POST /api/comfyui/restart ────────────────────