From 91948dd3296901fcd0f1ae5c1e6753c330ebe2e9 Mon Sep 17 00:00:00 2001 From: Towsty Date: Thu, 27 Aug 2026 22:56:29 -0500 Subject: [PATCH] Start Sidecar Qwen VL from Recommend when the prompt enhancer is offline. Co-authored-by: Cursor --- pages/index.vue | 8 +- server/utils/imageComfyLifecycle.ts | 117 +++++++++++++++++++++++++++- server/utils/promptComfy.ts | 5 ++ 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/pages/index.vue b/pages/index.vue index c6b9df9..4e2db1b 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1848,14 +1848,13 @@ const recommendDisabled = computed(() => { if (recommendBusy.value) return true if (!file.value || !prompt.value.trim()) return true if (!imageSidecarConfigured.value) return true - if (!imageSidecarOk.value) return true return false }) const recommendHint = computed(() => { if (!imageSidecarConfigured.value) return 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST.' - if (!imageSidecarOk.value) return 'Sidecar Qwen VL is offline.' if (!file.value) return 'Load a still first.' if (!prompt.value.trim()) return 'Type a draft prompt to enhance.' + if (!imageSidecarOk.value) return 'Sidecar is offline. Recommend will start Qwen VL on 192.168.77.101.' if (imageSidecarBusy.value) return 'Sidecar is busy — this may wait in its queue.' return 'Send this still and draft to Sidecar Qwen VL.' }) @@ -2507,7 +2506,9 @@ async function recommendPrompt() { recommendError.value = '' recommendText.value = '' recommendApplied.value = false - recommendMessage.value = 'Uploading still to Sidecar Qwen VL…' + recommendMessage.value = imageSidecarOk.value + ? 'Uploading still to Sidecar Qwen VL…' + : 'Sidecar is offline. Starting Qwen VL…' const body = new FormData() body.append('image', file.value) body.append('prompt', prompt.value) @@ -2542,6 +2543,7 @@ async function recommendPrompt() { } finally { recommendBusy.value = false if (recommendJobId.value) recommendJobId.value = '' + void pollComfyHealth() } } diff --git a/server/utils/imageComfyLifecycle.ts b/server/utils/imageComfyLifecycle.ts index 27582a2..88c87be 100644 --- a/server/utils/imageComfyLifecycle.ts +++ b/server/utils/imageComfyLifecycle.ts @@ -1,4 +1,4 @@ -import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured, getBeastImageHost, withImageComfyHost, type ImageEditBox } from '~/server/utils/imageComfy' +import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured, getBeastImageHost, getSidecarImageHost, withImageComfyHost, type ImageEditBox } from '~/server/utils/imageComfy' export type ImageComfyState = 'online' | 'booting' | 'busy' | 'starting' | 'offline' @@ -229,6 +229,121 @@ export function ensureImageComfyReady(onStatus: StatusFn = () => undefined) { return run } +let sidecarGate: Promise = Promise.resolve() +let sidecarLastStartAt = 0 + +function sidecarControlSettings() { + const config = useRuntimeConfig() + return { + host: getSidecarImageHost(), + controlUrl: String(config.imageComfyControlUrl || process.env.IMAGE_COMFY_CONTROL_URL || '').replace(/\/$/, ''), + controlToken: String(config.imageComfyControlToken || process.env.IMAGE_COMFY_CONTROL_TOKEN || ''), + healthTimeoutMs: 2500, + startTimeoutMs: Number(process.env.IMAGE_COMFY_START_TIMEOUT_MS || 180_000) + } +} + +async function sidecarControlRequest(path: string, method = 'GET') { + const { controlUrl, controlToken } = sidecarControlSettings() + if (!controlUrl) return null + try { + const res = await fetch(`${controlUrl}${path}`, { + method, + headers: { + Accept: 'application/json', + ...(controlToken ? { Authorization: `Bearer ${controlToken}` } : {}) + }, + signal: AbortSignal.timeout(5000) + }) + if (!res.ok) return { ok: false, status: res.status } + return await res.json() as { + ok?: boolean + http?: boolean + process?: boolean + started?: boolean + already?: boolean + } + } catch { + return null + } +} + +async function startSidecarComfy() { + const now = Date.now() + if (now - sidecarLastStartAt < 15_000) { + log('sidecar-start-skipped', { reason: 'recent-start' }) + return { started: false, reason: 'recent-start' as const } + } + sidecarLastStartAt = now + const remote = await sidecarControlRequest('/start', 'POST') + if (remote?.ok || remote?.started) { + log('sidecar-start-remote', {}) + return { started: true, reason: 'remote' as const } + } + return { started: false, reason: 'no-launcher' as const } +} + +async function ensureSidecarUnlocked(onStatus: StatusFn) { + const cfg = sidecarControlSettings() + const host = cfg.host + if (!host) { + throw createError({ + statusCode: 503, + statusMessage: 'Sidecar Qwen VL is not configured. Set IMAGE_COMFY_HOST or IMAGE_COMFY_FALLBACK_HOST.' + }) + } + + onStatus({ state: 'offline', message: 'Checking Sidecar Qwen VL…', host, box: 'sidecar' }) + const health = await checkImageComfyHttp(cfg.healthTimeoutMs, host) + if (health.ok) { + onStatus({ state: 'online', message: 'Sidecar Qwen VL online', host, box: 'sidecar' }) + return + } + + onStatus({ + state: 'starting', + message: 'Sidecar is offline. Starting Qwen VL on 192.168.77.101…', + host, + box: 'sidecar' + }) + const started = await startSidecarComfy() + if (!started.started && started.reason === 'no-launcher') { + throw createError({ + statusCode: 503, + statusMessage: 'Sidecar is offline and the host agent did not start it. Confirm the agent on 192.168.77.101:8199 is running.' + }) + } + + const deadline = Date.now() + cfg.startTimeoutMs + let attempt = 0 + while (Date.now() < deadline) { + attempt += 1 + onStatus({ + state: 'starting', + message: `Starting Sidecar Qwen VL… waiting for Comfy (${attempt})`, + host, + box: 'sidecar' + }) + const next = await checkImageComfyHttp(cfg.healthTimeoutMs, host) + if (next.ok) { + onStatus({ state: 'online', message: 'Sidecar Qwen VL online', host, box: 'sidecar', processRunning: true }) + return + } + await sleep(2000) + } + + throw createError({ + statusCode: 503, + statusMessage: 'Started Sidecar Qwen VL but it never answered. The 101 box can take a couple of minutes to load after a cold boot.' + }) +} + +export function ensureSidecarReady(onStatus: StatusFn = () => undefined) { + const run = sidecarGate.then(() => ensureSidecarUnlocked(onStatus)) + sidecarGate = run.then(() => undefined, () => undefined) + return run +} + export async function pickImageEditHost(onStatus: StatusFn = () => undefined): Promise { const beast = getBeastImageHost() if (!beast) { diff --git a/server/utils/promptComfy.ts b/server/utils/promptComfy.ts index 4a87f4a..1d981ba 100644 --- a/server/utils/promptComfy.ts +++ b/server/utils/promptComfy.ts @@ -1,4 +1,5 @@ import { getSidecarImageHost } from '~/server/utils/imageComfy' +import { ensureSidecarReady } from '~/server/utils/imageComfyLifecycle' import { buildVisionPromptWorkflow } from '~/server/utils/promptWorkflow' import { normalizeShotScript } from '~/utils/parseRecommend' @@ -220,6 +221,10 @@ export async function runPromptRecommend(job: PromptJob, params: { mode: string }) { job.status = 'running' + job.message = 'Checking Sidecar Qwen VL…' + await ensureSidecarReady((status) => { + job.message = status.message + }) job.message = 'Uploading still to Sidecar Qwen VL…' const imageName = await uploadPromptImage(params.image, job.id) job.message = 'Qwen VL is reading the still…'