Files
Claude 3adebb731f Rename FunPack to FunUI (5.0.3 Classy Chopper): /fun endpoint, wink logo, load-time migration of old names
Old project/workflow names and browser settings migrate on load; /funpack redirects to /fun.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016acRss69eH1235eRdFTZHY
2026-10-25 13:11:03 +00:00

124 lines
5.5 KiB
JavaScript

// What the server would queue, asked for rather than assembled here.
//
// The graph is built by core from the pipeline's slots, so the app never
// composes one: two builders would be two answers to "what runs", and the one
// the user sees would be whichever was written last. The app asks, and gets
// either a prompt or the reasons there is not one.
const ENDPOINT = "/fun/api/pipeline";
const NODES = "/fun/api/nodes";
/**
* check({slots, values}) -> { slots, refused, incomplete, notes, queueable, prompt }
*
* `refused` and `incomplete` are different things and stay apart: refused means
* the edit did not happen, incomplete means it did and the pipeline still is
* not ready -- an unset file picker on a fresh install is the normal case, not
* a failure. `notes` is a third thing again: the pipeline is fine and something
* about it is worth saying, such as settings that will not be applied.
*/
export async function check({ fetch: doFetch = globalThis.fetch, slots, values, inputs,
action = "check", slot, node,
input, from_slot: fromSlot, from_output: fromOutput } = {}) {
// `slots` and an action travel together on purpose: a remove is "take this
// one out of THIS pipeline", and sending the action without the pipeline the
// user is looking at would apply it to the server's defaults instead.
const body = { action };
if (slots) body.slots = slots;
// Sent with the pipeline, every time. The server holds no copy of what the
// panels say: two stores of "what the user picked" is two answers to what a
// run used, and the one believed would be whichever was written last.
if (values) body.values = values;
// Values for named inputs, from the controls the pipeline asked the main
// window to show. Only sent when there are some: an empty object is a
// request to override nothing, and saying it every time is noise on the wire.
if (inputs && Object.keys(inputs).length) body.inputs = inputs;
if (slot !== undefined) body.slot = slot;
if (node !== undefined) body.node = node;
// wire's own fields, forwarded the same way -- an unlisted key here is one
// that reaches the server as though it was never sent, which is exactly how
// this dropped `input` silently until a live click on Unwire caught it.
if (input !== undefined) body.input = input;
if (fromSlot !== undefined) body.from_slot = fromSlot;
if (fromOutput !== undefined) body.from_output = fromOutput;
const response = await doFetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
// A 400 here is a malformed REQUEST, which is the app's fault and not the
// user's -- so it is reported as a refusal rather than dressed up as an
// unfinished pipeline.
return {
slots: payload.slots || [],
refused: payload.problems || payload.refused || [`the pipeline could not be read (${response.status})`],
incomplete: [],
notes: [],
queueable: false,
prompt: null,
};
}
return {
slots: payload.slots || [],
refused: payload.refused || [],
incomplete: payload.incomplete || [],
notes: payload.notes || [],
queueable: Boolean(payload.queueable),
prompt: payload.prompt || null,
};
}
/** The pipeline as the server holds it, before anything has been edited. */
export async function load({ fetch: doFetch = globalThis.fetch } = {}) {
const response = await doFetch(ENDPOINT);
if (!response.ok) throw new Error(`the pipeline could not be read (${response.status})`);
const payload = await response.json();
return {
slots: payload.slots || [],
incomplete: payload.incomplete || [],
queueable: Boolean(payload.queueable),
};
}
/**
* presets() -> [{ id, title, module, slots }]
*
* Alternate starting points a module offers beside the one true default --
* loaded into the same editable pipeline `load()` returns, not a different
* kind of thing. An empty list is a real answer (nothing offers one), not
* a failure to fetch.
*/
export async function presets({ fetch: doFetch = globalThis.fetch } = {}) {
const response = await doFetch(`${ENDPOINT}/presets`);
if (!response.ok) throw new Error(`presets could not be read (${response.status})`);
const payload = await response.json();
return payload.presets || [];
}
/**
* describe(classes) -> { [className]: description | null }
*
* A null is an answer, not a gap: the slot points at a node this install does
* not have. The caller asked about it, so it is in the reply saying so.
*/
export async function describe(classes, { fetch: doFetch = globalThis.fetch } = {}) {
const wanted = [...new Set(classes)].filter(Boolean);
if (!wanted.length) return {};
const response = await doFetch(`${NODES}?classes=${encodeURIComponent(wanted.join(","))}`);
if (!response.ok) throw new Error(`those nodes could not be described (${response.status})`);
return (await response.json()).nodes || {};
}
/** Installed nodes matching a query, and how many there were before the cut. */
export async function search(query, { fetch: doFetch = globalThis.fetch, limit = 40 } = {}) {
const response = await doFetch(
`${NODES}/search?q=${encodeURIComponent(query || "")}&limit=${limit}`);
if (!response.ok) throw new Error(`nodes could not be searched (${response.status})`);
const payload = await response.json();
return { nodes: payload.nodes || [], total: payload.total || 0 };
}