Preserve existing workflows while adding capability-based catalogs, dedicated image requests, resumable native video, validated audio and explicit chat tiers. Cover compatibility, billing-sensitive retries, credentials and cancellation. Co-authored-by: Arthur Reboul Salze <22814805+ArthurReboulSalze@users.noreply.github.com> Co-authored-by: Dookie <me@djdookie.de>
169 lines
8.0 KiB
JavaScript
169 lines
8.0 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
|
import { api } from "../../../scripts/api.js";
|
|
import { NODE_IDS, migrateWorkflow, visibleInMode, modelOptions, scrubSerializedKey } from "./openrouter_workflow.js";
|
|
|
|
let catalog = {chat: [], image: [], video: []};
|
|
let inFlight;
|
|
const nodes = new Set();
|
|
const validJobId = value => typeof value === "string" && /^[A-Za-z0-9_-]{1,256}$/.test(value);
|
|
|
|
function executionNode(id) {
|
|
if (typeof id !== "string" && typeof id !== "number") return;
|
|
const path = String(id).split(":");
|
|
if (path.some(part => !part)) return;
|
|
let graph = app.rootGraph || app.graph;
|
|
for (let i = 0; i < path.length; i++) {
|
|
const node = graph?.getNodeById?.(path[i]);
|
|
if (i === path.length - 1) return nodes.has(node) ? node : undefined;
|
|
graph = node?.subgraph;
|
|
}
|
|
}
|
|
|
|
function rememberVideoJob({detail}) {
|
|
if (!validJobId(detail?.job_id)) return;
|
|
const node = executionNode(detail.node_id);
|
|
if (!node) return;
|
|
node.properties ||= {};
|
|
node.properties.openrouter_last_video_job_id = detail.job_id;
|
|
// Remember the ID without changing the next request into a recovery job.
|
|
node.graph?.change?.();
|
|
update(node);
|
|
}
|
|
|
|
async function refresh(force = false) {
|
|
if (inFlight) return inFlight;
|
|
inFlight = (async () => {
|
|
const response = await api.fetchApi(`/openrouter/model_catalog${force ? "?refresh=1" : ""}`);
|
|
if (!response.ok) throw new Error("Model catalog unavailable");
|
|
catalog = await response.json();
|
|
for (const node of nodes) update(node);
|
|
})().catch(() => {}).finally(() => { inFlight = null; });
|
|
return inFlight;
|
|
}
|
|
|
|
function setVisible(widget, visible) {
|
|
if (!widget._openrouterOriginal) widget._openrouterOriginal = {
|
|
type: widget.type, computeSize: widget.computeSize,
|
|
};
|
|
widget.type = visible ? widget._openrouterOriginal.type : "converted-widget";
|
|
widget.computeSize = visible ? widget._openrouterOriginal.computeSize : () => [0, -4];
|
|
widget.options ||= {};
|
|
widget.options.hidden = !visible;
|
|
widget.syncLiveVisibilityOptions?.();
|
|
if (widget.inputEl) widget.inputEl.style.display = visible ? "" : "none";
|
|
if (widget.element) widget.element.style.display = visible ? "" : "none";
|
|
}
|
|
|
|
function update(node) {
|
|
const widgets = new Map((node.widgets || []).map(w => [w.name, w]));
|
|
const mode = widgets.get("request_type")?.value || "chat";
|
|
const resume = !!widgets.get("video_job_id")?.value?.trim();
|
|
const model = widgets.get("model");
|
|
if (model) model.options.values = modelOptions(catalog, mode, model.value);
|
|
let record = (catalog[mode] || []).find(m => m.id === model?.value);
|
|
if (!record && mode === "chat" && typeof model?.value === "string") {
|
|
const baseId = model.value.replace(/(?::(?:floor|nitro|online))+$/, "");
|
|
record = (catalog.chat || []).find(m => m.id === baseId);
|
|
}
|
|
const chatImage = mode === "chat" && record?.architecture?.output_modalities?.includes("image");
|
|
const imageRecord = mode === "image" ? record : chatImage
|
|
? (catalog.image || []).find(m => m.id === record.id) : undefined;
|
|
const parameters = imageRecord?.supported_parameters || {};
|
|
const imageFields = {image_resolution: "resolution", image_quality: "quality", image_background: "background", aspect_ratio: "aspect_ratio"};
|
|
for (const widget of node.widgets || []) {
|
|
if (widget.name === "Resume Last Video") {
|
|
setVisible(widget, mode === "video" && validJobId(node.properties?.openrouter_last_video_job_id));
|
|
continue;
|
|
}
|
|
if (widget.type === "button") continue;
|
|
if (imageFields[widget.name]) {
|
|
widget.options ||= {};
|
|
// Provider-specific choices must not leak into another model/mode.
|
|
if (!widget._openrouterValues) widget._openrouterValues = [...(widget.options?.values || [])];
|
|
widget.options.values = [...widget._openrouterValues];
|
|
if (!widget.options.values.includes(widget.value)) widget.options.values.push(widget.value);
|
|
}
|
|
let visible = visibleInMode(widget.name, mode, resume);
|
|
if ((imageRecord || chatImage) && imageFields[widget.name]) {
|
|
const descriptor = parameters[imageFields[widget.name]];
|
|
// Preserve explicit unsupported values for a clear validation error;
|
|
// hide controls only when their automatic/default value is unused.
|
|
if (!descriptor && ["auto", "1K"].includes(widget.value)) visible = false;
|
|
if (Array.isArray(descriptor?.values)) {
|
|
const allowed = [...new Set(["auto", ...descriptor.values.map(String)])];
|
|
if (!allowed.includes(widget.value)) allowed.push(widget.value);
|
|
widget.options.values = allowed;
|
|
}
|
|
}
|
|
setVisible(widget, visible);
|
|
}
|
|
node.setSize?.([node.size[0], node.computeSize()[1]]);
|
|
node.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "OpenRouter.Controls",
|
|
beforeConfigureGraph(graph) { migrateWorkflow(graph); },
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (!NODE_IDS.has(nodeData.name)) return;
|
|
const created = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
const result = created?.apply(this, arguments);
|
|
nodes.add(this);
|
|
for (const name of ["request_type", "model", "video_job_id"]) {
|
|
const widget = this.widgets?.find(w => w.name === name);
|
|
if (!widget) continue;
|
|
const callback = widget.callback;
|
|
widget.callback = (...args) => { callback?.apply(widget, args); update(this); };
|
|
}
|
|
const key = this.widgets?.find(w => w.name === "api_key");
|
|
if (key?.inputEl) key.inputEl.type = "password";
|
|
const refreshModels = this.addWidget("button", "Refresh Models", null, () => {
|
|
refresh(true);
|
|
// Backend refresh is asynchronous; request its completed snapshot.
|
|
setTimeout(() => refresh(), 1500);
|
|
setTimeout(() => refresh(), 15000);
|
|
}, {serialize: false});
|
|
refreshModels.serialize = false;
|
|
const refreshCredits = this.addWidget("button", "Refresh Credits", null, async () => {
|
|
try {
|
|
const response = await api.fetchApi("/openrouter/credits");
|
|
const data = await response.json();
|
|
this.title = `OpenRouter — ${data.credits}`;
|
|
this.graph?.setDirtyCanvas(true);
|
|
} catch { this.title = "OpenRouter — credits unavailable"; }
|
|
}, {serialize: false});
|
|
refreshCredits.serialize = false;
|
|
const resumeVideo = this.addWidget("button", "Resume Last Video", null, () => {
|
|
const jobId = this.properties?.openrouter_last_video_job_id;
|
|
const job = this.widgets?.find(w => w.name === "video_job_id");
|
|
if (!validJobId(jobId) || !job) return;
|
|
job.value = jobId;
|
|
job.callback?.(jobId);
|
|
this.graph?.change?.();
|
|
update(this);
|
|
}, {serialize: false});
|
|
resumeVideo.serialize = false;
|
|
update(this);
|
|
refresh();
|
|
return result;
|
|
};
|
|
const configured = nodeType.prototype.onConfigure;
|
|
nodeType.prototype.onConfigure = function () {
|
|
configured?.apply(this, arguments);
|
|
update(this);
|
|
};
|
|
const serialize = nodeType.prototype.onSerialize;
|
|
nodeType.prototype.onSerialize = function (data) {
|
|
serialize?.apply(this, arguments);
|
|
scrubSerializedKey(this, data);
|
|
};
|
|
const removed = nodeType.prototype.onRemoved;
|
|
nodeType.prototype.onRemoved = function () { nodes.delete(this); return removed?.apply(this, arguments); };
|
|
},
|
|
setup() {
|
|
api.addEventListener("openrouter.video_job", rememberVideoJob);
|
|
setInterval(() => { if (nodes.size) refresh(); }, 15000);
|
|
},
|
|
});
|