362 lines
11 KiB
JavaScript
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;
|
|
};
|
|
},
|
|
});
|