// The server's routes, one line each. An error carries the sentence the server gave, not a bare status. const BASE = "/fun"; const TRAIN = "/api/m/training_lora"; function why(res, payload) { const p = payload && typeof payload === "object" ? payload : {}; const detail = Array.isArray(p.detail) ? p.detail.map((d) => (d && d.msg) || JSON.stringify(d)).join("; ") : p.detail; const list = (p.problems && p.problems.length ? p.problems : p.refused) || []; return [detail, p.why, p.error, list.join("; ")].find((s) => typeof s === "string" && s.trim()) || `HTTP ${res.status}`; } export async function call(method, path, body) { const res = await fetch(BASE + path, { method, headers: body === undefined ? {} : { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const payload = await res.json().catch(() => null); if (!res.ok) throw Object.assign(new Error(why(res, payload)), { status: res.status }); // status: a refusal (4xx) is not an outage // A 200 that is not JSON is not FunUI answering (ComfyUI restarting, a proxy's page, a route this server does not have): // saying so beats every caller failing on "r is null". if (payload === null && res.status !== 204) throw Object.assign(new Error(`FunUI did not answer ${path} (ComfyUI may be restarting, or this page is older than the server: reload the page).`), { status: 502 }); return payload; } /** POST a form. fetch cannot report bytes sent, so use XHR where it exists (browsers) and fetch elsewhere. * Resolves a fetch-like {ok, status, json()}. */ function post(url, form, onFraction) { if (typeof XMLHttpRequest === "undefined") return fetch(url, { method: "POST", body: form }); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", url); xhr.upload.onprogress = (e) => { if (e.lengthComputable) onFraction(e.loaded / e.total); }; xhr.onerror = () => reject(new Error("the upload was interrupted")); xhr.onload = () => resolve({ ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, statusText: xhr.statusText, json: async () => JSON.parse(xhr.responseText) }); xhr.send(form); }); } export const api = { pipeline: () => call("GET", "/api/pipeline"), editPipeline: (body) => call("POST", "/api/pipeline", body), modules: (traits) => call("GET", `/api/modules${traits != null ? `?traits=${encodeURIComponent(traits)}` : ""}`), releaseModule: (id) => call("POST", "/api/control/release", { id }), addons: () => call("GET", "/api/addons").then((d) => d.addons || []), setAddon: (id, enabled) => call("POST", "/api/addons", { id, enabled }).then((d) => d.addons || []), searchNodes: (q, limit = 40) => call("GET", `/api/nodes/search?q=${encodeURIComponent(q || "")}&limit=${limit}`), wireTargets: (node) => call("GET", `/api/wire-targets?node=${encodeURIComponent(node)}`), pipelinePresets: () => call("GET", "/api/pipeline/presets"), setMediaSubject: (id, subject) => call("PATCH", `/api/media/${encodeURIComponent(id)}`, { subject }), readiness: () => call("GET", "/api/readiness"), importWorkflow: (workflow) => call("POST", "/api/pipeline/import", { workflow }), /** The pipeline as a PNG (loaders, typed-in values, host torch/CUDA). -> a Blob. */ async settingsCard(slots, projectName, theme) { const res = await fetch(`${BASE}/api/settings-card`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slots, project_name: projectName || null, theme: theme || "dark" }) }); if (!res.ok) throw new Error(why(res, await res.json().catch(() => null))); return res.blob(); }, describeNodes: (classes) => call("GET", `/api/nodes?classes=${encodeURIComponent(classes.join(","))}`), system: () => call("GET", "/api/system"), storyMarkers: () => call("GET", "/api/story/markers"), saveStoryMarkers: (markers) => call("POST", "/api/story/markers", { markers }), storySplit: (text) => call("POST", "/api/story/split", { text }), shortcuts: () => call("GET", "/api/shortcuts"), saveShortcut: (shortcut, originalName) => call("POST", "/api/shortcuts", { ...shortcut, original_name: originalName }), clearShortcuts: () => call("POST", "/api/shortcuts/clear", {}), importShortcuts: (data, mode) => call("POST", "/api/shortcuts/import", { data, mode }), addShortcutCategory: (category, sub_category) => call("POST", "/api/shortcuts/category", { category, sub_category }), deleteShortcut: (name) => call("DELETE", `/api/shortcuts/${encodeURIComponent(name)}`), findShortcuts: (find, match_case, whole_words) => call("POST", "/api/shortcuts/find", { find, match_case, whole_words }), replaceShortcuts: (find, replace, match_case, apply, whole_words) => call("POST", "/api/shortcuts/replace", { find, replace, match_case, apply, whole_words }), shortcutPhrases: () => call("GET", "/api/shortcuts/phrases"), revolver: () => call("GET", "/api/shortcuts/revolver"), setRevolver: (enabled, random) => call("POST", "/api/shortcuts/revolver", { enabled, random }), enhancerDefaults: () => call("GET", "/api/m/conditioning_prompt_enhancer/defaults"), enhancerEncoders: () => call("GET", "/api/m/conditioning_prompt_enhancer/encoders"), enhancerRuns: () => call("GET", "/api/m/conditioning_prompt_enhancer/runs"), rateTaste: (promptId, rating, axis) => call("POST", "/api/m/taste/rate", { prompt_id: promptId, rating, axis: axis || null }), newTasteGeneration: (promptId) => call("POST", "/api/m/taste/generation", promptId ? { prompt_id: promptId } : {}), shotMemory: () => call("GET", "/api/m/conditioning_shot_camera/memory"), forgetShotMemory: (kind, name) => call("POST", "/api/m/conditioning_shot_camera/forget", { kind, name }), tasteKeys: () => call("GET", "/api/m/taste/keys"), torchBuild: () => call("GET", "/api/m/loader_diffusion_model/torch"), vedaState: () => call("GET", "/api/m/loader_diffusion_model/veda"), vedaDownload: (id) => call("POST", "/api/m/loader_diffusion_model/veda/download", { id }), blockInfluence: (key) => call("GET", `/api/m/block_influence/status?key=${encodeURIComponent(key || "default")}`), blockInfluenceGroups: (key) => call("GET", `/api/m/block_influence/groups?key=${encodeURIComponent(key || "default")}`), clearBlockInfluence: (key) => call("POST", "/api/m/block_influence/clear", { key: key || "default" }), blockInfluenceExportUrl: (key) => `${BASE}/api/m/block_influence/export?key=${encodeURIComponent(key || "default")}`, blockMap: () => call("GET", "/api/m/block_map/status"), setBlockMap: (on) => call("POST", "/api/m/block_map/enabled", { enabled: !!on }), clearBlockMap: () => call("POST", "/api/m/block_map/clear", {}), setBlockInfluence: (on) => call("POST", `/api/m/block_influence/enabled?key=default`, { enabled: !!on }), libraryFiles: () => call("GET", "/api/files"), deleteLibraryFile: (name) => call("DELETE", `/api/files/${encodeURIComponent(name)}`), tasteKindsOf: (name) => call("GET", `/api/m/taste/keys/${encodeURIComponent(name)}/kinds`), clearTasteKind: (name, kind) => call("DELETE", `/api/m/taste/keys/${encodeURIComponent(name)}/kinds/${encodeURIComponent(kind)}`), deleteTasteKey: (name) => call("DELETE", `/api/m/taste/keys/${encodeURIComponent(name)}`), tasteKeyUrl: (name) => `${BASE}/api/m/taste/keys/${encodeURIComponent(name)}/export`, /** The zip as the raw body; `exists: true` comes back (not thrown) when the name is taken and `overwrite` was not asked. */ async importTasteKey(name, file, overwrite) { const res = await fetch(`${BASE}/api/m/taste/keys/import?name=${encodeURIComponent(name)}${overwrite ? "&overwrite=1" : ""}`, { method: "POST", body: file }); const payload = await res.json().catch(() => null); if (res.status === 409) return { exists: true }; if (!res.ok) throw new Error((payload && payload.why) || `HTTP ${res.status}`); return payload; }, // LoRA training (the add-on module): training sets, rated results, taggers, and a run. trainState: () => call("GET", `${TRAIN}/state`), trainSets: () => call("GET", `${TRAIN}/sets`), trainCreateSet: (name) => call("POST", `${TRAIN}/sets`, { name }), trainSet: (name) => call("GET", `${TRAIN}/sets/${encodeURIComponent(name)}`), trainSaveSet: (name, doc) => call("PUT", `${TRAIN}/sets/${encodeURIComponent(name)}`, doc), trainDeleteSet: (name) => call("DELETE", `${TRAIN}/sets/${encodeURIComponent(name)}`), trainAdd: (name, entries) => call("POST", `${TRAIN}/sets/${encodeURIComponent(name)}/add`, { entries }), trainBulk: (name, body) => call("POST", `${TRAIN}/sets/${encodeURIComponent(name)}/bulk`, body), trainClips: (srcs) => call("POST", `${TRAIN}/clips`, { srcs }), trainRated: (include) => call("GET", `${TRAIN}/rated${include && include.length ? `?include=${encodeURIComponent(include.join(","))}` : ""}`), trainTaggers: () => call("GET", `${TRAIN}/taggers`), trainDownloadTagger: (tagger) => call("POST", `${TRAIN}/taggers/download`, { tagger }), trainRemoveTagger: (tagger) => call("POST", `${TRAIN}/taggers/remove`, { tagger }), trainAutotag: (body) => call("POST", `${TRAIN}/autotag`, body), trainAutotagState: () => call("GET", `${TRAIN}/autotag`), trainAutotagCancel: () => call("POST", `${TRAIN}/autotag/cancel`, {}), trainPlan: (set, slots, settings) => call("POST", `${TRAIN}/plan`, { set, slots, settings }), trainRun: (since) => call("GET", `${TRAIN}/run?since=${since || 0}`), trainPause: () => call("POST", `${TRAIN}/run/pause`, {}), trainStop: () => call("POST", `${TRAIN}/run/stop`, {}), trainRuns: () => call("GET", `${TRAIN}/runs`).then((d) => d.runs || []), trainRunRecord: (id) => call("GET", `${TRAIN}/runs/${encodeURIComponent(id)}`), trainLoraUrl: (id, file) => `${BASE}${TRAIN}/runs/${encodeURIComponent(id)}/lora${file ? `?file=${encodeURIComponent(file)}` : ""}`, trainForgetRun: (id) => call("DELETE", `${TRAIN}/runs/${encodeURIComponent(id)}`), /** Any graph as a ComfyUI job. -> the prompt id. */ async queueGraph(graph) { const res = await fetch("/prompt", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: graph }) }); const body = await res.json().catch(() => ({})); if (!res.ok || !body.prompt_id) { const nodes = Object.values(body.node_errors || {}).flatMap((n) => (n.errors || []).map((x) => x.message)); throw new Error([(body.error || {}).message, ...nodes].filter(Boolean).join("; ") || `ComfyUI refused the job (HTTP ${res.status})`); } return body.prompt_id; }, upscaleModels: () => call("GET", "/api/m/render/upscale_models"), /** The upscale is a background job of FunUI, not a ComfyUI prompt (that would make ComfyUI drop the workflow's loaded models). -> {job_id}. */ upscale: (media, model, rtx) => call("POST", "/api/m/render/upscale", { media: { filename: media.filename, subfolder: media.subfolder || "", type: media.type === "temp" ? "temp" : "output" }, model, ...(rtx ? { rtx_options: rtx } : {}) }), upscaleBusy: () => call("GET", "/api/m/render/upscale_busy"), upscaleJob: (id) => call("GET", `/api/m/render/upscale/${encodeURIComponent(id)}`), /** The Stop button for Upscale and Interpolate jobs (Upscale's route; Interpolate has its own below). -> {stopped} */ stopJob: (id) => call("POST", `/api/m/render/jobs/${encodeURIComponent(id)}/stop`), stopInterpolateJob: (id) => call("POST", `/api/m/interpolate/jobs/${encodeURIComponent(id)}/stop`), refsheetMake: (body) => call("POST", "/api/m/refsheet/make", body), frameGeneration: () => call("GET", "/api/m/interpolate/frame_generation"), /** Interpolation is a background job of FunUI, not a ComfyUI prompt (that would make ComfyUI drop the workflow's loaded models). -> {job_id}. */ interpolate: (media, options) => call("POST", "/api/m/interpolate/run", { media: { filename: media.filename, subfolder: media.subfolder || "", type: media.type === "temp" ? "temp" : "output" }, options }), interpolateJob: (id) => call("GET", `/api/m/interpolate/jobs/${encodeURIComponent(id)}`), /** Whether ComfyUI's queue holds a run of this project (queued from any tab). Unreadable: false, the queue decides. */ async projectQueued(projectId) { try { const q = await (await fetch("/queue", { signal: AbortSignal.timeout(5000) })).json(); // a stalled server: not a hang return [...(q.queue_running || []), ...(q.queue_pending || [])].some((r) => (r[3] || {}).funui_project_id === projectId); } catch { return false; } }, renderLibrary: () => call("GET", "/api/m/render/library"), lastFrame: (pid, body) => call("POST", `/api/m/render/projects/${encodeURIComponent(pid)}/last-frame`, body), carryLog: (pid, body) => call("POST", `/api/m/render/projects/${encodeURIComponent(pid)}/carry-log`, body), suggestionStats: () => call("GET", "/api/shortcuts/suggestion_stats"), expandPrompt: (body) => call("POST", "/api/prompt/expand", { ...body, seed: 1 }), packs: () => call("GET", "/api/packs"), packProviders: (classes) => call("GET", `/api/packs/providers?classes=${encodeURIComponent(classes.join(","))}`), pack: (action, body) => call("POST", `/api/packs/${action}`, body || {}), git: (action, body) => call("POST", `/api/git/${action}`, body || {}), health: () => call("GET", "/api/health"), requirements: () => call("POST", "/api/requirements"), torchSwap: () => call("POST", "/api/torch/cuda13"), torchSwapState: () => call("GET", "/api/torch/cuda13"), gitFull: () => call("GET", "/api/git/status"), gitStatus: () => call("GET", "/api/git/status?remote=0"), media: () => call("GET", "/api/media"), renameMedia: (id, name) => call("PATCH", `/api/media/${encodeURIComponent(id)}`, { name }), deleteMedia: (id) => call("DELETE", `/api/media/${encodeURIComponent(id)}`), /** Upload files as media, one request each so one refusal (too big, wrong type) does not sink the rest. * Resolves {media, problems}. Not JSON, so not through call(). */ async uploadMedia(files, onProgress) { // onProgress(done, total, name, fraction) before each file, then again as bytes go out const out = { media: [], problems: [] }; let done = 0; for (const file of files) { const at = done++; if (onProgress) onProgress(at, files.length, file.name, 0); const form = new FormData(); form.append("file", file, file.name); try { const res = await post(`${BASE}/api/media`, form, (f) => onProgress && onProgress(at, files.length, file.name, f)); const payload = await res.json().catch(() => null); if (!res.ok) throw Object.assign(new Error(why(res, payload)), { status: res.status }); // status: a refusal (4xx) is not an outage out.media.push(...(payload.media || [])); out.problems.push(...(payload.problems || [])); } catch (err) { out.problems.push(`${file.name}: ${err.message}`); } } return out; }, probeFamily: (file) => call("GET", `/api/probe?file=${encodeURIComponent(file)}`), };