| |
| |
|
|
| const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "lost"]); |
| const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]); |
| const DEFAULT_LOCAL_COMFY_BASE = "http://127.0.0.1:8188"; |
| const LOCAL_SETUP_FILE_FLAG = "__koolookLocalSetupFile"; |
|
|
| function sleep(ms) { |
| return new Promise(resolve => setTimeout(resolve, ms)); |
| } |
|
|
| function normalizeBaseUrl(value) { |
| return String(value || "").trim().replace(/\/+$/, ""); |
| } |
|
|
| function apiUrl(baseUrl, path) { |
| return `${normalizeBaseUrl(baseUrl)}${path}`; |
| } |
|
|
| function isFilePage() { |
| return globalThis.location?.protocol === "file:"; |
| } |
|
|
| function defaultBaseUrl() { |
| return isFilePage() ? DEFAULT_LOCAL_COMFY_BASE : ""; |
| } |
|
|
| async function readJsonResponse(response, fallbackMessage) { |
| let body; |
| try { |
| body = await response.json(); |
| } catch (error) { |
| const message = fallbackMessage || `Request failed (${response.status}).`; |
| const responseError = new Error(`${message} Response was not valid JSON.`); |
| responseError.status = response.status; |
| throw responseError; |
| } |
| if (!response.ok || body.ok === false) { |
| const errors = Array.isArray(body.errors) && body.errors.length |
| ? body.errors |
| : [fallbackMessage || `Request failed (${response.status}).`]; |
| const error = new Error(errors.join("; ")); |
| error.payload = body; |
| error.status = response.status; |
| throw error; |
| } |
| return body; |
| } |
|
|
| function responseShapeError(message, payload) { |
| const error = new Error(message); |
| error.payload = payload; |
| return error; |
| } |
|
|
| function requireObject(value, message) { |
| if (value && typeof value === "object" && !Array.isArray(value)) return value; |
| throw responseShapeError(message, value); |
| } |
|
|
| function isPublishedSetupRecord(value) { |
| return value |
| && typeof value === "object" |
| && !Array.isArray(value) |
| && typeof value.id === "string" |
| && value.metadata |
| && typeof value.metadata === "object" |
| && value.visualGraph |
| && typeof value.visualGraph === "object" |
| && value.apiPrompt |
| && typeof value.apiPrompt === "object" |
| && !Array.isArray(value.apiPrompt) |
| && value.setupSurface |
| && typeof value.setupSurface === "object" |
| && value.source |
| && typeof value.source === "object"; |
| } |
|
|
| function setupFileValidationError(value) { |
| if (!value || typeof value !== "object" || Array.isArray(value)) { |
| return "record must be an object"; |
| } |
| if (typeof value.id !== "string" || !value.id.trim()) return "id must be text"; |
| if (!value.metadata || typeof value.metadata !== "object" || Array.isArray(value.metadata)) return "metadata must be an object"; |
| if (!value.visualGraph || typeof value.visualGraph !== "object" || Array.isArray(value.visualGraph)) return "visualGraph must be an object"; |
| if (!value.apiPrompt || typeof value.apiPrompt !== "object" || Array.isArray(value.apiPrompt)) return "apiPrompt must be an object"; |
| if (!value.setupSurface || typeof value.setupSurface !== "object" || Array.isArray(value.setupSurface)) return "setupSurface must be an object"; |
| if (!value.source || typeof value.source !== "object" || Array.isArray(value.source)) return "source must be an object"; |
| return "record is invalid"; |
| } |
|
|
| export function setupsFromPublishedSetupFile(value) { |
| let setups; |
| if (Array.isArray(value)) { |
| setups = value; |
| } else if (value && typeof value === "object" && !Array.isArray(value) && ("id" in value || "schemaVersion" in value)) { |
| if (!isPublishedSetupRecord(value)) { |
| throw responseShapeError(`Setup file contains an invalid published setup record: ${setupFileValidationError(value)}.`, value); |
| } |
| setups = [value]; |
| } else if (value && typeof value === "object" && Array.isArray(value.setups)) { |
| setups = value.setups; |
| } else { |
| throw responseShapeError("Setup file must contain one published setup record or an object with a setups array.", value); |
| } |
| const invalid = setups.find(setup => !isPublishedSetupRecord(setup)); |
| if (invalid) { |
| throw responseShapeError(`Setup file contains an invalid published setup record: ${setupFileValidationError(invalid)}.`, invalid); |
| } |
| return setups; |
| } |
|
|
| export function localSetupFileRecords(value) { |
| return setupsFromPublishedSetupFile(value).map(setup => ({ |
| ...setup, |
| [LOCAL_SETUP_FILE_FLAG]: true, |
| })); |
| } |
|
|
| export function setupCanRunFromRegistry(setup) { |
| return !Boolean(setup?.[LOCAL_SETUP_FILE_FLAG]); |
| } |
|
|
| export function localSetupRunMessage(setup) { |
| const title = setupDisplayTitle(setup); |
| return [ |
| `"${title}" was loaded from a file for contract review.`, |
| "", |
| "The simulator can render local setup files, but Run uses the live Koolook registry API.", |
| "Publish this setup into the running ComfyUI registry, then load it from the API before running it.", |
| ].join("\n"); |
| } |
|
|
| function requireRunPayload(value, message, { requirePromptId = false } = {}) { |
| const run = requireObject(value, message); |
| if (typeof run.runId !== "string" || !run.runId.trim()) { |
| throw responseShapeError(`${message} Missing runId.`, run); |
| } |
| if (typeof run.status !== "string" || !run.status.trim()) { |
| throw responseShapeError(`${message} Missing status.`, run); |
| } |
| if (requirePromptId && (typeof run.promptId !== "string" || !run.promptId.trim())) { |
| throw responseShapeError(`${message} Missing promptId.`, run); |
| } |
| if ("promptId" in run && run.promptId != null && typeof run.promptId !== "string") { |
| throw responseShapeError(`${message} promptId must be text when present.`, run); |
| } |
| return run; |
| } |
|
|
| export async function listPublishedSetups({ baseUrl = "", fetchImpl = fetch } = {}) { |
| const response = await fetchImpl(apiUrl(baseUrl, "/koolook/api/setups")); |
| const body = await readJsonResponse(response, `Setup list request failed (${response.status}).`); |
| if (Array.isArray(body)) return body; |
| if (body && typeof body === "object" && Array.isArray(body.setups)) return body.setups; |
| throw responseShapeError("Setup list response must be an array or an object with a setups array.", body); |
| } |
|
|
| export async function getPublishedSetup({ setupId, baseUrl = "", fetchImpl = fetch }) { |
| const id = typeof setupId === "string" ? setupId.trim() : ""; |
| if (!id) throw new Error("Published setup id is required."); |
| const response = await fetchImpl(apiUrl(baseUrl, `/koolook/api/setups/${encodeURIComponent(id)}`)); |
| const body = await readJsonResponse(response, `Setup detail request failed (${response.status}).`); |
| return requireObject(body, "Setup detail response must be an object."); |
| } |
|
|
| export async function runPublishedSetup({ |
| setupId, |
| inputs = {}, |
| baseUrl = "", |
| fetchImpl = fetch, |
| }) { |
| const id = typeof setupId === "string" ? setupId.trim() : ""; |
| if (!id) throw new Error("Published setup id is required."); |
| const response = await fetchImpl(apiUrl(baseUrl, `/koolook/api/setups/${encodeURIComponent(id)}/run`), { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ inputs: inputs && typeof inputs === "object" ? inputs : {} }), |
| }); |
| const body = await readJsonResponse(response, `Run request failed (${response.status}).`); |
| const payload = requireObject(body, "Run response must be an object."); |
| return requireRunPayload(payload.run, "Run response must include a valid run object.", { |
| requirePromptId: true, |
| }); |
| } |
|
|
| export async function getPublishedSetupRun({ runId, baseUrl = "", fetchImpl = fetch }) { |
| const id = typeof runId === "string" ? runId.trim() : ""; |
| if (!id) throw new Error("Published setup run id is required."); |
| const response = await fetchImpl(apiUrl(baseUrl, `/koolook/api/runs/${encodeURIComponent(id)}`)); |
| const body = await readJsonResponse(response, `Run status request failed (${response.status}).`); |
| const payload = requireObject(body, "Run status response must be an object."); |
| return requireRunPayload(payload.run, "Run status response must include a valid run object."); |
| } |
|
|
| export async function runAndPollPublishedSetup({ |
| setupId, |
| inputs = {}, |
| baseUrl = "", |
| fetchImpl = fetch, |
| intervalMs = 1000, |
| timeoutMs = 120000, |
| sleepImpl = sleep, |
| onUpdate, |
| }) { |
| const queued = await runPublishedSetup({ setupId, inputs, baseUrl, fetchImpl }); |
| if (typeof onUpdate === "function") onUpdate(queued); |
| const runId = queued?.runId; |
| if (!runId || TERMINAL_RUN_STATUSES.has(String(queued.status || "").toLowerCase())) { |
| return queued; |
| } |
| let lastRun = queued; |
| let lastProgress = runProgressSignature(queued); |
| |
| |
| |
| |
| let deadline = Date.now() + timeoutMs; |
| while (Date.now() <= deadline) { |
| await sleepImpl(intervalMs); |
| lastRun = await getPublishedSetupRun({ runId, baseUrl, fetchImpl }); |
| if (typeof onUpdate === "function") onUpdate(lastRun); |
| const status = String(lastRun?.status || "").toLowerCase(); |
| if (TERMINAL_RUN_STATUSES.has(status)) return lastRun; |
| if (ACTIVE_RUN_STATUSES.has(status)) { |
| const progress = runProgressSignature(lastRun); |
| if (progress !== lastProgress) { |
| lastProgress = progress; |
| deadline = Date.now() + timeoutMs; |
| } |
| } |
| } |
| const error = new Error(`Timed out waiting for run ${runId}.`); |
| error.run = lastRun; |
| throw error; |
| } |
|
|
| function runProgressSignature(run) { |
| if (!run || typeof run !== "object") return ""; |
| const outputs = Array.isArray(run.outputs) |
| ? run.outputs.map(output => ({ |
| key: output?.key, |
| itemCount: Array.isArray(output?.items) ? output.items.length : 0, |
| values: Array.isArray(output?.items) |
| ? output.items.map(item => item?.value ?? "").filter(Boolean) |
| : [], |
| })) |
| : []; |
| return JSON.stringify({ |
| status: String(run.status || "").toLowerCase(), |
| promptId: run.promptId || "", |
| queuePosition: run.queuePosition ?? run.position ?? null, |
| updatedAt: run.updatedAt || run.timestamp || run.lastUpdate || "", |
| outputs, |
| }); |
| } |
|
|
| export function formatRun(run) { |
| if (!run || typeof run !== "object") return "No run payload."; |
| const lines = []; |
| if (run.runId) lines.push(`Run: ${run.runId}`); |
| if (run.setupId) lines.push(`Setup: ${run.setupId}`); |
| if (run.promptId) lines.push(`Prompt: ${run.promptId}`); |
| if (run.status) lines.push(`Status: ${run.status}`); |
| const resultLines = summarizeRunResultLines(run); |
| if (resultLines.length) { |
| lines.push(""); |
| lines.push("Result:"); |
| lines.push(...resultLines); |
| } |
| if (Array.isArray(run.outputs) && run.outputs.length) { |
| lines.push(""); |
| lines.push("Outputs:"); |
| lines.push(JSON.stringify(run.outputs, null, 2)); |
| } |
| const details = {}; |
| for (const [key, value] of Object.entries(run)) { |
| if (!["runId", "setupId", "promptId", "status", "outputs"].includes(key)) { |
| details[key] = value; |
| } |
| } |
| if (Object.keys(details).length) { |
| lines.push(""); |
| lines.push("Details:"); |
| lines.push(JSON.stringify(details, null, 2)); |
| } |
| return lines.join("\n") || JSON.stringify(run, null, 2); |
| } |
|
|
| export function formatError(error) { |
| if (error?.payload && typeof error.payload === "object") { |
| return JSON.stringify(error.payload, null, 2); |
| } |
| if (error?.run && typeof error.run === "object") { |
| return `${error.message}\n\nLast status:\n${formatRun(error.run)}`; |
| } |
| return error?.message || "Request failed."; |
| } |
|
|
| export function summarizeRunResultLines(run) { |
| if (!Array.isArray(run?.outputs)) return []; |
| const lines = []; |
| for (const output of run.outputs) { |
| const label = output?.label || output?.key || "Output"; |
| const items = Array.isArray(output?.items) ? output.items : []; |
| for (const item of items) { |
| if (item?.value == null || item.value === "") continue; |
| lines.push(`${label}: ${String(item.value)}`); |
| } |
| } |
| return lines; |
| } |
|
|
| export function setupDisplayTitle(setup) { |
| return setup?.metadata?.title || setup?.title || setup?.id || "(untitled)"; |
| } |
|
|
| function appSurface(setup) { |
| const app = setup?.setupSurface?.app; |
| return app && typeof app === "object" ? app : {}; |
| } |
|
|
| export function setupHasAppSurface(setup) { |
| const app = appSurface(setup); |
| return Boolean( |
| app.switch |
| || (Array.isArray(app.inputs) && app.inputs.length) |
| || (Array.isArray(app.outputs) && app.outputs.length) |
| ); |
| } |
|
|
| function fieldsByKey(fields) { |
| const out = {}; |
| if (!Array.isArray(fields)) return out; |
| for (const field of fields) { |
| if (field && typeof field === "object" && typeof field.key === "string" && field.key) { |
| out[field.key] = field; |
| } |
| } |
| return out; |
| } |
|
|
| function visibleSwitchOptions(app) { |
| const options = Array.isArray(app?.switch?.options) ? app.switch.options : []; |
| return options.filter(option => option && typeof option === "object" && option.visible !== false); |
| } |
|
|
| function defaultSwitchValue(app) { |
| const visibleOptions = visibleSwitchOptions(app); |
| const defaultValue = app?.switch?.default; |
| if (visibleOptions.some(option => option.value === defaultValue)) return defaultValue; |
| return visibleOptions.length ? visibleOptions[0].value : defaultValue; |
| } |
|
|
| function fieldDefault(field) { |
| return field?.default == null ? "" : field.default; |
| } |
|
|
| export function defaultRunInputsFromSetup(setup) { |
| const app = appSurface(setup); |
| const inputs = {}; |
| const switchField = app.switch && typeof app.switch === "object" ? app.switch : null; |
| const selectedSwitch = switchField ? defaultSwitchValue(app) : undefined; |
| if (switchField?.key && selectedSwitch !== undefined) { |
| inputs[switchField.key] = selectedSwitch; |
| } |
|
|
| const inputFields = fieldsByKey(app.inputs); |
| const selectedOption = visibleSwitchOptions(app).find(option => option.value === selectedSwitch); |
| if (selectedOption?.input && inputFields[selectedOption.input]) { |
| inputs[selectedOption.input] = fieldDefault(inputFields[selectedOption.input]); |
| } else if (Array.isArray(app.inputs)) { |
| for (const field of app.inputs) { |
| if (field?.visible !== false && field?.key) inputs[field.key] = fieldDefault(field); |
| } |
| } |
| if (Array.isArray(app.inputs)) { |
| for (const field of app.inputs) { |
| if (field?.standalone && field?.visible !== false && field?.key) { |
| inputs[field.key] = fieldDefault(field); |
| } |
| } |
| } |
| if (Array.isArray(app.outputs)) { |
| for (const field of app.outputs) { |
| if (field?.visible !== false && field?.key) inputs[field.key] = fieldDefault(field); |
| } |
| } |
| return inputs; |
| } |
|
|
| export function runInputsFromAppValues(setup, values = {}) { |
| const app = appSurface(setup); |
| const inputs = {}; |
| const switchField = app.switch && typeof app.switch === "object" ? app.switch : null; |
| const selectedSwitch = switchField?.key && switchField.key in values |
| ? values[switchField.key] |
| : defaultSwitchValue(app); |
| if (switchField?.key && selectedSwitch !== undefined) { |
| inputs[switchField.key] = selectedSwitch; |
| } |
| const inputFields = fieldsByKey(app.inputs); |
| const selectedOption = visibleSwitchOptions(app).find(option => String(option.value) === String(selectedSwitch)); |
| if (selectedOption?.input && inputFields[selectedOption.input]) { |
| inputs[selectedOption.input] = values[selectedOption.input] ?? fieldDefault(inputFields[selectedOption.input]); |
| } else if (Array.isArray(app.inputs)) { |
| for (const field of app.inputs) { |
| if (field?.visible !== false && field?.key) { |
| inputs[field.key] = values[field.key] ?? fieldDefault(field); |
| } |
| } |
| } |
| if (Array.isArray(app.inputs)) { |
| for (const field of app.inputs) { |
| if (field?.standalone && field?.visible !== false && field?.key) { |
| inputs[field.key] = values[field.key] ?? fieldDefault(field); |
| } |
| } |
| } |
| if (Array.isArray(app.outputs)) { |
| for (const field of app.outputs) { |
| if (field?.visible !== false && field?.key) { |
| inputs[field.key] = values[field.key] ?? fieldDefault(field); |
| } |
| } |
| } |
| return inputs; |
| } |
|
|
| function formatFetchFailure(error, baseUrl) { |
| const message = formatError(error); |
| if (!/failed to fetch|load failed|networkerror/i.test(message)) return message; |
| const base = baseUrl || DEFAULT_LOCAL_COMFY_BASE; |
| return [ |
| message, |
| "", |
| "The simulator could not reach the Koolook API.", |
| `Check that ComfyUI is running at ${base}, or open this page from the Koolook route:`, |
| `${base}/koolook/setup_runner_simulator.html`, |
| ].join("\n"); |
| } |
|
|
| let demoRunInputs = {}; |
|
|
| function demoFetch(url, options = {}) { |
| const path = String(url).replace(/^https?:\/\/[^/]+/, ""); |
| const ok = body => ({ ok: true, status: 200, async json() { return body; } }); |
| if (path.endsWith("/koolook/api/setups") && !options.method) { |
| return ok({ |
| ok: true, |
| setups: [ |
| { |
| id: "director-demo", |
| title: "Director Demo", |
| description: "Sample published setup", |
| category: "Video", |
| tags: ["demo"], |
| validation: { status: "valid" }, |
| }, |
| ], |
| }); |
| } |
| if (path.endsWith("/koolook/api/setups/director-demo")) { |
| return ok({ |
| id: "director-demo", |
| metadata: { title: "Director Demo", description: "Sample published setup" }, |
| inputContract: { inputs: [] }, |
| outputContract: { outputs: [{ key: "video", label: "Video", type: "video" }] }, |
| setupSurface: { |
| app: { |
| switch: { |
| key: "switch", |
| label: "Input type", |
| default: 2, |
| options: [ |
| { value: 0, label: "EXR", input: "sequence_folder", visible: true }, |
| { value: 1, label: "QT", input: "qt_file", visible: true }, |
| { value: 2, label: "Img", input: "single_file", visible: true }, |
| { value: 3, label: "Prompt", input: "prompt", visible: false }, |
| ], |
| }, |
| inputs: [ |
| { key: "sequence_folder", label: "Sequence folder", visible: true, default: "/shots/demo/plates" }, |
| { key: "qt_file", label: "QT file", visible: true, default: "/shots/demo/source.mov" }, |
| { key: "single_file", label: "Single file", visible: true, default: "/shots/demo/source.png" }, |
| { |
| key: "prompt", |
| label: "Prompt", |
| visible: true, |
| standalone: true, |
| multiline: true, |
| default: "", |
| placeholder: "a bear talking to the camera in a sunny forest", |
| help: "Describe the shot in one simple line: subject + action + setting.", |
| }, |
| ], |
| outputs: [ |
| { key: "folder", label: "Output folder", visible: true, default: "/shots/demo/output" }, |
| { key: "name", label: "Output name", visible: true, default: "demo" }, |
| { key: "version", label: "Version", visible: true, default: "1" }, |
| ], |
| results: [{ key: "result", label: "Result", visible: true, default: "" }], |
| }, |
| }, |
| validation: { status: "valid" }, |
| }); |
| } |
| if (path.endsWith("/koolook/api/setups/director-demo/run")) { |
| const request = JSON.parse(options.body || "{}"); |
| demoRunInputs = request.inputs && typeof request.inputs === "object" ? request.inputs : {}; |
| return ok({ ok: true, run: { runId: "run-000001", promptId: "prompt-123", status: "queued" } }); |
| } |
| if (path.endsWith("/koolook/api/runs/run-000001")) { |
| const folder = String(demoRunInputs.folder || "/shots/demo/output").replace(/\/+$/, ""); |
| const name = String(demoRunInputs.name || "demo"); |
| const rawVersion = String(demoRunInputs.version || "1"); |
| const version = rawVersion.padStart(3, "0"); |
| const extension = Number(demoRunInputs.switch) === 1 ? "mov" : "png"; |
| return ok({ |
| ok: true, |
| run: { |
| runId: "run-000001", |
| setupId: "director-demo", |
| promptId: "prompt-123", |
| status: "succeeded", |
| comfyStatus: { completed: true, status_str: "success" }, |
| outputs: [ |
| { |
| key: "result", |
| label: "Result", |
| type: "result", |
| items: [{ nodeId: "300", kind: "text", value: `${folder}/${name}_v${version}.${extension}` }], |
| }, |
| ], |
| }, |
| }); |
| } |
| return { ok: false, status: 404, async json() { return { ok: false, errors: ["demo route not found"] }; } }; |
| } |
|
|
| function bindSimulator(documentRef, fetchImpl) { |
| const root = documentRef.querySelector("[data-simulator]"); |
| if (!root) return; |
| const baseInput = root.querySelector("[data-base-url]"); |
| const loadBtn = root.querySelector("[data-load-setups]"); |
| const fileInput = root.querySelector("[data-setup-file]"); |
| const setupSelect = root.querySelector("[data-setup-select]"); |
| const inputsText = root.querySelector("[data-inputs-json]"); |
| const appForm = root.querySelector("[data-app-form]"); |
| const rawInputs = root.querySelector("[data-raw-inputs]"); |
| const runBtn = root.querySelector("[data-run-setup]"); |
| const statusText = root.querySelector("[data-status]"); |
| const detailText = root.querySelector("[data-detail]"); |
| const routeLink = root.querySelector("[data-route-link]"); |
|
|
| const state = { setups: [], selectedSetup: null, formActive: false, selectedSetupCanRun: false }; |
| const baseUrl = () => baseInput.value.trim(); |
| const setStatus = value => { statusText.textContent = value; }; |
| const setDetail = value => { detailText.textContent = value; }; |
|
|
| function populateSetups(setups) { |
| state.setups = Array.isArray(setups) ? setups : []; |
| setupSelect.innerHTML = ""; |
| for (const setup of state.setups) { |
| const opt = documentRef.createElement("option"); |
| opt.value = setup.id || ""; |
| opt.textContent = setupDisplayTitle(setup); |
| setupSelect.appendChild(opt); |
| } |
| runBtn.disabled = true; |
| } |
|
|
| function updatePayloadPreview() { |
| if (!state.selectedSetup) { |
| inputsText.value = "{}"; |
| return; |
| } |
| if (state.formActive) { |
| inputsText.value = JSON.stringify(collectFormInputs(), null, 2); |
| } |
| } |
|
|
| function fieldLabelText(field) { |
| return field?.label || field?.key || "Field"; |
| } |
|
|
| function createInputField(field, { name, value } = {}) { |
| const label = documentRef.createElement("label"); |
| label.dataset.fieldKey = name || field.key; |
| label.textContent = fieldLabelText(field); |
| const input = documentRef.createElement(field?.multiline ? "textarea" : "input"); |
| input.dataset.appField = name || field.key; |
| input.value = value == null ? "" : String(value); |
| if (field?.placeholder) input.placeholder = String(field.placeholder); |
| label.appendChild(input); |
| if (field?.help) { |
| const help = documentRef.createElement("small"); |
| help.className = "field-help"; |
| help.textContent = String(field.help); |
| label.appendChild(help); |
| } |
| input.addEventListener("input", updatePayloadPreview); |
| return label; |
| } |
|
|
| function renderAppForm(setup) { |
| if (!appForm) return; |
| appForm.innerHTML = ""; |
| state.selectedSetup = setup; |
| state.selectedSetupCanRun = setupCanRunFromRegistry(setup); |
| state.formActive = setupHasAppSurface(setup); |
| if (inputsText) inputsText.readOnly = state.formActive; |
| if (rawInputs) rawInputs.open = !state.formActive; |
| const app = appSurface(setup); |
| const inputFields = fieldsByKey(app.inputs); |
| const options = visibleSwitchOptions(app); |
| if (!state.formActive) { |
| appForm.textContent = "This setup does not expose an app form yet. Use raw inputs JSON below."; |
| if (inputsText) inputsText.value = "{}"; |
| return; |
| } |
|
|
| const heading = documentRef.createElement("div"); |
| heading.className = "form-heading"; |
| heading.textContent = "External app form"; |
| appForm.appendChild(heading); |
|
|
| if (app.switch && options.length) { |
| const switchLabel = documentRef.createElement("label"); |
| switchLabel.textContent = app.switch.label || "Input type"; |
| const select = documentRef.createElement("select"); |
| select.dataset.appSwitch = app.switch.key || "switch"; |
| const selectedDefault = defaultSwitchValue(app); |
| for (const option of options) { |
| const opt = documentRef.createElement("option"); |
| opt.value = String(option.value); |
| opt.textContent = option.label || String(option.value); |
| if (String(option.value) === String(selectedDefault)) opt.selected = true; |
| select.appendChild(opt); |
| } |
| switchLabel.appendChild(select); |
| appForm.appendChild(switchLabel); |
| const sourceWrap = documentRef.createElement("div"); |
| sourceWrap.dataset.sourceFieldWrap = "1"; |
| appForm.appendChild(sourceWrap); |
| const renderSourceField = () => { |
| sourceWrap.innerHTML = ""; |
| const selected = options.find(option => String(option.value) === String(select.value)); |
| const field = selected?.input ? inputFields[selected.input] : null; |
| if (field) sourceWrap.appendChild(createInputField(field, { name: selected.input, value: fieldDefault(field) })); |
| updatePayloadPreview(); |
| }; |
| select.addEventListener("change", renderSourceField); |
| renderSourceField(); |
| |
| |
| for (const field of app.inputs || []) { |
| if (field?.standalone && field?.visible !== false && field?.key) { |
| appForm.appendChild(createInputField(field, { value: fieldDefault(field) })); |
| } |
| } |
| } else if (Array.isArray(app.inputs)) { |
| for (const field of app.inputs) { |
| if (field?.visible !== false && field?.key) { |
| appForm.appendChild(createInputField(field, { value: fieldDefault(field) })); |
| } |
| } |
| } |
|
|
| const visibleOutputs = Array.isArray(app.outputs) |
| ? app.outputs.filter(field => field?.visible !== false && field?.key) |
| : []; |
| if (visibleOutputs.length) { |
| const outputHeading = documentRef.createElement("div"); |
| outputHeading.className = "form-heading"; |
| outputHeading.textContent = "Output"; |
| appForm.appendChild(outputHeading); |
| for (const field of visibleOutputs) { |
| appForm.appendChild(createInputField(field, { value: fieldDefault(field) })); |
| } |
| } |
| updatePayloadPreview(); |
| } |
|
|
| function collectFormInputs() { |
| if (!state.selectedSetup) return {}; |
| const values = {}; |
| const switchEl = appForm?.querySelector("[data-app-switch]"); |
| if (switchEl) { |
| const numeric = Number(switchEl.value); |
| values[switchEl.dataset.appSwitch] = Number.isNaN(numeric) ? switchEl.value : numeric; |
| } |
| for (const input of appForm?.querySelectorAll("[data-app-field]") || []) { |
| values[input.dataset.appField] = input.value; |
| } |
| return runInputsFromAppValues(state.selectedSetup, values); |
| } |
|
|
| async function loadSetups() { |
| setStatus("Loading setups..."); |
| setDetail(""); |
| runBtn.disabled = true; |
| try { |
| state.setups = await listPublishedSetups({ baseUrl: baseUrl(), fetchImpl }); |
| populateSetups(state.setups); |
| setStatus(state.setups.length ? `${state.setups.length} setup(s) loaded.` : "No published setups found."); |
| if (state.setups.length) await showSelectedSetup(); |
| else runBtn.disabled = true; |
| } catch (error) { |
| setStatus("Setup list failed."); |
| setDetail(formatFetchFailure(error, baseUrl())); |
| } |
| } |
|
|
| async function showSelectedSetup() { |
| if (!setupSelect.value) return; |
| try { |
| const localSetup = state.setups.find(setup => setup?.id === setupSelect.value && isPublishedSetupRecord(setup)); |
| const setup = localSetup || await getPublishedSetup({ setupId: setupSelect.value, baseUrl: baseUrl(), fetchImpl }); |
| renderAppForm(setup); |
| runBtn.disabled = !state.selectedSetupCanRun; |
| setDetail(state.selectedSetupCanRun |
| ? JSON.stringify(setup, null, 2) |
| : `${localSetupRunMessage(setup)}\n\n${JSON.stringify(setup, null, 2)}`); |
| } catch (error) { |
| setDetail(formatFetchFailure(error, baseUrl())); |
| runBtn.disabled = true; |
| } |
| } |
|
|
| async function loadSetupFile() { |
| const file = fileInput?.files?.[0]; |
| if (!file) return; |
| setStatus("Loading setup file..."); |
| try { |
| const parsed = JSON.parse(await file.text()); |
| populateSetups(localSetupFileRecords(parsed)); |
| setStatus(`${state.setups.length} setup(s) loaded from file.`); |
| if (state.setups.length) await showSelectedSetup(); |
| else runBtn.disabled = true; |
| } catch (error) { |
| setStatus("Setup file failed."); |
| setDetail(formatError(error)); |
| runBtn.disabled = true; |
| } |
| } |
|
|
| async function runSelectedSetup() { |
| if (!setupSelect.value) return; |
| if (!state.selectedSetupCanRun) { |
| setStatus("Setup file is inspect-only."); |
| setDetail(localSetupRunMessage(state.selectedSetup)); |
| runBtn.disabled = true; |
| return; |
| } |
| let inputs; |
| try { |
| inputs = state.formActive ? collectFormInputs() : JSON.parse(inputsText.value || "{}"); |
| } catch (error) { |
| setStatus("Inputs JSON is invalid."); |
| setDetail(error.message); |
| return; |
| } |
| runBtn.disabled = true; |
| setStatus("Queueing setup..."); |
| const startedAt = Date.now(); |
| try { |
| const finalRun = await runAndPollPublishedSetup({ |
| setupId: setupSelect.value, |
| inputs, |
| baseUrl: baseUrl(), |
| fetchImpl, |
| onUpdate: run => { |
| const elapsed = Math.round((Date.now() - startedAt) / 1000); |
| const active = ACTIVE_RUN_STATUSES.has(String(run.status || "").toLowerCase()); |
| setStatus(`Run ${run.status || "unknown"}${active ? ` (${elapsed}s)` : ""}`); |
| setDetail(formatRun(run)); |
| }, |
| }); |
| setStatus(`Run ${finalRun?.status || "finished"}`); |
| setDetail(formatRun(finalRun)); |
| } catch (error) { |
| setStatus("Run failed."); |
| setDetail(formatFetchFailure(error, baseUrl())); |
| } finally { |
| runBtn.disabled = state.setups.length === 0 || !state.selectedSetupCanRun; |
| } |
| } |
|
|
| loadBtn.addEventListener("click", loadSetups); |
| if (fileInput) fileInput.addEventListener("change", loadSetupFile); |
| setupSelect.addEventListener("change", showSelectedSetup); |
| runBtn.addEventListener("click", runSelectedSetup); |
| const routeBase = isFilePage() ? DEFAULT_LOCAL_COMFY_BASE : globalThis.location.origin; |
| if (routeLink) routeLink.href = `${routeBase}/koolook/setup_runner_simulator.html`; |
| baseInput.value = defaultBaseUrl(); |
| if (isFilePage()) { |
| setStatus("Enter or confirm the ComfyUI API base, then load setups."); |
| setDetail([ |
| "You opened this page from disk.", |
| "", |
| "For the live API path, run ComfyUI and either:", |
| `- keep ${DEFAULT_LOCAL_COMFY_BASE} as the API base and click Load setups`, |
| `- open ${DEFAULT_LOCAL_COMFY_BASE}/koolook/setup_runner_simulator.html`, |
| "", |
| "If you only want to verify the simulator UI, open demo mode:", |
| "setup_runner_simulator.html?demo=1", |
| ].join("\n")); |
| } |
| if (new URLSearchParams(globalThis.location?.search || "").get("demo") === "1") { |
| loadSetups(); |
| } |
| } |
|
|
| if (typeof document !== "undefined") { |
| const fetchImpl = new URLSearchParams(globalThis.location?.search || "").get("demo") === "1" |
| ? demoFetch |
| : fetch; |
| bindSimulator(document, fetchImpl); |
| } |
|
|