Files
2026-08-08 21:30:52 +08:00

1685 lines
93 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
let i18n = {};
let baseI18n = {
not_app: "This is not an App workflow!\nPlease configure it using AppBuilder first.",
save_confirm: "Save changes?",
};
async function loadI18n() {
const comfyLang = app.ui.settings.getSettingValue("Comfy.Locale");
const baseUrl = new URL("./i18n/", import.meta.url).href;
if (comfyLang !== "en") {
try {
const responseLang = await fetch(`${baseUrl}${comfyLang}.json`);
if (responseLang.ok) {
const langData = await responseLang.json();
i18n = { ...baseI18n, ...langData };
} else { i18n = baseI18n; }
} catch (e) { console.log(e); i18n = baseI18n; }
} else { i18n = baseI18n; }
}
const findAllNodes = (nodes, type) => {
let found = [];
for (const node of nodes) {
if (!type || node.type === type) found.push(node);
if (node.subgraph && node.subgraph._nodes) found.push(...findAllNodes(node.subgraph._nodes, type));
else if (typeof node.getInnerNodes === 'function') {
try {
const inner = node.getInnerNodes();
if (inner) found.push(...findAllNodes(inner, type));
} catch (e) {}
}
}
return found;
};
function isNodeV2() {
return app.ui.settings.getSettingValue("Comfy.VueNodes.Enabled");
}
function isMobile() {
const ua = navigator.userAgent;
if (/Android|iPhone|iPod/i.test(ua)) return true;
if (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) return true;
return false;
}
function hideWidget(widget) {
if (!widget) return;
if (isNodeV2()) {
widget.disabled = true;
} else {
widget.hidden = true;
widget.type = "converted-widget";
widget.computeSize = () => [0, -4];
}
}
async function setupAPI() {
await loadI18n();
window.comfyApp = app;
window.comfyApi = api;
function isComfyuiFullyLoaded() {
if (!app || !app.graph) return false;
const isObjectInfoLoaded = typeof LiteGraph !== "undefined" &&
!!LiteGraph.registered_node_types &&
(!!LiteGraph.registered_node_types["KSampler"] || !!LiteGraph.registered_node_types["LoadImage"]);
const isCanvasReady = !!document.querySelector("canvas") || (app.canvas && !!app.canvas.canvas);
const isMenuReady = !!(document.querySelector(".comfy-menu") || document.querySelector("#comfy-user-button") || document.querySelector(".comfyui-body-left"));
return isObjectInfoLoaded && isCanvasReady && isMenuReady;
}
function tryInjectManager() {
if (isMobile()) {
if (isComfyuiFullyLoaded()) {
let wfIframe = document.createElement("iframe");
wfIframe.id = "app-manager-iframe";
wfIframe.style.cssText = `
position: fixed; top: 0; left: 0;
width: 100%; height: 100dvh; z-index: 999998; border: none;
pointer-events: auto; background: #121212;
`;
const htmlUrl = new URL('app_manager.html', import.meta.url);
wfIframe.src = htmlUrl.href;
document.body.appendChild(wfIframe);
} else {
setTimeout(tryInjectManager, 300);
}
}
}
tryInjectManager();
window.addEventListener("message", async (e) => {
if (e.data && e.data.type === "close_appview") {
if (isMobile()) await closeActiveWorkflow();
const iframe = document.getElementById("appview-iframe");
if (iframe) iframe.remove();
}
if (e.data && e.data.type === "close_app_manager") {
const iframe = document.getElementById("app-manager-iframe");
if (iframe) iframe.remove();
}
if (e.data && e.data.type === "save_workflow") {
await saveActiveWorkflow();
}
if (e.data && e.data.type === "load_workflow") {
const filename = e.data.filename;
try {
const response = await fetch(`/appbuilder/workflows/get?file=${encodeURIComponent(filename)}`);
if (response.ok) {
const workflowData = await response.json();
const nativeFilePath = "app/" + filename;
const cleanName = filename.replace(/\.json$/i, "");
try {
const vueApp = document.querySelector('[data-v-app]')?.__vue_app__;
const pinia = vueApp?.config?.globalProperties?.$pinia;
if (pinia && pinia._s) {
for (const [id, store] of pinia._s.entries()) {
if (store && store.workflows) {
const wfs = Array.isArray(store.workflows) ? store.workflows : Object.values(store.workflows);
const ghostWf = wfs.find(w => w && (w.filename === cleanName && w.directory === "workflows/app"));
if (ghostWf) {
ghostWf.originalContent = JSON.stringify(workflowData);
Object.defineProperty(ghostWf, "isLoaded", { get() { return false; }, set(v) {}, configurable: true });
if (!ghostWf.changeTracker) {
ghostWf.changeTracker = {
reset: function(data) { ghostWf._isModified = false; },
restore: function() {}, deactivate: function() {}, prepareForSave: function() {}, captureCanvasState: function() {},
get activeState() { return app.graph ? app.graph.serialize() : workflowData; },
get initialState() { return workflowData; }
};
}
}
}
}
}
} catch (err) { console.warn("Pre-load check failed:", err); }
await app.loadGraphData(workflowData, true, true, nativeFilePath);
if (app.ui && typeof app.ui.setFilename === "function") app.ui.setFilename(cleanName);
if (app.graph) app.graph.filename = filename;
setTimeout(() => {
try {
const vueApp = document.querySelector('[data-v-app]')?.__vue_app__;
if (vueApp) {
const pinia = vueApp.config.globalProperties.$pinia;
let wfStore = pinia.state.value?.workflow || pinia.state.value?.workflowStore;
if (!wfStore) {
for (const key in pinia.state.value || {}) {
if (pinia.state.value[key]?.activeWorkflow !== undefined) {
wfStore = pinia.state.value[key]; break;
}
}
}
if (wfStore && wfStore.activeWorkflow) {
const wf = wfStore.activeWorkflow;
wf.name = cleanName; wf.fullFilename = filename; wf.directory = "workflows/app"; wf.path = "workflows/app/" + filename;
Object.defineProperty(wf, "isPersisted", { get() { return true; }, set(v) {}, configurable: true });
Object.defineProperty(wf, "isLoaded", { get() { return true; }, set(v) {}, configurable: true });
wf.originalContent = JSON.stringify(workflowData); wf._isModified = false;
if (!wf.changeTracker) {
wf.changeTracker = {
reset: function(data) { wf._isModified = false; }, restore: function() {}, deactivate: function() {}, prepareForSave: function() {}, captureCanvasState: function() {},
get activeState() { return app.graph ? app.graph.serialize() : workflowData; }, get initialState() { return workflowData; }
};
}
}
}
} catch (err) {}
}, 500);
setTimeout(() => {
const firstBuilder = app.graph.findNodesByType("AppBuilder")[0] || app.graph.findNodesByType("AppBuilderAdv")[0];
const manager = document.getElementById("app-manager-iframe");
if (firstBuilder) {
const btnAppView = firstBuilder.widgets?.find(w => w.is_appview_button);
if (btnAppView && btnAppView.callback) btnAppView.callback();
if (manager && manager.contentWindow) {
setTimeout(() => {manager.contentWindow.postMessage({ type: "hide_splash" }, "*"); }, 1000);
}
} else {
alert(i18n.not_app);
if (manager && manager.contentWindow) manager.contentWindow.postMessage({ type: "hide_splash" }, "*");
}
}, 600);
}
} catch (err) { console.error("Auto load workflow failed:", err); }
}
});
if (!api._queuePromptPatched) {
api._queuePromptPatched = true;
const originalQueuePrompt = api.queuePrompt;
api.queuePrompt = async function() {
try {
return await originalQueuePrompt.apply(this, arguments);
} catch (err) {
let detailedMessage = err.message || String(err);
if (err.response) {
try {
const errorData = err.response.error;
const nodeErrors = err.response.node_errors;
let details = [];
if (errorData && errorData.message) details.push(errorData.message);
if (nodeErrors) {
for (const [nodeId, nodeInfo] of Object.entries(nodeErrors)) {
if (nodeInfo.errors && nodeInfo.errors.length > 0) {
nodeInfo.errors.forEach(e => {
const nodeDef = app.graph.getNodeById(nodeId);
const nodeTitle = nodeDef?.title || nodeInfo.class_type || `Node ${nodeId}`;
details.push(`[${nodeTitle}]: ${e.message} (${e.details})`);
});
}
}
}
if (details.length > 0) detailedMessage = details.join('\n');
} catch (parseErr) {}
}
const nodes = [...app.graph.findNodesByType("AppBuilderAdv"), ...app.graph.findNodesByType("AppBuilder")];
nodes.forEach(node => {
if (node.appWindow && !node.appWindow.closed) { node.appWindow.postMessage({ type: 'pre_queue_error', message: detailedMessage }, '*'); }
});
throw err;
}
};
}
}
const applyBypasser = (v, params, graph) => {
if (!graph || !graph._nodes) return;
const nodes = findAllNodes(graph._nodes, null);
const tIds = params.match_id ? (Array.isArray(params.match_id) ? params.match_id : [params.match_id]).map(String) : null;
const tTitles = params.match_title ? (Array.isArray(params.match_title) ? params.match_title : [params.match_title]).map(String) : null;
const tGroups = params.match_group ? (Array.isArray(params.match_group) ? params.match_group : [params.match_group]).map(String) : null;
const groupMatchedNodeIds = new Set();
if (tGroups) {
const collectFromGraph = (g) => {
if (!g) return;
if (g._groups) {
g._groups.forEach(grp => {
if (tGroups.includes(String(grp.title).trim())) {
grp.recomputeInsideNodes();
if (grp._nodes) grp._nodes.forEach(n => groupMatchedNodeIds.add(String(n.id)));
}
});
}
if (g._nodes) g._nodes.forEach(n => { if (n.subgraph) collectFromGraph(n.subgraph) });
};
collectFromGraph(graph);
}
nodes.forEach(n => {
let match = false;
const nid = String(n.id);
const ntitle = String(n.title || n.type);
if (tIds && tIds.includes(nid)) match = true;
if (tTitles && tTitles.includes(ntitle)) match = true;
if (tGroups && groupMatchedNodeIds.has(nid)) match = true;
if (match) n.mode = v ? 0 : 4;
});
};
const saveWidgetValueToConfig = (node, key, val) => {
const jsonW = node.widgets?.find(w => w.name === "config_json");
if (jsonW && jsonW.value) {
try {
const config = JSON.parse(jsonW.value);
if (config[key]) {
config[key].value = val;
jsonW.value = JSON.stringify(config);
if (node.widgets_values && Array.isArray(node.widgets_values)) {
const idx = node.widgets.indexOf(jsonW);
if (idx !== -1) node.widgets_values[idx] = jsonW.value;
}
}
} catch(e) {}
}
};
const notifyConnectedAppBuilder = (bypasserNode) => {
if (!bypasserNode || !bypasserNode.outputs) return;
const output = bypasserNode.outputs[0];
if (output && output.links) {
output.links.forEach(linkId => {
const link = app.graph.links[linkId];
if (link) {
const targetNode = app.graph.getNodeById(link.target_id);
if (targetNode && typeof targetNode.syncAllConnections === "function") { targetNode.syncAllConnections(); }
}
});
}
};
const setupUploaderWidget = (node, key, param, defaultVal, validKeys) => {
let inputFiles = ["None"];
if (app.node_defs && app.node_defs["LoadImage"]) {
inputFiles = app.node_defs["LoadImage"].input.required.image[0];
} else if (defaultVal) {
inputFiles = [defaultVal];
}
let comboWidget = node.widgets?.find(w => w.name === key);
if (!comboWidget) {
comboWidget = node.addWidget("combo", key, defaultVal || inputFiles[0], ()=>{
if (typeof node.notifyUnpackers === "function") node.notifyUnpackers();
}, { values: inputFiles });
} else {
if (!comboWidget.options) comboWidget.options = {};
comboWidget.options.values = inputFiles;
}
comboWidget.label = param.name || key;
// 🌟 核心修复 1:按 associatedKey 精准匹配属于本 Uploader 的专属按钮,绝不与其他 Uploader 共享按钮!
let uploadBtn = node.widgets?.find(w => w.associatedKey === key);
const btnDefaultLabel = `Choose ${param.name || key}`;
if (!uploadBtn) {
uploadBtn = node.addWidget("button", btnDefaultLabel, "Upload", function() {
const selfBtn = this; // 🌟 核心修复 2:锁定当前按钮的专属引用
const fileInput = document.createElement("input");
fileInput.type = "file";
const mediaType = param.media || 'image';
if (mediaType === 'image') fileInput.accept = 'image/*';
else if (mediaType === 'video') fileInput.accept = 'video/*';
else if (mediaType === 'audio') fileInput.accept = 'audio/*';
else fileInput.accept = 'image/*,video/*,audio/*';
fileInput.onchange = async () => {
if (fileInput.files.length > 0) {
const file = fileInput.files[0];
selfBtn.label = "Uploading...";
node.setDirtyCanvas(true, true);
const formData = new FormData();
formData.append("image", file);
try {
const response = await fetch("/upload/image", { method: "POST", body: formData });
if (response.ok) {
const result = await response.json();
if (comboWidget.options && comboWidget.options.values) {
if (!comboWidget.options.values.includes(result.name)) {
comboWidget.options.values.push(result.name);
}
}
comboWidget.value = result.name;
selfBtn.label = "Success ✅";
if (comboWidget.callback) comboWidget.callback(result.name);
if (typeof node.notifyUnpackers === "function") node.notifyUnpackers();
} else {
selfBtn.label = "Failed ❌";
}
} catch (e) {
selfBtn.label = "Error ❌";
}
node.setDirtyCanvas(true, true);
setTimeout(() => {
selfBtn.label = btnDefaultLabel;
node.setDirtyCanvas(true, true);
}, 1200);
}
};
fileInput.click();
});
uploadBtn.associatedKey = key;
uploadBtn.serialize = false;
} else {
uploadBtn.label = btnDefaultLabel;
}
return comboWidget;
};
function getActiveWorkflow() {
const vueApp = document.querySelector('[data-v-app]')?.__vue_app__;
const pinia = vueApp?.config.globalProperties.$pinia;
if (!pinia) return null;
for (const store of Object.values(pinia.state.value)) {
if (store.activeWorkflow) return store.activeWorkflow;
}
return null;
}
const saveActiveWorkflow = async () => {
try {
const wf = getActiveWorkflow()
if (wf) {
const isAppWorkflow = wf.directory === "workflows/app";
if (isAppWorkflow) {
const serializedState = app.graph.serialize();
const cleanStateStr = JSON.stringify(app.graph.serialize());
const hasZeroNodes = !app.graph._nodes || app.graph._nodes.length === 0;
if (!serializedState || cleanStateStr === "null" || hasZeroNodes) { return; }
await wf.save();
}
}
} catch (e) {}
}
const closeActiveWorkflow = async () => {
try {
const vueApp = document.querySelector('[data-v-app]')?.__vue_app__;
const pinia = vueApp?.config?.globalProperties?.$pinia;
if (pinia && pinia._s) {
let wfStoreInstance = null;
for (const [id, instance] of pinia._s.entries()) {
if (instance && instance.activeWorkflow !== undefined) { wfStoreInstance = instance; break; }
}
if (wfStoreInstance) {
const openWorkflows = wfStoreInstance.openWorkflows || wfStoreInstance.workflows || [];
const activeWf = wfStoreInstance.activeWorkflow;
if (openWorkflows.length > 1 && activeWf) {
if (typeof wfStoreInstance.closeWorkflow === "function") { await wfStoreInstance.closeWorkflow(activeWf); }
}
}
}
} catch(err) {}
};
function openAppViewIframe(node) {
const htmlUrl = new URL('app_view.html', import.meta.url);
htmlUrl.searchParams.set('nodeId', node.id);
const oldIframe = document.getElementById("appview-iframe");
if (oldIframe) oldIframe.remove();
const iframe = document.createElement("iframe");
iframe.id = "appview-iframe";
Object.assign(iframe.style, {
position: "fixed", top: "0", left: "0", bottom: "0", width: "100%", height: "100dvh",
zIndex: "999999", border: "none", opacity: "0", transition: "opacity 0.3s ease",
backgroundColor: localStorage.getItem('appview_theme') === 'light' ? '#fafafa' : '#000000',
});
document.body.appendChild(iframe);
iframe.src = htmlUrl.href;
iframe.onload = function() { iframe.style.opacity = "1"; };
setTimeout(() => { iframe.style.opacity = "1"; }, 500);
return iframe;
}
function setupAppWindowBridge(context, api) {
const eventConfigs = [
{ apiEvent: "b_preview", msgType: "b_preview", payload: (e) => ({ blob: e.detail }) },
{ apiEvent: "executed", msgType: "executed", payload: (e) => ({ detail: e.detail }) },
{ apiEvent: "execution_start", msgType: "execution_start" },
{ apiEvent: "status", msgType: "status", payload: (e) => ({ detail: e.detail }) },
{ apiEvent: "execution_interrupted", msgType: "execution_interrupted" },
{ apiEvent: "progress", msgType: "progress", payload: (e) => ({ detail: e.detail }) },
{ apiEvent: "execution_error", msgType: "execution_error", payload: (e) => ({ detail: e.detail }) },
{ apiEvent: "executing", msgType: "executing", payload: (e) => ({ detail: e.detail }) },
{ apiEvent: "appbuilder_log", msgType: "appbuilder_log", payload: (e) => ({ detail: e.detail }) },
];
const removeListeners = () => {
if (context._bridgeHandlers) {
context._bridgeHandlers.forEach(({ apiEvent, handler }) => { api.removeEventListener(apiEvent, handler); });
context._bridgeHandlers = null;
}
};
context.registerAppWindow = (appWin) => {
context.appWindow = appWin;
removeListeners();
context._bridgeHandlers = eventConfigs.map(({ apiEvent, msgType, payload }) => {
const handler = (e) => {
if (context.appWindow && !context.appWindow.closed) {
const extraData = payload ? payload(e) : {};
context.appWindow.postMessage({ type: msgType, ...extraData }, '*');
}
};
api.addEventListener(apiEvent, handler);
return { apiEvent, handler };
});
};
const originalOnRemoved = context.onRemoved;
context.onRemoved = function (...args) {
removeListeners();
if (originalOnRemoved) { originalOnRemoved.apply(this, args); }
};
}
function openConfigOverlay(nodeId) {
if (document.querySelector('.config-modal-overlay')) return;
const overlay = document.createElement('div');
overlay.className = 'config-modal-overlay';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
z-index: 99999; display: flex; justify-content: center; align-items: center;
`;
const iframe = document.createElement('iframe');
const htmlUrl = new URL('config_panel.html', import.meta.url);
htmlUrl.searchParams.set('nodeId', nodeId);
iframe.src = htmlUrl.href;
iframe.style.cssText = `
width: 90vw; height: 90vh; max-width: 920px; max-height: 800px;
border: 1px solid #333; border-radius: 16px; background: #121212;
box-shadow: 0 10px 40px rgba(0,0,0,0.8); overflow: hidden;
`;
overlay.appendChild(iframe); document.body.appendChild(overlay);
window.closeConfigOverlay = () => { document.body.removeChild(overlay); delete window.closeConfigOverlay; };
}
// 💡【新增函数 1】:递归穿透 Subgraph (子图),找到内部真实的底层节点与槽位
function resolveInnerTargetSlot(node, slotIndex) {
if (!node) return null;
if (node.subgraph && node.subgraph._nodes) {
const inputSlot = node.inputs ? node.inputs[slotIndex] : null;
if (inputSlot && inputSlot.link !== null) {
const innerLink = node.subgraph.links ? node.subgraph.links[inputSlot.link] : null;
if (innerLink) {
const innerTargetNode = node.subgraph.getNodeById(innerLink.target_id);
if (innerTargetNode) {
return resolveInnerTargetSlot(innerTargetNode, innerLink.target_slot);
}
}
}
}
return { node, slotIndex };
}
// 💡【新增函数 2】:深度寻找节点上的 Widget 控件(支持 unet_name_1 -> unet_name 智能匹配)
function findWidgetOnNode(node, slot) {
if (!node || !node.widgets) return null;
const slotName = slot.name;
const slotLabel = slot.label;
const widgetName = slot.widget?.name;
// 1. 精确名称查找
let w = node.widgets.find(item => item.name === widgetName || item.name === slotName || item.name === slotLabel);
if (w) return w;
// 2. 模糊剥离后缀查找 (如 unet_name_1 搜寻 unet_name)
if (slotLabel) {
w = node.widgets.find(item => item.name.startsWith(slotLabel) || slotLabel.startsWith(item.name));
if (w) return w;
}
if (slotName) {
const baseName = slotName.replace(/_\d+$/, ''); // 剥离末尾的 _1
w = node.widgets.find(item => item.name === baseName || item.name.startsWith(baseName));
if (w) return w;
}
return null;
}
setupAPI();
app.registerExtension({
name: "AppBuilder.AppBuilderAdv",
async setup() {
const originalGraphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
const res = await originalGraphToPrompt.apply(this, arguments);
const prompt = res.output;
try {
const panelOutputs = {};
const replaceMap = {};
for (const [nodeId, node] of Object.entries(prompt)) {
if (node.class_type === "AppBuilderAdv") {
const configJson = node.inputs?.config_json || "{}";
let config = {};
try { config = JSON.parse(configJson); } catch(e) {}
const entries = Object.entries(config).slice(0, 32).filter(([k, p]) => {
const t = (p.type || "STRING").toUpperCase();
return t !== "BUTTON" && t !== "BYPASSER";
});
panelOutputs[nodeId] = {};
entries.forEach(([key, params], slotIdx) => {
const type = (params.type || "STRING").toUpperCase();
if (type === "INPUT") {
const rawOpt = params.optional || false;
const displayKey = rawOpt ? `${key} (opt)` : key;
const inLink = node.inputs[displayKey];
if (inLink && Array.isArray(inLink)) { panelOutputs[nodeId][slotIdx] = inLink; }
else {
if (rawOpt) { panelOutputs[nodeId][slotIdx] = null; }
else { alert(`Validation Error: Required input '${key}' is missing!`); throw new Error(`Required input missing`); }
}
} else {
let val = node.inputs[key];
if (val === undefined) val = params.default;
if (type === "INT" || type === "SEED") val = Math.round(Number(val));
else if (type === "FLOAT") val = Number(val);
else if (type === "BOOLEAN" || type === "BYPASSER") val = Boolean(val);
else if (type === "STRING" || type === "UPLOADER" || type === "LORA_STACK") val = String(val);
panelOutputs[nodeId][slotIdx] = val;
}
});
}
}
for (const [nodeId, node] of Object.entries(prompt)) {
if (node.class_type === "ParametersUnpacker") {
let panelId = null;
if (node.inputs) {
for (const val of Object.values(node.inputs)) {
if (Array.isArray(val) && val.length === 2) { panelId = String(val[0]); break; }
}
}
if (panelId && panelOutputs[panelId]) { replaceMap[nodeId] = panelOutputs[panelId]; }
}
}
for (const [nodeId, node] of Object.entries(prompt)) {
if (node.class_type === "AppBuilderAdv" || node.class_type === "ParametersUnpacker") continue;
if (node.inputs) {
for (const [inKey, inVal] of Object.entries(node.inputs)) {
if (Array.isArray(inVal) && inVal.length === 2) {
const sourceId = String(inVal[0]);
const sourceSlot = inVal[1];
if (replaceMap[sourceId] && replaceMap[sourceId][sourceSlot] !== undefined) {
const replaceVal = replaceMap[sourceId][sourceSlot];
if (replaceVal === null) { delete node.inputs[inKey]; } else { node.inputs[inKey] = replaceVal; }
}
}
}
}
}
for (const nodeId of Object.keys(prompt)) {
if (prompt[nodeId].class_type === "AppBuilderAdv" || prompt[nodeId].class_type === "ParametersUnpacker") {
delete prompt[nodeId];
}
}
} catch(e) {}
return res;
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "AppBuilderAdv") {
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
this.isConfigured = true;
if (onConfigure) onConfigure.apply(this, arguments);
if (info && info.widgets_values) {
const savedJson = info.widgets_values[0];
this.buildDynamicUI(savedJson, true, info.widgets_values);
}
const autoOpenWidget = this.widgets?.find(w => w.name === "Auto Launch");
if (autoOpenWidget && typeof autoOpenWidget.value !== "boolean") autoOpenWidget.value = false;
const showTitleWidget = this.widgets?.find(w => w.name === "Short Title");
if (showTitleWidget && typeof showTitleWidget.value !== "boolean") showTitleWidget.value = true;
setTimeout(() => {
const autoOpenWidget = this.widgets?.find(w => w.name === "Auto Launch");
if (autoOpenWidget && autoOpenWidget.value === true) {
const btnAppView = this.widgets?.find(w => w.is_appview_button);
if (btnAppView && btnAppView.callback && !isMobile()) { btnAppView.callback(); }
}
}, 500);
};
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const existingBuilders = [
...(app.graph.findNodesByType("AppBuilder") || []),
...(app.graph.findNodesByType("AppBuilderAdv") || [])
].filter(n => n.id !== this.id);
if (existingBuilders.length > 0) {
app.extensionManager.toast.add({
severity: "warn",
summary: "Warning",
detail: "Only ONE AppBuilder node is allowed per workflow!",
life: 5000
});
setTimeout(() => {
const canvas = app.canvas;
app.graph.remove(this);
if (canvas.state) {
canvas.state.draggingItems = false;
canvas.state.draggingCanvas = false;
}
canvas.current_node = null;
if (canvas.pointer) {
canvas.pointer.dragStarted = false;
canvas.pointer.pointerId = undefined;
canvas.pointer.eLastDown = undefined;
}
canvas.last_mouse_dragging = false;
canvas.setDirty(true, true);
}, 10);
return r;
}
this.size = [300, 120];
setupAppWindowBridge(this, api);
const btnWidget = this.addWidget("button", "📱 Open in AppView", "btn_app_view", () => { openAppViewIframe(this) });
Object.defineProperty(btnWidget, 'height', { get() { return 40; }, configurable: true });
btnWidget.computeSize = function(width) { return [width, 40]; };
btnWidget.is_appview_button = true;
const btnWidget2 = this.addWidget("button", "⚙️ Configure Panel", "btn_configure", () => { openConfigOverlay(this.id); });
btnWidget2.is_configure_button = true;
this.addWidget("toggle", "Auto Launch", false, (v) => {
if (v) {
const nodes = [...app.graph.findNodesByType("AppBuilderAdv"), ...app.graph.findNodesByType("AppBuilder")];
const otherNodes = nodes.filter(n => n.id !== this.id);
otherNodes.forEach(n => {
const otherToggle = n.widgets?.find(w => w.name === "Auto Launch");
if (otherToggle && otherToggle.value === true) { otherToggle.value = false; if (otherToggle.callback) otherToggle.callback(false); }
});
}
}, {});
setTimeout(() => {
const jsonW = this.widgets.find(w => w.name === "config_json"); if (jsonW) hideWidget(jsonW);
const prevW = this.widgets.find(w => w.name === "live_preview"); if (prevW) hideWidget(prevW);
this.computeSize(); this.setDirtyCanvas(true, true);
}, 50);
this.notifyUnpackers = function() {
if (!this.outputs || !this.outputs[0].links) return;
this.outputs[0].links.forEach(linkId => {
const link = app.graph.links[linkId];
if (link) {
const targetNode = app.graph.getNodeById(link.target_id);
if (targetNode && targetNode.type === "ParametersUnpacker") { targetNode.syncFromUpstream(); }
}
});
};
this.buildDynamicUI = async function(customJsonStr = null, isRestoring = false, storedValues = null) {
this.imgs = []; this.setDirtyCanvas(true, true);
let jsonStr = customJsonStr || this.widgets.find(w => w.name === "config_json")?.value;
if (jsonStr === "" || !jsonStr) jsonStr = "{}";
let config;
try { config = JSON.parse(jsonStr); } catch (e) { return; }
const entries = Object.entries(config).slice(0, 32);
const isStatic = (w) => (
w.name === "config_json" || w.name === "live_preview" || w.is_appview_button || w.is_configure_button ||
w.value === "btn_configure" || w.value === "btn_app_view" || w.name === "Auto Launch"
);
for (let i = this.widgets.length - 1; i >= 0; i--) {
if (!isStatic(this.widgets[i])) {
if (this.widgets[i].onRemove) this.widgets[i].onRemove();
this.widgets.splice(i, 1);
}
}
if (this.inputs) while(this.inputs.length > 0) this.removeInput(this.inputs.length - 1);
const oldInputs = [];
if (this.inputs) {
for (let i = 0; i < this.inputs.length; i++) {
const inp = this.inputs[i];
if (inp.link) {
const l = app.graph.links[inp.link];
if (l) { oldInputs.push({ name: inp.name, origin_id: l.origin_id, origin_slot: l.origin_slot }); }
}
}
while(this.inputs.length > 0) { this.removeInput(this.inputs.length - 1); }
}
for (const [key, params] of entries) {
const type = (params.type || "STRING").toUpperCase();
let widget;
let val = params.value !== undefined ? params.value : params.default;
if (type === "INT" || type === "FLOAT") {
const isInt = type === "INT";
const step = params.step ?? (isInt ? 1 : 0.1);
const prec = params.precision ?? (isInt ? 0 : 3);
const isSlider = params.slider === true || params.display === "slider";
if (isSlider && (params.min === undefined || params.max === undefined)) { continue; }
const minVal = params.min ?? -999999; const maxVal = params.max ?? 999999;
const initVal = val ?? 0;
widget = this.addWidget(isSlider ? "slider" : "number", key, initVal, (v) => {
let snapped = isInt ? Math.round(v) : Math.round(v / step) * step;
const finalVal = parseFloat(snapped.toFixed(prec));
if (widget.value !== finalVal) { widget.value = finalVal; }
saveWidgetValueToConfig(this, key, finalVal);
}, { min: minVal, max: maxVal, step: isSlider ? step : step * 10, precision: prec });
} else if (type === "SEED") {
const minVal = params.min ?? 0; const maxVal = params.max ?? 0xffffffffffffffff; const initVal = val ?? params.default ?? 0;
ComfyWidgets.INT(this, key, ["INT", { default: initVal, min: minVal, max: maxVal, control_after_generate: true }], app);
widget = this.widgets.find(w => w.name === key);
if (widget) widget.value = initVal;
const companionW = this.widgets.find(w => w.name === "control_after_generate");
if (companionW) {
if (params.seed_mode) companionW.value = params.seed_mode;
companionW.callback = (v) => {
saveWidgetValueToConfig(this, key, widget.value);
// 保存 seed_mode 到 config_json
const jsonW = this.widgets?.find(w => w.name === "config_json");
if (jsonW && jsonW.value) {
try {
const cfg = JSON.parse(jsonW.value);
if (cfg[key]) { cfg[key].seed_mode = v; jsonW.value = JSON.stringify(cfg); }
} catch(e) {}
}
};
}
} else if (type === "STRING") {
if (params.multiline || params.placeholder) {
ComfyWidgets.STRING(this, key, ["STRING", { multiline: !!params.multiline, default: val || "" }], app);
widget = this.widgets[this.widgets.length - 1];
if (widget) {
widget.value = val || "";
widget.callback = (v) => saveWidgetValueToConfig(this, key, v);
if (widget.inputEl) {
if (params.placeholder) widget.inputEl.placeholder = params.placeholder;
widget.inputEl.addEventListener("input", (e) => { saveWidgetValueToConfig(this, key, e.target.value); });
}
}
} else { widget = this.addWidget("text", key, val || "", (v) => saveWidgetValueToConfig(this, key, v), {}); }
} else if (type === "LORA_STACK") {
widget = this.addWidget("text", key, val || "[]", (v) => saveWidgetValueToConfig(this, key, v), { read_only: true });
if (widget) {
widget.disabled = true;
widget.options = widget.options || {};
widget.options.read_only = true;
}
} else if (type === "COMBO") {
let values = params.values || ["None"];
if (params.folder) {
try {
const response = await fetch(`/appbuilder/ls/${params.folder}`);
if (response.ok) { const data = await response.json(); values = ["None", ...data]; }
} catch (e) {}
}
widget = this.addWidget("combo", key, val || values[0], (v) => saveWidgetValueToConfig(this, key, v), { values: values });
} else if (type === "BOOLEAN") {
widget = this.addWidget("toggle", key, val ?? true, (v) => saveWidgetValueToConfig(this, key, v), {});
} else if (type === "BYPASSER") {
const applyMuter = (v) => {
const nodes = findAllNodes(app.graph._nodes, null);
const tIds = params.match_id ? (Array.isArray(params.match_id) ? params.match_id : [params.match_id]).map(String) : null;
const tTitles = params.match_title ? (Array.isArray(params.match_title) ? params.match_title : [params.match_title]).map(String) : null;
const tGroups = params.match_group ? (Array.isArray(params.match_group) ? params.match_group : [params.match_group]).map(String) : null;
const groupMatchedNodeIds = new Set();
if (tGroups) {
const collectFromGraph = (graph) => {
if (!graph) return;
if (graph._groups) {
graph._groups.forEach(g => {
if (tGroups.includes(String(g.title).trim())) {
g.recomputeInsideNodes();
if (g._nodes) g._nodes.forEach(n => groupMatchedNodeIds.add(String(n.id)));
}
});
}
if (graph._nodes) graph._nodes.forEach(n => { if (n.subgraph) collectFromGraph(n.subgraph) });
};
collectFromGraph(app.graph);
}
nodes.forEach(n => {
let match = false;
const nid = String(n.id); const ntitle = String(n.title || n.type);
if (tIds) match = tIds.includes(nid); if (tTitles) match = tTitles.includes(ntitle); if (tGroups) match = groupMatchedNodeIds.has(nid);
if (match) n.mode = v ? 0 : 4;
});
};
widget = this.addWidget("toggle", key, val, (v) => applyMuter(v), {});
setTimeout(() => applyMuter(widget.value), 300);
} else if (type === "UPLOADER") {
const oldValidKeys = entries.map(([k, p]) => k);
widget = setupUploaderWidget(this, key, params, val, oldValidKeys);
} else if (type === "INPUT") {
const inputClass = params.class ? String(params.class).toUpperCase() : "*";
const isOptional = params.optional ?? false;
this.addInput(key, inputClass, {shape: isOptional ? 7 : undefined});
const newIdx = this.inputs.length - 1;
const backup = oldInputs.find(l => l.name === key);
if (backup) {
const originNode = app.graph.getNodeById(backup.origin_id);
if (originNode) originNode.connect(backup.origin_slot, this, newIdx);
}
} else if (type === "BUTTON") {
const action = params.action === "stop" ? "stop" : "run";
widget = this.addWidget("button", params.name || key, action, () => {
if (action === "run") app.queuePrompt(0);
else if (action === "stop") api.interrupt();
});
}
if (widget) {
const label = params.name || key;
widget.label = (type === "LORA_STACK") ? `🔒 ${label}` : label;
widget.tooltip = params.tooltip;
if (storedValues) {
const wIdx = this.widgets.indexOf(widget);
if (wIdx !== -1 && storedValues[wIdx] !== undefined) widget.value = storedValues[wIdx];
}
}
}
if (!isRestoring) this.notifyUnpackers();
this.computeSize(); this.setDirtyCanvas(true, true);
};
setTimeout(() => {
const jsonW = this.widgets.find(w => w.name === "config_json"); if (jsonW) hideWidget(jsonW);
const prevW = this.widgets.find(w => w.name === "live_preview"); if (prevW) hideWidget(prevW);
if (!this.isConfigured) this.buildDynamicUI(null, true);
}, 100);
return r;
};
}
if (nodeData.name === "ParametersUnpacker") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
this.outputs = []; this.size = [200, 40];
// 🔥【修复的核心方法】:智能同步并保持连线完好
this.syncFromUpstream = function() {
// 如果根本没有输入连线,清空输出槽
if (!this.inputs || !this.inputs[0].link) {
if (this.outputs && this.outputs.length > 0) {
for (let i = 0; i < this.outputs.length; i++) this.disconnectOutput(i);
this.outputs = []; this.computeSize(); this.setDirtyCanvas(true, true);
}
return;
}
const linkId = this.inputs[0].link;
const link = app.graph ? app.graph.links[linkId] : null;
if (!link) return; // 连线还没加载完成,直接返回,绝不断开已有的输出!
const upstreamNode = app.graph.getNodeById(link.origin_id);
if (!upstreamNode || upstreamNode.type !== "AppBuilderAdv") return;
const jsonWidget = upstreamNode.widgets?.find(w => w.name === "config_json");
if (!jsonWidget || !jsonWidget.value) return; // 上游节点尚未构建完毕,直接返回!
let config;
try { config = JSON.parse(jsonWidget.value || "{}"); } catch (e) { return; }
const entries = Object.entries(config).slice(0, 32);
const outputEntries = entries.filter(([k, p]) => {
const t = (p.type || "STRING").toUpperCase(); return t !== "BUTTON" && t !== "BYPASSER";
});
// 校验现有输出槽是否和上游配置完全一致
const currentOutputs = this.outputs || [];
let needsUpdate = currentOutputs.length !== outputEntries.length;
if (!needsUpdate) {
for (let i = 0; i < outputEntries.length; i++) {
const [key] = outputEntries[i];
if (currentOutputs[i].name !== key) {
needsUpdate = true; break;
}
}
}
// 💡【核心修复点】:如果输出槽数量和 Key 都没变(即常规加载工作流),只更新标签和类型,绝不销毁/断开连线!
if (!needsUpdate) {
outputEntries.forEach(([key, params], idx) => {
const baseType = (params.type || "*").toUpperCase();
let outputClass;
if (params.class) { outputClass = String(params.class).toUpperCase() }
else if (baseType === "INPUT") { outputClass = "*"; }
else if (baseType === "SEED") { outputClass = "INT"; }
else if (baseType === "UPLOADER") { outputClass = "COMBO"; }
else if (baseType === "LORA_STACK") { outputClass = "STRING"; }
else { outputClass = baseType; }
const displayName = params.name || key;
if (this.outputs[idx]) {
this.outputs[idx].type = outputClass;
this.outputs[idx].label = displayName;
}
});
this.setDirtyCanvas(true, true);
return;
}
// 只有在配置真正发生修改(增删配置项)时,才安全备份并重建
const oldLinks = [];
if (this.outputs) {
for (let i = 0; i < this.outputs.length; i++) {
const output = this.outputs[i];
if (output.links && output.links.length > 0) {
const linksInfo = output.links.map(lId => {
const l = app.graph ? app.graph.links[lId] : null;
if (!l) return null;
const targetNode = app.graph.getNodeById(l.target_id);
return targetNode ? { targetNode, target_slot: l.target_slot } : null;
}).filter(Boolean);
oldLinks.push({ name: output.name, connections: linksInfo });
this.disconnectOutput(i);
}
}
}
this.outputs = [];
outputEntries.forEach(([key, params], idx) => {
const baseType = (params.type || "*").toUpperCase();
let outputClass;
if (params.class) { outputClass = String(params.class).toUpperCase() }
else if (baseType === "INPUT") { outputClass = "*"; }
else if (baseType === "SEED") { outputClass = "INT"; }
else if (baseType === "UPLOADER") { outputClass = "COMBO"; }
else if (baseType === "LORA_STACK") { outputClass = "STRING"; }
else { outputClass = baseType; }
const displayName = params.name || key; const isOptional = params.optional ?? false;
this.addOutput(key, outputClass, {shape: isOptional ? 7 : undefined});
const newOutput = this.outputs[this.outputs.length - 1]; newOutput.label = displayName;
const backup = oldLinks.find(l => l.name === key);
if (backup) {
backup.connections.forEach(conn => {
if (conn.targetNode) {
this.connect(idx, conn.targetNode, conn.target_slot);
}
});
}
});
this.setSize(this.computeSize()); this.setDirtyCanvas(true, true);
};
return r;
};
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function (type, slotIndex, isConnected, linkInfo) {
if (onConnectionsChange) onConnectionsChange.apply(this, arguments);
if (type === 1 && slotIndex === 0) { setTimeout(() => this.syncFromUpstream(), 50); }
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
if (onConfigure) onConfigure.apply(this, arguments);
setTimeout(() => this.syncFromUpstream(), 100);
};
}
}
});
app.registerExtension({
name: "AppBuilder.AppBuilder",
setup() {
const originalGraphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
const res = await originalGraphToPrompt.apply(this, arguments);
const prompt = res.output;
try {
const builderNodes = app.graph.findNodesByType("AppBuilder");
const panelData = {};
builderNodes.forEach(node => {
const nodeId = String(node.id); panelData[nodeId] = {};
let config = {};
const jsonW = node.widgets?.find(w => w.name === "config_json");
if (jsonW && jsonW.value) { try { config = JSON.parse(jsonW.value); } catch(e) {} }
Object.entries(config).forEach(([key, param]) => {
const slotIdx = param._slot; if (slotIdx === undefined) return;
const targetWidget = node.widgets?.find(w => w.name === key);
let val = targetWidget ? targetWidget.value : param.default;
if (param.type === "INT" || param.type === "SEED") val = Math.round(Number(val));
else if (param.type === "FLOAT") val = Number(val);
panelData[nodeId][slotIdx] = val;
});
});
for (const [nodeId, promptNode] of Object.entries(prompt)) {
if (promptNode.class_type === "AppBuilder") continue;
if (promptNode.inputs) {
for (const [inKey, inVal] of Object.entries(promptNode.inputs)) {
if (Array.isArray(inVal) && inVal.length === 2) {
const sourceId = String(inVal[0]); const sourceSlot = inVal[1];
if (panelData[sourceId] !== undefined) {
const valToInject = panelData[sourceId][sourceSlot];
if (valToInject !== undefined) promptNode.inputs[inKey] = valToInject;
else delete promptNode.inputs[inKey];
}
}
}
}
}
for (const nodeId of Object.keys(prompt)) {
if (["AppBuilder", "AppBuilderBypasser"].includes(prompt[nodeId].class_type)) { delete prompt[nodeId]; }
}
} catch(e) {}
return res;
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "AppBuilderBypasser") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
this.addInput("node_1", "*"); this.size = [220, 80];
setTimeout(() => {
const groupWidget = this.widgets?.find(w => w.name === "group_name");
if (groupWidget) { groupWidget.callback = () => notifyConnectedAppBuilder(this); }
const nameWidget = this.widgets?.find(w => w.name === "bypasser_name");
if (nameWidget) { nameWidget.callback = () => notifyConnectedAppBuilder(this); }
}, 100);
return r;
};
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function (type, slotIndex, isConnected, linkInfo) {
if (onConnectionsChange) onConnectionsChange.apply(this, arguments);
if (type === 1) {
let hasEmpty = false;
for (let i = this.inputs.length - 1; i >= 0; i--) {
if (this.inputs[i].name.startsWith("node_")) {
if (!this.inputs[i].link) {
if (hasEmpty) this.removeInput(i); else hasEmpty = true;
}
}
}
if (!hasEmpty) this.addInput(`node_${this.inputs.length + 1}`, "*");
this.setSize(this.computeSize());
}
setTimeout(() => { notifyConnectedAppBuilder(this); }, 100);
};
nodeType.prototype.onWidgetChanged = function (name) {
if (name === "group_name" || name === "bypasser_name") notifyConnectedAppBuilder(this);
};
}
if (nodeData.name === "AppBuilder") {
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
if (onConfigure) onConfigure.apply(this, arguments);
const autoOpenWidget = this.widgets?.find(w => w.name === "Auto Launch");
if (autoOpenWidget && typeof autoOpenWidget.value !== "boolean") autoOpenWidget.value = false;
const showTitleWidget = this.widgets?.find(w => w.name === "Short Title");
if (showTitleWidget && typeof showTitleWidget.value !== "boolean") showTitleWidget.value = true;
setTimeout(() => {
const autoOpenWidget = this.widgets?.find(w => w.name === "Auto Launch");
if (autoOpenWidget && autoOpenWidget.value === true) {
const btnAppView = this.widgets?.find(w => w.is_appview_button);
if (btnAppView && btnAppView.callback && !isMobile()) { btnAppView.callback(); }
}
}, 500);
};
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const existingBuilders = [
...(app.graph.findNodesByType("AppBuilder") || []),
...(app.graph.findNodesByType("AppBuilderAdv") || [])
].filter(n => n.id !== this.id);
if (existingBuilders.length > 0) {
app.extensionManager.toast.add({
severity: "warn",
summary: "Warning",
detail: "Only ONE AppBuilder node is allowed per workflow!",
life: 5000
});
setTimeout(() => {
const canvas = app.canvas;
app.graph.remove(this);
if (canvas.state) {
canvas.state.draggingItems = false;
canvas.state.draggingCanvas = false;
}
canvas.current_node = null;
if (canvas.pointer) {
canvas.pointer.dragStarted = false;
canvas.pointer.pointerId = undefined;
canvas.pointer.eLastDown = undefined;
}
canvas.last_mouse_dragging = false;
canvas.setDirty(true, true);
}, 10);
return r;
}
this.size = [300, 100];
this.addInput("bypasser_1", "BYPASSER", { shape: 7 });
setupAppWindowBridge(this, api);
setTimeout(() => {
const jsonW = this.widgets.find(w => w.name === "config_json"); if (jsonW) { hideWidget(jsonW); }
}, 50);
const btnWidget = this.addWidget("button", "📱 Open in AppView", "btn_app_view", () => {
this.syncAllConnections(); openAppViewIframe(this);
});
Object.defineProperty(btnWidget, 'height', { get() { return 40; }, configurable: true });
btnWidget.computeSize = function(width) { return [width, 40]; };
btnWidget.is_appview_button = true;
this.addWidget("toggle", "Auto Launch", false, (v) => {
if (v) {
const nodes = [...app.graph.findNodesByType("AppBuilderAdv"), ...app.graph.findNodesByType("AppBuilder")];
const otherNodes = nodes.filter(n => n.id !== this.id);
otherNodes.forEach(n => {
const otherToggle = n.widgets?.find(w => w.name === "Auto Launch");
if (otherToggle && otherToggle.value === true) { otherToggle.value = false; if (otherToggle.callback) otherToggle.callback(false); }
});
}
}, {});
this.addWidget("toggle", "Short Title", false, (v) => { this.syncAllConnections(); }, {});
if (this.inputs) {
for (let i = this.inputs.length - 1; i >= 0; i--) {
if (this.inputs[i].name === "config_json") { this.removeInput(i); }
}
}
return r;
};
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
nodeType.prototype.onConnectionsChange = function (type, slotIndex, isConnected, linkInfo) {
if (onConnectionsChange) onConnectionsChange.apply(this, arguments);
if (type === 1) {
let hasEmpty = false;
for (let i = this.inputs.length - 1; i >= 0; i--) {
if (this.inputs[i].name.startsWith("bypasser_")) {
if (!this.inputs[i].link) { if (hasEmpty) this.removeInput(i); else hasEmpty = true; }
}
}
if (!hasEmpty) {
let nextIdx = 1;
while(this.inputs.find(inp => inp.name === `bypasser_${nextIdx}`)) nextIdx++;
this.addInput(`bypasser_${nextIdx}`, "BYPASSER", { shape: 7 });
}
}
setTimeout(() => this.syncAllConnections(), 50);
};
nodeType.prototype.syncAllConnections = function() {
if (this.inputs) {
for (let i = this.inputs.length - 1; i >= 0; i--) {
if (this.inputs[i].name === "config_json") { this.removeInput(i); }
}
}
const jsonW = this.widgets?.find(w => w.name === "config_json");
let oldConfig = {};
if (jsonW && jsonW.value) {
try { oldConfig = JSON.parse(jsonW.value); } catch(e) {}
}
let hasEmptyOutput = false;
for (let i = this.outputs.length - 1; i >= 0; i--) {
const output = this.outputs[i];
if (!output.links || output.links.length === 0) {
if (hasEmptyOutput) { this.removeOutput(i); } else { hasEmptyOutput = true; }
}
}
if (!hasEmptyOutput) this.addOutput(`any`, "*");
this.outputs.forEach((output, idx) => { output.name = "any"; });
let config = {}; let validKeys = [];
this.outputs.forEach((output, outIdx) => {
if (output.name !== "any" || !output.links || output.links.length === 0) return;
const link = app.graph.links[output.links[0]]; if (!link) return;
let targetNode = app.graph.getNodeById(link.target_id); if (!targetNode || !targetNode.inputs) return;
let targetSlotIdx = link.target_slot;
// 1. 穿透 Subgraph (子图) 寻找内部真实节点
const resolved = resolveInnerTargetSlot(targetNode, targetSlotIdx);
if (resolved && resolved.node) {
targetNode = resolved.node;
targetSlotIdx = resolved.slotIndex;
}
const targetSlot = targetNode.inputs[targetSlotIdx];
if (!targetSlot) return;
const targetSlotName = targetSlot.name;
let pType = "STRING";
let pOpts = {};
let pValues = undefined;
let isSupported = false;
let pythonPresetStep = undefined;
const shortTitleWidget = this.widgets?.find(w => w.name === "Short Title");
const shortTitle = shortTitleWidget ? shortTitleWidget.value : false;
const displayName = shortTitle ? targetSlotName : `${targetSlotName} (${targetNode.title || targetNode.type})`;
const liveWidget = findWidgetOnNode(targetNode, targetSlot);
// 🛡️ 第一层校验:直接检查 targetSlot 槽位本身声明的类型
if (targetSlot) {
const rawType = targetSlot.type;
if (Array.isArray(rawType)) {
pType = "COMBO"; pValues = rawType; isSupported = true;
} else if (typeof rawType === "string") {
const uType = rawType.toUpperCase();
if (["INT", "FLOAT", "BOOLEAN", "STRING", "COMBO"].includes(uType)) {
pType = uType; isSupported = true;
} else if (uType === "NUMBER") {
pType = "FLOAT"; isSupported = true;
} else if (uType === "*" || uType === "") {
pType = "STRING"; isSupported = true; // 允许通配符透传
}
}
}
// 🛡️ 第二层校验:从静态节点定义 nodeDefs 查找类型
const nodeDefs = app.nodeDefs || app.node_defs || (app.extensionManager ? app.extensionManager.nodeDefs : null);
const nodeDef = (nodeDefs ? nodeDefs[targetNode.type] : null) || targetNode.constructor?.nodeData;
if (nodeDef && nodeDef.input) {
const baseSlotName = targetSlotName.replace(/_\d+$/, ''); // 剥离 _1 后缀
let paramDef = nodeDef.input.required?.[targetSlotName] || nodeDef.input.optional?.[targetSlotName] ||
nodeDef.input.required?.[baseSlotName] || nodeDef.input.optional?.[baseSlotName] ||
(targetSlot.label ? (nodeDef.input.required?.[targetSlot.label] || nodeDef.input.optional?.[targetSlot.label]) : null);
if (paramDef) {
const typeInfo = paramDef[0]; pOpts = { ...pOpts, ...(paramDef[1] || {}) };
if (paramDef[1] && paramDef[1].step !== undefined) pythonPresetStep = paramDef[1].step; // 👈 2. 记录 Python 真实的预设 (如 8)
const optionsList = pOpts.options || pOpts.values;
if (Array.isArray(typeInfo)) {
pType = "COMBO"; pValues = typeInfo; isSupported = true;
} else if (typeInfo === "COMBO" || (pOpts && Array.isArray(optionsList))) {
pType = "COMBO"; pValues = optionsList; isSupported = true;
} else if (["INT", "FLOAT", "BOOLEAN", "STRING"].includes(String(typeInfo).toUpperCase())) {
pType = String(typeInfo).toUpperCase(); isSupported = true;
}
}
}
// 🛡️ 第三层校验:深度解析 liveWidget 控件本身属性 (包含 converted-widget)
if (liveWidget) {
const wType = String(liveWidget._origType || liveWidget.type || "").toUpperCase();
const opts = liveWidget.options || liveWidget._origOptions || {};
const liveOptsList = opts.options || opts.values;
pOpts = { ...opts, ...pOpts };
if (Array.isArray(liveOptsList) && liveOptsList.length > 0) {
pType = "COMBO"; pValues = liveOptsList; isSupported = true;
} else if (wType === "TOGGLE" || wType === "BOOLEAN") {
pType = "BOOLEAN"; isSupported = true;
} else if (wType === "NUMBER" || wType === "SLIDER") {
pType = (pOpts.precision === 0 || opts.precision === 0) ? "INT" : "FLOAT";
isSupported = true;
} else if (wType === "TEXT" || wType === "CUSTOMTEXT" || wType === "STRING" || wType === "CONVERTED-WIDGET") {
if (!isSupported) { pType = "STRING"; isSupported = true; }
}
if (liveWidget.value !== undefined) {
pOpts.default = liveWidget.value;
}
}
// 🛡️ 第四层兜底:对于槽位名称包含显式关键词的,直接放行
/*const sNameLower = (targetSlotName + " " + (targetSlot.label || "")).toLowerCase();
if (!isSupported) {
if (sNameLower.includes("text") || sNameLower.includes("prompt") || sNameLower.includes("string") || sNameLower.includes("name")) {
pType = "STRING"; isSupported = true;
} else if (sNameLower.includes("seed")) {
pType = "SEED"; isSupported = true;
} else if (sNameLower.includes("step") || sNameLower.includes("width") || sNameLower.includes("height") || sNameLower.includes("batch")) {
pType = "INT"; isSupported = true;
} else if (sNameLower.includes("cfg") || sNameLower.includes("denoise") || sNameLower.includes("strength")) {
pType = "FLOAT"; isSupported = true;
}
}*/
// 💡 智能模型文件夹归类
const tClass = String(targetNode.type).toLowerCase();
const tSlot = String(targetSlotName).toLowerCase();
const sLabel = String(targetSlot.label || "").toLowerCase();
if (pType === "COMBO") {
isSupported = true;
if (tClass.includes("load") && (tSlot.includes("image") || tSlot.includes("video") || tSlot.includes("file"))) {
pType = "UPLOADER";
} else if (tSlot.includes("unet") || sLabel.includes("unet") || tSlot.includes("diffusion") || sLabel.includes("diffusion")) {
pOpts.folder = "diffusion_models";
} else if (tSlot.includes("ckpt") || sLabel.includes("ckpt") || tSlot.includes("checkpoint") || sLabel.includes("checkpoint")) {
pOpts.folder = "checkpoints";
} else if (tSlot.includes("lora") || sLabel.includes("lora")) {
pOpts.folder = "loras";
} else if (tSlot.includes("vae") || sLabel.includes("vae")) {
pOpts.folder = "vae";
} else if (tSlot.includes("clip") || sLabel.includes("clip")) {
pOpts.folder = "text_encoders";
} else if (tSlot.includes("control") || sLabel.includes("control") || tSlot.includes("controlnet")) {
pOpts.folder = "controlnet";
} else if (tSlot.includes("style") || sLabel.includes("style")) {
pOpts.folder = "style_models";
} else if (tSlot.includes("gligen") || sLabel.includes("gligen")) {
pOpts.folder = "gligen";
}
} else if (pType === "INT" && (tSlot.includes("seed") || sLabel.includes("seed"))) {
pType = "SEED"; isSupported = true;
} else if (tClass.includes("lorastack") && (tSlot.includes("lora_stack") || sLabel.includes("lora_stack"))) {
pType = "LORA_STACK"; pOpts.folder = "loras";
pOpts.min = 1; pOpts.max = 128; isSupported = true;
}
// ❌ 只有在四层判定全不通过时,才判定为不支持(比如真正的 MODEL / LATENT 物理模型槽位)
if (!isSupported) {
const illegalType = targetSlot.type || "Unknown";
app.extensionManager.toast.add({
severity: "warn",
summary: "Connection Rejected",
detail: `Unsupported type "${illegalType}"`,
life: 5000
});
app.graph.removeLink(link.id); this.setDirtyCanvas(true, true); return;
}
const getPrecisionFromStep = (stepVal) => {
if (!stepVal || Number.isInteger(stepVal)) return 0;
const str = String(stepVal); const decimalIdx = str.indexOf(".");
return decimalIdx === -1 ? 0 : str.length - decimalIdx - 1;
};
let derivedPrecision = undefined;
if (pOpts.precision !== undefined) { derivedPrecision = pOpts.precision; }
else if (pOpts.round !== undefined) { derivedPrecision = getPrecisionFromStep(pOpts.round); }
else if (pOpts.step !== undefined) { derivedPrecision = getPrecisionFromStep(pOpts.step); }
let key = `${targetNode.title || targetNode.type}_${targetSlotName}`.replace(/[^a-zA-Z0-9_]/g, "_");
if (config[key]) key = `${key}_${outIdx}`;
let targetLiveValue = undefined;
if (liveWidget && liveWidget.value !== undefined) {
targetLiveValue = liveWidget.value;
}
const activeWidget = this.widgets?.find(w => w.name === key);
const oldItem = oldConfig?.[key];
const isSameNode = Boolean(oldItem && oldItem._node_id !== undefined && oldItem._node_id === targetNode.id);
let fallbackValue = targetLiveValue !== undefined ? targetLiveValue : (pOpts.default !== undefined ? pOpts.default : null);
const savedValue = (isSameNode && oldConfig[key].value !== undefined) ? oldConfig[key].value : fallbackValue;
const activeValue = isSameNode && activeWidget ? activeWidget.value : savedValue;
let finalStep = pOpts.step;
if (pType === "INT") finalStep = (pythonPresetStep !== undefined && pythonPresetStep !== null) ? pythonPresetStep : 1;
config[key] = {
type: pType,
name: displayName,
default: pOpts.default,
value: activeValue,
min: pOpts.min,
max: pOpts.max,
step: finalStep,
precision: derivedPrecision,
display: pOpts.display,
values: pValues,
multiline: !!pOpts.multiline,
_slot: outIdx,
_node_id: targetNode.id,
folder: pOpts.folder
};
if (pType === "SEED" && oldItem && oldItem.seed_mode) config[key].seed_mode = oldItem.seed_mode;
validKeys.push(key);
});
this.inputs.forEach((input, inIdx) => {
if (!input.name.startsWith("bypasser_") || !input.link) return;
const link = app.graph.links[input.link];
const originNode = app.graph.getNodeById(link.origin_id);
if (!originNode || originNode.type !== "AppBuilderBypasser") return;
const nameWidget = originNode.widgets?.find(w => w.name === "bypasser_name");
const bypasserName = nameWidget ? nameWidget.value.trim() : "";
const groupWidget = originNode.widgets?.find(w => w.name === "group_name");
const groupName = groupWidget ? groupWidget.value.trim() : "";
const matchIds = originNode.inputs.filter(inp => inp.name.startsWith("node_") && inp.link).map(inp => String(app.graph.links[inp.link].origin_id));
let key = `Bypasser_In_${inIdx}`;
const activeWidget = this.widgets?.find(w => w.name === key);
const savedValue = (oldConfig[key] && oldConfig[key].value !== undefined) ? oldConfig[key].value : true;
const activeValue = activeWidget ? activeWidget.value : savedValue;
config[key] = {
type: "BYPASSER", name: bypasserName ? bypasserName : `Bypasser ${inIdx}`,
match_group: groupName ? [groupName] : null, match_id: matchIds.length > 0 ? matchIds : null,
default: true, value: activeValue, _slot: 100 + inIdx
};
validKeys.push(key);
});
if (jsonW) {
jsonW.value = JSON.stringify(config);
// 🌟 核心修复:同步更新 widgets_values 数组,确保存盘/序列化时写进磁盘 JSON 文件!
if (this.widgets_values && Array.isArray(this.widgets_values)) {
const idx = this.widgets.indexOf(jsonW);
if (idx !== -1) this.widgets_values[idx] = jsonW.value;
}
}
const hasActiveSeed = Object.values(config).some(p => p.type === "SEED");
const hasActiveUploader = Object.values(config).some(p => p.type === "UPLOADER");
let seenControlAfterGenerate = false;
for (let i = this.widgets.length - 1; i >= 0; i--) {
const w = this.widgets[i];
if (w.value === "Upload" && !w.associatedKey) { this.widgets.splice(i, 1); continue; }
if (w.name === "control_after_generate") {
if (!hasActiveSeed) { this.widgets.splice(i, 1); continue; }
else {
if (seenControlAfterGenerate) { this.widgets.splice(i, 1); continue; }
else { seenControlAfterGenerate = true; }
}
}
if (w.value === "Upload" && w.associatedKey) {
if (!validKeys.includes(w.associatedKey) || !hasActiveUploader) { this.widgets.splice(i, 1); continue; }
}
const isProtected = [
"config_json", "control_after_generate", "btn_app_view",
"converted-widget", "hidden_parameter", "Auto Launch", "Short Title"
].includes(w.name) || w.type === "hidden_parameter" || w.is_appview_button ||
(w.value === "Upload" && w.associatedKey && validKeys.includes(w.associatedKey));
if (!isProtected && !validKeys.includes(w.name)) { this.widgets.splice(i, 1); }
}
Object.entries(config).forEach(([key, param]) => {
let defaultVal = param.value !== undefined ? param.value : param.default;
if (defaultVal === null || defaultVal === undefined) {
if (param.type === "COMBO" || param.type === "UPLOADER") { defaultVal = param.values ? param.values[0] : "None"; }
else if (param.type === "INT" || param.type === "FLOAT" || param.type === "SEED") { defaultVal = 0; }
else if (param.type === "BOOLEAN") { defaultVal = true; }
else { defaultVal = ""; }
}
let existingW = this.widgets.find(w => w.name === key);
if (existingW) {
let currentWType = String(existingW.type).toUpperCase();
let targetWType = String(param.type).toUpperCase();
if (targetWType === "FLOAT" || targetWType === "INT" || targetWType === "SEED") targetWType = "NUMBER";
if (targetWType === "BOOLEAN") targetWType = "TOGGLE";
if (targetWType === "STRING" || targetWType === "LORA_STACK") targetWType = "TEXT";
if (targetWType === "UPLOADER") targetWType = "COMBO";
if (currentWType !== targetWType && !(currentWType === "CONVERTED-WIDGET" && targetWType === "NUMBER")) {
const wIdx = this.widgets.indexOf(existingW);
if (wIdx !== -1) {
if (existingW.onRemove) existingW.onRemove();
this.widgets.splice(wIdx, 1); existingW = null;
}
}
}
if (param.type === "UPLOADER") {
existingW = setupUploaderWidget(this, key, param, defaultVal, validKeys);
if (existingW) {
const origCallback = existingW.callback;
existingW.callback = (v) => { saveWidgetValueToConfig(this, key, v); if (origCallback) origCallback(v); };
}
} else if (!existingW) {
if (param.type === "BYPASSER") {
existingW = this.addWidget("toggle", key, true, (v) => {
const currentJsonW = this.widgets?.find(w => w.name === "config_json");
if (currentJsonW && currentJsonW.value) {
try {
const currentConfig = JSON.parse(currentJsonW.value);
const currentParam = currentConfig[key];
if (currentParam) { applyBypasser(v, currentParam, app.graph); }
} catch(e) {}
}
saveWidgetValueToConfig(this, key, v);
}, {});
setTimeout(() => { if (existingW) applyBypasser(existingW.value, param, app.graph); }, 100);
} else if (param.type === "SEED") {
existingW = ComfyWidgets.INT(this, key, ["INT", {
default: defaultVal, min: param.min ?? 0, max: param.max ?? 0xffffffffffffffff, control_after_generate: true
}], app).widget;
if (existingW) existingW.callback = (v) => saveWidgetValueToConfig(this, key, v);
const companionW = this.widgets.find(w => w.name === "control_after_generate");
if (companionW) {
if (param.seed_mode) companionW.value = param.seed_mode;
companionW.callback = (v) => {
saveWidgetValueToConfig(this, key, existingW ? existingW.value : defaultVal);
// 实时将 seed_mode 保存到 config_json
const currentJsonW = this.widgets?.find(w => w.name === "config_json");
if (currentJsonW && currentJsonW.value) {
try {
const currentCfg = JSON.parse(currentJsonW.value);
if (currentCfg[key]) {
currentCfg[key].seed_mode = v;
currentJsonW.value = JSON.stringify(currentCfg);
}
} catch(e) {}
}
};
}
} else if (param.type === "COMBO") {
existingW = this.addWidget("combo", key, defaultVal, ()=>{}, {values: param.values});
} else if (param.type === "INT" || param.type === "FLOAT") {
const isInt = param.type === "INT";
const step = param.step ?? (isInt ? 1 : 0.1);
const prec = param.precision ?? (isInt ? 0 : 3);
const isSlider = param.display === "slider" || param.slider === true;
const hasBounds = param.min !== undefined && param.max !== undefined;
const useSlider = isSlider && hasBounds;
const minVal = param.min ?? -999999; const maxVal = param.max ?? 999999;
const widgetStep = useSlider ? step : step * 10;
existingW = this.addWidget(useSlider ? "slider" : "number", key, defaultVal, (v)=>{
saveWidgetValueToConfig(this, key, v);
}, { min: minVal, max: maxVal, step: widgetStep, precision: prec });
} else if (param.type === "BOOLEAN") {
existingW = this.addWidget("toggle", key, Boolean(defaultVal ?? true), (v) => {
saveWidgetValueToConfig(this, key, v);
}, {});
} else if (param.type === "LORA_STACK") {
existingW = this.addWidget("text", key, defaultVal || "[]", (v) => saveWidgetValueToConfig(this, key, v), { read_only: true });
if (existingW) {
existingW.disabled = true;
existingW.options = existingW.options || {};
existingW.options.read_only = true;
existingW.label = `🔒 ${param.name || key}`;
}
} else if (param.type === "STRING" && param.multiline) {
existingW = ComfyWidgets.STRING(this, key, ["STRING", { multiline: true, default: defaultVal || "" }], app).widget;
if (existingW) existingW.callback = (v) => saveWidgetValueToConfig(this, key, v);
if (existingW.inputEl) {
if (existingW.inputEl) existingW.inputEl.placeholder = param.name;
existingW.inputEl.addEventListener("input", (e) => { saveWidgetValueToConfig(this, key, e.target.value); });
}
} else {
existingW = this.addWidget("text", key, defaultVal, (v) => saveWidgetValueToConfig(this, key, v), {});
}
const label = param.name || key;
existingW.label = (param.type === "LORA_STACK") ? `🔒 ${label}` : label;
} else {
const label = param.name || key;
existingW.label = (param.type === "LORA_STACK") ? `🔒 ${label}` : label;
existingW.callback = (v) => saveWidgetValueToConfig(this, key, v);
}
});
const staticWidgets = this.widgets.filter(w =>
w.name === "config_json" || w.name === "converted-widget" || w.type === "hidden_parameter" || w.is_appview_button || w.name === "Auto Launch" || w.name === "Short Title"
);
const sortedDynamicWidgets = [];
Object.keys(config).forEach(key => {
const w = this.widgets.find(wd => wd.name === key);
if (w) {
sortedDynamicWidgets.push(w);
if (config[key].type === "SEED") {
const companion = this.widgets.find(c => c.name === "control_after_generate");
if (companion) sortedDynamicWidgets.push(companion);
}
if (config[key].type === "UPLOADER") {
const uploadBtn = this.widgets.find(u => u.value === "Upload" && u.associatedKey === key);
if (uploadBtn) sortedDynamicWidgets.push(uploadBtn);
}
}
});
const remainingWidgets = this.widgets.filter(w => !staticWidgets.includes(w) && !sortedDynamicWidgets.includes(w));
this.widgets = [...staticWidgets, ...sortedDynamicWidgets, ...remainingWidgets];
if (this.widgets_values && Array.isArray(this.widgets_values)) {
this.widgets.forEach((w, idx) => {
if (this.widgets_values[idx] !== undefined && this.widgets_values[idx] !== null) {
w.value = this.widgets_values[idx];
}
});
}
this.setDirtyCanvas(true, true);
};
}
}
});