Files
Marigold1122-Chat-Image-Bri…/web/chat_image_bridge.js
T

362 lines
11 KiB
JavaScript

import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
const NODE_CLASS = "ChatImageBridge";
const FETCH_BUTTON = "Fetch Models";
const CATAPI_NODE_CLASS = "CatAPIGeminiImage";
const MAX_CATAPI_IMAGES = 14;
const CATAPI_AUTO_RATIO = "自动 / 保持原图";
const CATAPI_CUSTOM_RATIO = "自定义比例";
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function markCanvasDirty() {
app.graph?.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
}
function setWidgetValue(widget, value) {
if (!widget) {
return;
}
widget.value = value;
widget.callback?.(value);
}
function prepareModelWidget(node) {
const modelWidget = findWidget(node, "model");
if (!modelWidget) {
return;
}
const currentModel = `${modelWidget.value || ""}`.trim();
const existingValues = Array.isArray(modelWidget.options?.values) ? modelWidget.options.values : [];
const values = ["", currentModel, ...existingValues]
.map((value) => `${value || ""}`.trim())
.filter((value, index, array) => index === array.indexOf(value));
modelWidget.options = modelWidget.options || {};
modelWidget.options.values = values;
modelWidget.type = "combo";
}
function updateModelDropdown(node, models) {
const modelWidget = findWidget(node, "model");
if (!modelWidget) {
throw new Error("model widget was not found");
}
const currentModel = `${modelWidget?.value || ""}`.trim();
const selected = currentModel && models.includes(currentModel) ? currentModel : models[0];
modelWidget.options = modelWidget.options || {};
modelWidget.options.values = models.includes("") ? models : ["", ...models];
modelWidget.type = "combo";
setWidgetValue(modelWidget, selected);
markCanvasDirty();
return selected;
}
async function fetchModels(node) {
const apiKey = `${findWidget(node, "api_key")?.value || ""}`.trim();
const baseUrl = `${findWidget(node, "base_url")?.value || findWidget(node, "endpoint_url")?.value || ""}`.trim();
const timeoutSeconds = Number(findWidget(node, "timeout_seconds")?.value || 30);
if (!baseUrl) {
window.alert("base_url is required");
return;
}
if (!apiKey) {
window.alert("api_key is required");
return;
}
try {
const response = await api.fetchApi("/chat_image_bridge/models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
base_url: baseUrl,
api_key: apiKey,
timeout_seconds: Math.max(5, Math.min(timeoutSeconds, 120)),
}),
});
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || `HTTP ${response.status}`);
}
const models = Array.isArray(data.models) ? data.models.filter(Boolean) : [];
if (!models.length) {
throw new Error("No models were returned by the provider");
}
updateModelDropdown(node, models);
} catch (error) {
console.error("[Chat Image Bridge] Failed to fetch models:", error);
window.alert(`Failed to fetch models: ${error.message || error}`);
}
}
app.registerExtension({
name: "Comfy.ChatImageBridge.Models",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_CLASS) {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
prepareModelWidget(this);
if (!findWidget(this, FETCH_BUTTON)) {
const fetchButton = this.addWidget("button", FETCH_BUTTON, null, () => fetchModels(this));
fetchButton.serialize = false;
}
return result;
};
},
});
function setWidgetVisible(node, widget, visible) {
if (!widget) {
return;
}
if (visible && widget.__catapiHidden) {
widget.type = widget.__catapiHidden.type;
widget.computeSize = widget.__catapiHidden.computeSize;
widget.serializeValue = widget.__catapiHidden.serializeValue;
delete widget.__catapiHidden;
} else if (!visible && !widget.__catapiHidden) {
widget.__catapiHidden = {
type: widget.type,
computeSize: widget.computeSize,
serializeValue: widget.serializeValue,
};
widget.type = "converted-widget";
widget.computeSize = () => [0, -4];
widget.serializeValue = () => widget.value;
}
const computed = node.computeSize?.() || node.size;
node.setSize?.([Math.max(node.size?.[0] || 360, 360), computed?.[1] || node.size?.[1] || 200]);
markCanvasDirty();
}
function parseRatioValue(value) {
const match = `${value || ""}`.match(/(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*$/);
if (!match) {
return null;
}
const width = Number(match[1]);
const height = Number(match[2]);
return width > 0 && height > 0 ? width / height : null;
}
function currentPreviewRatio(node) {
const outputRatio = `${findWidget(node, "output_ratio")?.value || ""}`;
if (outputRatio === CATAPI_AUTO_RATIO) {
return null;
}
if (outputRatio === CATAPI_CUSTOM_RATIO) {
return parseRatioValue(findWidget(node, "custom_ratio")?.value);
}
return parseRatioValue(outputRatio);
}
function makeRatioPreviewWidget(node) {
return {
name: "ratio_preview",
type: "custom",
value: null,
serialize: false,
computeSize() {
return [360, 62];
},
draw(ctx, _node, width, y, height) {
const ratio = currentPreviewRatio(node);
const centerX = width / 2;
const centerY = y + height / 2 + 2;
ctx.save();
ctx.lineWidth = 1.5;
ctx.strokeStyle = "#8a8a8a";
ctx.fillStyle = "#b8b8b8";
ctx.font = "12px sans-serif";
ctx.textAlign = "left";
ctx.fillText(ratio ? (ratio > 1 ? "横图" : ratio < 1 ? "竖图" : "方形") : "自动 / 保持原图", 14, centerY + 4);
if (ratio) {
const maxWidth = 105;
const maxHeight = 42;
let boxWidth = maxWidth;
let boxHeight = boxWidth / ratio;
if (boxHeight > maxHeight) {
boxHeight = maxHeight;
boxWidth = boxHeight * ratio;
}
ctx.strokeRect(centerX + 24 - boxWidth / 2, centerY - boxHeight / 2, boxWidth, boxHeight);
} else {
ctx.setLineDash([5, 4]);
ctx.strokeRect(centerX - 28, centerY - 18, 80, 36);
}
ctx.restore();
},
};
}
function catapiImageInputs(node) {
return (node.inputs || []).filter((input) => /^image_\d+$/.test(input.name));
}
function labelCatapiImageInputs(node) {
for (const input of catapiImageInputs(node)) {
const index = Number(input.name.split("_")[1]);
input.label = `图片${index}`;
}
}
function updateCatapiImageInputs(node) {
let inputs = catapiImageInputs(node);
labelCatapiImageInputs(node);
const last = inputs[inputs.length - 1];
if (last?.link != null && inputs.length < MAX_CATAPI_IMAGES) {
const nextIndex = inputs.length + 1;
node.addInput(`image_${nextIndex}`, "IMAGE");
inputs = catapiImageInputs(node);
labelCatapiImageInputs(node);
}
while (inputs.length > 1) {
const lastInput = inputs[inputs.length - 1];
const previousInput = inputs[inputs.length - 2];
if (lastInput.link != null || previousInput.link != null) {
break;
}
node.removeInput(node.inputs.indexOf(lastInput));
inputs = catapiImageInputs(node);
}
const computed = node.computeSize?.() || node.size;
node.setSize?.([Math.max(node.size?.[0] || 360, 360), computed?.[1] || node.size?.[1] || 200]);
markCanvasDirty();
}
function prepareCatapiNode(node) {
node.title = "Gemini 3.1 图片生成 / 编辑";
node.setSize?.([Math.max(node.size?.[0] || 380, 380), node.size?.[1] || 240]);
const labels = {
prompt: "提示词",
output_ratio: "输出比例",
custom_ratio: "自定义比例",
resolution: "分辨率",
api_key: "API Key",
base_url: "Base URL",
timeout_seconds: "超时时间",
};
for (const [name, label] of Object.entries(labels)) {
const widget = findWidget(node, name);
if (widget) {
widget.label = label;
}
}
const ratioWidget = findWidget(node, "output_ratio");
const customRatioWidget = findWidget(node, "custom_ratio");
const updateRatioWidgets = () => {
setWidgetVisible(node, customRatioWidget, ratioWidget?.value === CATAPI_CUSTOM_RATIO);
markCanvasDirty();
};
if (ratioWidget && !ratioWidget.__catapiCallbackWrapped) {
const originalCallback = ratioWidget.callback;
ratioWidget.callback = function (value) {
const result = originalCallback?.apply(this, arguments);
updateRatioWidgets();
return result;
};
ratioWidget.__catapiCallbackWrapped = true;
}
if (customRatioWidget && !customRatioWidget.__catapiCallbackWrapped) {
const originalCallback = customRatioWidget.callback;
customRatioWidget.callback = function () {
const result = originalCallback?.apply(this, arguments);
markCanvasDirty();
return result;
};
customRatioWidget.__catapiCallbackWrapped = true;
}
if (node.addCustomWidget && !node.widgets?.some((widget) => widget.name === "ratio_preview")) {
node.addCustomWidget(makeRatioPreviewWidget(node));
}
const advancedWidgets = ["api_key", "base_url", "timeout_seconds"].map((name) => findWidget(node, name));
node.properties = node.properties || {};
if (node.properties.catapiConnectionExpanded == null) {
node.properties.catapiConnectionExpanded = false;
}
let connectionButton = findWidget(node, "connection_settings");
const updateConnectionWidgets = () => {
const expanded = Boolean(node.properties.catapiConnectionExpanded);
for (const widget of advancedWidgets) {
setWidgetVisible(node, widget, expanded);
}
if (connectionButton) {
connectionButton.label = expanded ? "收起连接设置" : "⚙ 连接设置";
connectionButton.name = "connection_settings";
}
};
if (!connectionButton) {
connectionButton = node.addWidget("button", "connection_settings", null, () => {
node.properties.catapiConnectionExpanded = !node.properties.catapiConnectionExpanded;
updateConnectionWidgets();
});
connectionButton.serialize = false;
}
updateRatioWidgets();
updateConnectionWidgets();
updateCatapiImageInputs(node);
if (node.outputs?.[0]) {
node.outputs[0].label = "图像";
}
}
app.registerExtension({
name: "Comfy.ChatImageBridge.CatAPISimpleUI",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== CATAPI_NODE_CLASS) {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
prepareCatapiNode(this);
return result;
};
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function () {
const result = onConnectionsChange?.apply(this, arguments);
updateCatapiImageInputs(this);
return result;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = onConfigure?.apply(this, arguments);
setTimeout(() => prepareCatapiNode(this), 0);
return result;
};
},
});