* feat(sidebar): capture API prompt when publishing setups * feat(setups): preview and load canonical setup records * fix(setups): close publish review gaps
750 lines
30 KiB
JavaScript
750 lines
30 KiB
JavaScript
// SPDX-FileCopyrightText: 2025-2026 Kforge Labs <https://github.com/malkuthro/ComfyUI-Koolook>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "lost"]);
|
|
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 = 30000,
|
|
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;
|
|
}
|
|
const startedAt = Date.now();
|
|
let lastRun = queued;
|
|
while (Date.now() - startedAt <= timeoutMs) {
|
|
await sleepImpl(intervalMs);
|
|
lastRun = await getPublishedSetupRun({ runId, baseUrl, fetchImpl });
|
|
if (typeof onUpdate === "function") onUpdate(lastRun);
|
|
if (TERMINAL_RUN_STATUSES.has(String(lastRun?.status || "").toLowerCase())) {
|
|
return lastRun;
|
|
}
|
|
}
|
|
const error = new Error(`Timed out waiting for run ${runId}.`);
|
|
error.run = lastRun;
|
|
throw error;
|
|
}
|
|
|
|
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.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.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: false, default: "" },
|
|
],
|
|
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("input");
|
|
input.dataset.appField = name || field.key;
|
|
input.value = value == null ? "" : String(value);
|
|
label.appendChild(input);
|
|
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();
|
|
} 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...");
|
|
try {
|
|
const finalRun = await runAndPollPublishedSetup({
|
|
setupId: setupSelect.value,
|
|
inputs,
|
|
baseUrl: baseUrl(),
|
|
fetchImpl,
|
|
onUpdate: run => {
|
|
setStatus(`Run ${run.status || "unknown"}`);
|
|
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);
|
|
}
|