Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/hal0/api/routes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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://<host>: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://<host>: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.
Expand All @@ -153,6 +179,7 @@ async def get_urls(request: Request) -> dict[str, object]:
"openwebui_enabled": true | false,
"hermes": "" | "https://hermes.<host>",
"hermes_enabled": true | false,
"comfyui": "http://<host>:8188" | "https://comfyui.<host>",
}

``HAL0_OPENWEBUI_PUBLIC_URL`` (set in /etc/hal0/api.env) wins
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -208,13 +237,15 @@ 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()}",
"openwebui": f"http://{host}:{_OPENWEBUI_PORT}",
"openwebui_enabled": enabled,
"hermes": hermes,
"hermes_enabled": bool(hermes),
"comfyui": comfyui,
}


Expand Down
55 changes: 55 additions & 0 deletions tests/api/test_config_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>: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://<host>: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
4 changes: 4 additions & 0 deletions ui/src/api/hooks/useConfigUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface ConfigUrls {
openwebui_enabled: boolean
hermes: string
hermes_enabled: boolean
// ComfyUI's own web UI. Defaults to http://<host>: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() {
Expand Down
86 changes: 55 additions & 31 deletions ui/src/dash/comfyui-pane.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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=<file>`
// 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 (
<div>
<BlkH icon="bolt" acc note="opens in ComfyUI ↗">workflows</BlkH>
<div className="flows">
{flows.map((f, i) => (
<button
className="flow"
key={i}
onClick={() => onLaunch && onLaunch(f.name)}
data-workflow={f.name}
>
<span className="ic"><Ci name={f.ic} size={14} /></span>
<span>{f.a}</span>
<span className="arr">→</span>
<span>{f.b}</span>
{f.tag && <span className="tag">{f.tag}</span>}
</button>
))}
{flows.map((f, i) => {
const href = workflowHref(comfyBaseUrl, f.wf)
return (
<a
className="flow"
key={i}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-disabled={href ? undefined : 'true'}
data-workflow={f.name}
>
<span className="ic"><Ci name={f.ic} size={14} /></span>
<span>{f.a}</span>
<span className="arr">→</span>
<span>{f.b}</span>
{f.tag && <span className="tag">{f.tag}</span>}
</a>
)
})}
</div>
</div>
)
Expand Down Expand Up @@ -393,7 +412,6 @@ export function ImageGenCard({
onCancel,
onRestart,
onLogs,
onLaunch,
comfyBaseUrl,
}) {
const { engine, run, queue, gtt, ram, stats } = mock
Expand Down Expand Up @@ -584,7 +602,7 @@ export function ImageGenCard({

{/* ── Workflows + Models lower section ── */}
<div className="wcard-sub">
<WorkflowsBlock onLaunch={onLaunch} />
<WorkflowsBlock comfyBaseUrl={comfyBaseUrl} />
<ModelsBlock />
</div>
</div>
Expand Down Expand Up @@ -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://<request-host>: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 (
Expand All @@ -656,7 +681,6 @@ export function ComfyuiPane() {
})
.catch(() => alert('logs fetch failed'))
}}
onLaunch={(name) => launchMutation.mutate(name)}
comfyBaseUrl={comfyBaseUrl}
/>
</div>
Expand Down
29 changes: 14 additions & 15 deletions ui/tests/e2e/specs/comfyui-arbiter-v3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<file> 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 ────────────────────
Expand Down