refactor(workflow_ui): 抽出 bootstrap 子模块
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,799 @@
|
||||
pub(super) fn inject_bootstrap(html: String, config: &crate::config::ComfyUIConfig) -> String {
|
||||
if config.uses_native_backend() && !config.uses_cloud_backend() {
|
||||
return inject_external_bootstrap(html);
|
||||
}
|
||||
inject_enhanced_bootstrap(html)
|
||||
}
|
||||
|
||||
fn workflow_secret_cleanup_bootstrap() -> &'static str {
|
||||
r#"<script>
|
||||
(function () {
|
||||
const clearLeakedComfyApiKey = () => {
|
||||
try {
|
||||
localStorage.removeItem('comfy_api_key');
|
||||
sessionStorage.removeItem('comfy_api_key');
|
||||
document.cookie = 'comfy_api_key=; Path=/workflow-ui; Max-Age=0; SameSite=Lax';
|
||||
} catch (err) {}
|
||||
};
|
||||
clearLeakedComfyApiKey();
|
||||
window.addEventListener('DOMContentLoaded', clearLeakedComfyApiKey);
|
||||
})();
|
||||
</script>"#
|
||||
}
|
||||
|
||||
fn inject_external_bootstrap(html: String) -> String {
|
||||
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
|
||||
let shell = r#"<script>
|
||||
(function () {
|
||||
const renameComfyUiBrand = () => {
|
||||
document.title = document.title.replace(/ComfyUI/g, 'BoosAI');
|
||||
const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 80);
|
||||
for (const el of candidates) {
|
||||
if (el.childNodes.length === 1 && el.firstChild && el.firstChild.nodeType === Node.TEXT_NODE) {
|
||||
const value = String(el.textContent || '').trim();
|
||||
if (value === 'ComfyUI' || value === 'Comfy UI') el.textContent = 'BoosAI';
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
const userJwt = localStorage.getItem('user_jwt_token');
|
||||
const adminToken = localStorage.getItem('auth_token');
|
||||
const token = adminToken || userJwt;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
document.cookie = 'boosapi_workflow_token=' + encodeURIComponent(token) + '; Path=/workflow-ui; SameSite=Lax';
|
||||
}
|
||||
} catch (err) {}
|
||||
const getCurrentWorkflowJson = async () => {
|
||||
try {
|
||||
if (window.app?.graphToPrompt) {
|
||||
const result = await window.app.graphToPrompt();
|
||||
return result?.workflow || result?.output || result?.prompt || result;
|
||||
}
|
||||
} catch (err) {}
|
||||
try {
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
if (graph?.serialize) return graph.serialize();
|
||||
} catch (err) {}
|
||||
return null;
|
||||
};
|
||||
const installWorkflowValidationButton = () => {
|
||||
if (document.getElementById('boosapi-workflow-validate-button')) return;
|
||||
const button = document.createElement('button');
|
||||
button.id = 'boosapi-workflow-validate-button';
|
||||
button.type = 'button';
|
||||
button.textContent = '检查模板';
|
||||
button.title = '检查当前工作流的节点、模型和输入素材兼容性';
|
||||
button.style.cssText = 'position:fixed;right:112px;top:10px;z-index:10000;height:32px;padding:0 12px;border:1px solid rgba(120,130,150,.45);border-radius:6px;background:rgba(20,24,32,.88);color:#fff;font-size:13px;cursor:pointer;';
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const oldText = button.textContent;
|
||||
button.textContent = '检查中...';
|
||||
try {
|
||||
const workflow = await getCurrentWorkflowJson();
|
||||
if (!workflow) throw new Error('没有读取到当前工作流');
|
||||
const response = await fetch('/workflow-ui/api/workflow/validate?check_files=true', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(workflow),
|
||||
});
|
||||
const report = await response.json();
|
||||
const summary = report?.summary || {};
|
||||
const lines = [`节点 ${summary.nodes || 0},连线 ${summary.links || 0},错误 ${summary.errors || 0},警告 ${summary.warnings || 0}`];
|
||||
for (const item of report?.missingNodeTypes || []) lines.push(`缺失节点: ${item.nodeType} #${item.nodeId}${item.connected ? '(已连接)' : '(未连接)'}`);
|
||||
for (const item of report?.emptyModelFields || []) lines.push(`空模型列表: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
for (const item of report?.missingModelChoices || []) lines.push(`缺失模型: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
for (const item of report?.inputFiles || []) if (item.checked) lines.push(`素材: ${item.filename} ${item.reachable ? '可访问' : '不可访问'}`);
|
||||
if (report?.hints?.length) lines.push('', ...report.hints);
|
||||
window.alert(lines.join('\n'));
|
||||
} catch (err) {
|
||||
window.alert('检查失败:' + (err?.message || err));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = oldText;
|
||||
}
|
||||
});
|
||||
document.body.appendChild(button);
|
||||
};
|
||||
window.setTimeout(renameComfyUiBrand, 250);
|
||||
window.setTimeout(renameComfyUiBrand, 1200);
|
||||
window.setTimeout(installWorkflowValidationButton, 800);
|
||||
window.setTimeout(installWorkflowValidationButton, 1800);
|
||||
})();
|
||||
</script>"#;
|
||||
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
|
||||
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
||||
}
|
||||
|
||||
pub(super) fn inject_enhanced_bootstrap(html: String) -> String {
|
||||
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
|
||||
let shell = r#"<style id="boosapi-workflow-shell-style">
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
const renameComfyUiBrand = () => {
|
||||
document.title = document.title.replace(/ComfyUI/g, 'BoosAI');
|
||||
const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 120);
|
||||
for (const el of candidates) {
|
||||
if (el.childNodes.length === 1 && el.firstChild && el.firstChild.nodeType === Node.TEXT_NODE) {
|
||||
const value = String(el.textContent || '').trim();
|
||||
if (value === 'ComfyUI' || value === 'Comfy UI') {
|
||||
el.textContent = 'BoosAI';
|
||||
}
|
||||
}
|
||||
for (const attr of ['aria-label', 'title']) {
|
||||
const value = el.getAttribute(attr);
|
||||
if (value && /Comfy\s*UI|ComfyUI/.test(value)) {
|
||||
el.setAttribute(attr, value.replace(/Comfy\s*UI|ComfyUI/g, 'BoosAI'));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const installBoosApiFetchAuth = () => {
|
||||
try {
|
||||
const api = window.app?.api || window.comfyAPI?.api || window.api;
|
||||
if (!api || api.__boosapiFetchAuthPatch || typeof api.fetchApi !== 'function') return;
|
||||
const originalFetchApi = api.fetchApi.bind(api);
|
||||
api.fetchApi = function(route, options) {
|
||||
options ||= {};
|
||||
const headers = new Headers(options.headers || {});
|
||||
const token = localStorage.getItem('auth_token') || localStorage.getItem('user_jwt_token');
|
||||
if (token && !headers.has('Authorization')) headers.set('Authorization', 'Bearer ' + token);
|
||||
options.headers = headers;
|
||||
options.credentials ||= 'same-origin';
|
||||
return originalFetchApi(route, options);
|
||||
};
|
||||
api.__boosapiFetchAuthPatch = true;
|
||||
} catch (err) {}
|
||||
};
|
||||
const workflowTextMap = new Map(Object.entries({
|
||||
'Add Node': '添加节点',
|
||||
'Search': '搜索',
|
||||
'Reroute': '转接点',
|
||||
'PreviewImage': '预览图像',
|
||||
'SaveImage': '保存图像',
|
||||
'Load Image': '加载图像',
|
||||
'CheckpointLoaderSimple': 'Checkpoint 加载器(简易)',
|
||||
'CLIPTextEncode': 'CLIP 文本编码',
|
||||
'KSampler': 'K 采样器',
|
||||
'VAEDecode': 'VAE 解码',
|
||||
'VAEEncode': 'VAE 编码',
|
||||
'EmptyLatentImage': '空潜空间图像',
|
||||
'Latent': '潜空间',
|
||||
'Input': '输入',
|
||||
'Output': '输出',
|
||||
'Util': '工具',
|
||||
'utils': '工具',
|
||||
'ComfyUI': 'BoosAI',
|
||||
'IMAGE': '图像',
|
||||
'LATENT': '潜空间',
|
||||
'MODEL': '模型',
|
||||
'CLIP': 'CLIP',
|
||||
'VAE': 'VAE',
|
||||
'CONDITIONING': '条件',
|
||||
'BoosAPI': 'BoosAPI',
|
||||
'boosapi.image_to_video': 'BoosAPI 图生视频',
|
||||
'boosapi.text_to_video': 'BoosAPI 文生视频',
|
||||
'boosapi.video_status': '查询视频任务',
|
||||
'output.preview_video': '输出:预览视频',
|
||||
'output.preview_image': '输出:预览图像',
|
||||
'input.text': '输入:文本',
|
||||
'boosapi.chat_completion': 'BoosAPI 对话',
|
||||
'boosapi.prompt_rewrite': 'BoosAPI 提示词改写',
|
||||
'boosapi.text_to_image': 'BoosAPI 文生图',
|
||||
'output.save_file': '输出:保存文件'
|
||||
}));
|
||||
const translateWorkflowText = (text) => {
|
||||
const raw = String(text || '');
|
||||
const trimmed = raw.replace(/\s+/g, ' ').trim();
|
||||
if (!trimmed) return null;
|
||||
if (workflowTextMap.has(trimmed)) return workflowTextMap.get(trimmed);
|
||||
return null;
|
||||
};
|
||||
const localizeWorkflowMenus = () => {
|
||||
const selector = [
|
||||
'.litecontextmenu .litemenu-entry',
|
||||
'.litecontextmenu .litemenu-title',
|
||||
'.litecontextmenu .litemenu-submenu',
|
||||
'.litecontextmenu li',
|
||||
'.litecontextmenu div',
|
||||
'.p-contextmenu [role="menuitem"]',
|
||||
'.p-contextmenu .p-menuitem-text',
|
||||
'.p-tieredmenu [role="menuitem"]',
|
||||
'.p-tieredmenu .p-menuitem-text',
|
||||
'[role="menu"] [role="menuitem"]'
|
||||
].join(',');
|
||||
for (const el of document.querySelectorAll(selector)) {
|
||||
if (!el || el.children.length > 1) continue;
|
||||
const textNode = Array.from(el.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && String(node.textContent || '').trim());
|
||||
const target = textNode || (el.children.length === 0 ? el : null);
|
||||
if (!target) continue;
|
||||
const translated = translateWorkflowText(target.textContent);
|
||||
if (translated && target.textContent !== translated) target.textContent = translated;
|
||||
}
|
||||
for (const el of document.querySelectorAll('[title],[aria-label]')) {
|
||||
for (const attr of ['title', 'aria-label']) {
|
||||
const translated = translateWorkflowText(el.getAttribute(attr));
|
||||
if (translated) el.setAttribute(attr, translated);
|
||||
}
|
||||
}
|
||||
};
|
||||
const observeWorkflowMenuLocalization = () => {
|
||||
if (window.__boosapiWorkflowMenuI18nObserver) return;
|
||||
window.__boosapiWorkflowMenuI18nObserver = true;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((item) => item.addedNodes && item.addedNodes.length)) {
|
||||
window.requestAnimationFrame(localizeWorkflowMenus);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
};
|
||||
const normalizeWorkflowPortType = (value) => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'conditioning' || normalized === 'condition' || normalized === 'cond' || normalized === '条件' || normalized === '条件化' || normalized === '正面条件' || normalized === '负面条件') return 'conditioning';
|
||||
if (normalized === 'latent' || normalized === '潜空间') return 'latent';
|
||||
if (normalized === 'model' || normalized === '模型') return 'model';
|
||||
if (normalized === 'clip') return 'clip';
|
||||
if (normalized === 'vae') return 'vae';
|
||||
if (normalized === 'image' || normalized === '图像' || normalized === '图片') return 'image';
|
||||
if (normalized === 'video' || normalized === '视频') return 'video';
|
||||
return normalized;
|
||||
};
|
||||
const normalizeWorkflowSlotType = (slot) => {
|
||||
if (!slot) return;
|
||||
const normalized = normalizeWorkflowPortType(slot.type || slot.name || slot.localized_name);
|
||||
const canonical = {
|
||||
conditioning: 'CONDITIONING',
|
||||
latent: 'LATENT',
|
||||
model: 'MODEL',
|
||||
clip: 'CLIP',
|
||||
vae: 'VAE',
|
||||
image: 'IMAGE',
|
||||
video: 'VIDEO'
|
||||
}[normalized];
|
||||
if (canonical) slot.type = canonical;
|
||||
};
|
||||
const slotName = (slot) => String(slot?.name || slot?.localized_name || slot?.label || '').trim().toLowerCase();
|
||||
const normalizeWorkflowNodeSlots = (node) => {
|
||||
try {
|
||||
if (!node) return;
|
||||
if (node.type === 'CLIPTextEncode' && Array.isArray(node.outputs)) {
|
||||
for (const output of node.outputs) {
|
||||
const name = slotName(output);
|
||||
if (name.includes('conditioning') || name.includes('condition') || name.includes('条件') || name === 'conditioning') {
|
||||
output.type = 'CONDITIONING';
|
||||
output.name = output.name || 'CONDITIONING';
|
||||
output.localized_name = output.localized_name || '条件';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.type === 'KSampler' && Array.isArray(node.inputs)) {
|
||||
for (const input of node.inputs) {
|
||||
const name = slotName(input);
|
||||
if (name === 'positive' || name === 'negative' || name.includes('positive') || name.includes('negative') || name.includes('正面条件') || name.includes('负面条件')) {
|
||||
input.type = 'CONDITIONING';
|
||||
}
|
||||
}
|
||||
}
|
||||
node.inputs?.forEach(normalizeWorkflowSlotType);
|
||||
node.outputs?.forEach(normalizeWorkflowSlotType);
|
||||
} catch (err) {}
|
||||
};
|
||||
const normalizeWorkflowGraphSlots = () => {
|
||||
try {
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
if (!graph?.nodes) return;
|
||||
for (const node of graph.nodes) normalizeWorkflowNodeSlots(node);
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
window.app?.canvas?.setDirty?.(true, true);
|
||||
} catch (err) {}
|
||||
};
|
||||
const patchWorkflowConnectionTypes = () => {
|
||||
try {
|
||||
const liteGraph = window.LiteGraph;
|
||||
if (!liteGraph || liteGraph.__boosapiConnectionPatch) return;
|
||||
const original = liteGraph.isValidConnection;
|
||||
if (typeof original !== 'function') return;
|
||||
liteGraph.isValidConnection = function (typeA, typeB) {
|
||||
const a = normalizeWorkflowPortType(typeA);
|
||||
const b = normalizeWorkflowPortType(typeB);
|
||||
if (a && b && a === b) return true;
|
||||
return original.call(this, typeA, typeB);
|
||||
};
|
||||
const proto = window.LGraphNode?.prototype;
|
||||
if (proto?.connect && !proto.__boosapiConnectPatch) {
|
||||
const originalConnect = proto.connect;
|
||||
proto.connect = function (slot, targetNode, targetSlot, afterRerouteId) {
|
||||
normalizeWorkflowNodeSlots(this);
|
||||
normalizeWorkflowNodeSlots(targetNode);
|
||||
return originalConnect.call(this, slot, targetNode, targetSlot, afterRerouteId);
|
||||
};
|
||||
proto.__boosapiConnectPatch = true;
|
||||
}
|
||||
const originalCreateNode = liteGraph.createNode;
|
||||
if (typeof originalCreateNode === 'function' && !liteGraph.__boosapiCreateNodePatch) {
|
||||
liteGraph.createNode = function () {
|
||||
const node = originalCreateNode.apply(this, arguments);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
return node;
|
||||
};
|
||||
liteGraph.__boosapiCreateNodePatch = true;
|
||||
}
|
||||
liteGraph.__boosapiConnectionPatch = true;
|
||||
normalizeWorkflowGraphSlots();
|
||||
} catch (err) {}
|
||||
};
|
||||
const startWorkflowSlotNormalizer = () => {
|
||||
if (window.__boosapiWorkflowSlotNormalizer) return;
|
||||
window.__boosapiWorkflowSlotNormalizer = true;
|
||||
window.setInterval(normalizeWorkflowGraphSlots, 1000);
|
||||
window.setTimeout(normalizeWorkflowGraphSlots, 100);
|
||||
window.setTimeout(normalizeWorkflowGraphSlots, 600);
|
||||
window.setTimeout(normalizeWorkflowGraphSlots, 1500);
|
||||
};
|
||||
const workflowConnectionQuickNodes = {
|
||||
model: [
|
||||
{ title: 'K 采样器', types: ['KSampler'], hint: '把模型接入采样流程' },
|
||||
{ title: 'LoRA 加载器', types: ['LoraLoader', 'LoraLoaderModelOnly'], hint: '给模型叠加 LoRA' },
|
||||
{ title: '模型采样设置', types: ['ModelSamplingDiscrete', 'ModelSamplingContinuousEDM'], hint: '调整模型采样方式' },
|
||||
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||
],
|
||||
clip: [
|
||||
{ title: 'CLIP 文本编码', types: ['CLIPTextEncode'], hint: '输入提示词生成条件' },
|
||||
{ title: 'CLIP 跳层', types: ['CLIPSetLastLayer'], hint: '设置 CLIP skip' },
|
||||
{ title: 'LoRA 加载器', types: ['LoraLoader'], hint: '同时接入模型与 CLIP' },
|
||||
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||
],
|
||||
vae: [
|
||||
{ title: 'VAE 解码', types: ['VAEDecode', 'VAEDecodeTiled'], hint: '潜空间转图像' },
|
||||
{ title: 'VAE 编码', types: ['VAEEncode', 'VAEEncodeTiled'], hint: '图像转潜空间' },
|
||||
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||
],
|
||||
image: [
|
||||
{ title: '预览图像', types: ['PreviewImage'], hint: '查看这一条图像输出' },
|
||||
{ title: '保存图像', types: ['SaveImage'], hint: '保存到输出目录' },
|
||||
{ title: 'VAE 编码', types: ['VAEEncode', 'VAEEncodeTiled'], hint: '图像转潜空间' },
|
||||
{ title: '放大图像', types: ['ImageScale', 'ImageScaleBy', 'ImageUpscaleWithModel'], hint: '调整尺寸/放大' },
|
||||
{ title: '图像对比', types: ['Image Comparer (rgthree)', 'Image Comparer', 'ImageCompare'], hint: '对比输入图像' },
|
||||
{ title: '图像转遮罩', types: ['ImageToMask'], hint: '从图像提取遮罩' }
|
||||
],
|
||||
latent: [
|
||||
{ title: 'VAE 解码', types: ['VAEDecode', 'VAEDecodeTiled'], hint: '潜空间转图像' },
|
||||
{ title: 'K 采样器', types: ['KSampler'], hint: '继续采样' },
|
||||
{ title: '潜空间放大', types: ['LatentUpscale', 'LatentUpscaleBy'], hint: '调整潜空间尺寸' },
|
||||
{ title: '潜空间合成', types: ['LatentComposite'], hint: '合成两个潜空间' }
|
||||
],
|
||||
conditioning: [
|
||||
{ title: 'K 采样器', types: ['KSampler'], hint: '接入正/负条件' },
|
||||
{ title: '合并条件', types: ['ConditioningCombine', 'ConditioningConcat'], hint: '组合提示条件' },
|
||||
{ title: '条件平均', types: ['ConditioningAverage'], hint: '混合两组条件' },
|
||||
{ title: '条件区域', types: ['ConditioningSetArea'], hint: '限制条件作用区域' }
|
||||
],
|
||||
mask: [
|
||||
{ title: '遮罩转图像', types: ['MaskToImage'], hint: '查看/保存遮罩' },
|
||||
{ title: '图像转遮罩', types: ['ImageToMask'], hint: '从图像提取遮罩' },
|
||||
{ title: '遮罩合成', types: ['MaskComposite'], hint: '组合遮罩' },
|
||||
{ title: '遮罩扩张', types: ['GrowMask'], hint: '扩大或收缩遮罩' }
|
||||
],
|
||||
video: [
|
||||
{ title: '预览视频', types: ['VHS_VideoCombine', 'Video Combine 🎥🅥🅗🅢', 'VideoCombine'], hint: '合成为可预览视频' },
|
||||
{ title: '保存动画', types: ['SaveAnimatedPNG', 'SaveAnimatedWEBP'], hint: '保存序列动画' },
|
||||
{ title: '加载视频', types: ['VHS_LoadVideo', 'LoadVideo'], hint: '读取视频素材' }
|
||||
],
|
||||
audio: [
|
||||
{ title: '预览音频', types: ['PreviewAudio', 'SaveAudio'], hint: '试听或保存音频' },
|
||||
{ title: '加载音频', types: ['LoadAudio'], hint: '读取音频素材' }
|
||||
],
|
||||
string: [
|
||||
{ title: '文本输入', types: ['PrimitiveNode', 'StringConstant', 'Text Input'], hint: '创建文本值' },
|
||||
{ title: '显示文本', types: ['ShowText|pysssss', 'ShowText', 'PreviewText'], hint: '查看文本输出' }
|
||||
],
|
||||
int: [
|
||||
{ title: '数值输入', types: ['PrimitiveNode', 'Integer Constant'], hint: '创建整数值' }
|
||||
],
|
||||
float: [
|
||||
{ title: '数值输入', types: ['PrimitiveNode', 'Float Constant'], hint: '创建小数值' }
|
||||
],
|
||||
boolean: [
|
||||
{ title: '开关输入', types: ['PrimitiveNode', 'Boolean Constant'], hint: '创建布尔值' }
|
||||
]
|
||||
};
|
||||
const findWorkflowNodeType = (candidates) => {
|
||||
const registry = window.LiteGraph?.registered_node_types || {};
|
||||
for (const name of candidates) {
|
||||
if (registry[name]) return name;
|
||||
}
|
||||
const entries = Object.keys(registry);
|
||||
for (const name of candidates) {
|
||||
const normalized = String(name).toLowerCase().replace(/[\s_-]+/g, '');
|
||||
const found = entries.find((item) => String(item).toLowerCase().replace(/[\s_-]+/g, '') === normalized);
|
||||
if (found) return found;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const getWorkflowConnectionType = (options) => {
|
||||
try {
|
||||
const link = options || window.__boosapiLastConnectionMenuOptions || {};
|
||||
if (link.nodeFrom && Number.isInteger(link.slotFrom)) {
|
||||
return normalizeWorkflowPortType(link.nodeFrom.outputs?.[link.slotFrom]?.type || link.nodeFrom.outputs?.[link.slotFrom]?.name);
|
||||
}
|
||||
if (link.nodeTo && Number.isInteger(link.slotTo)) {
|
||||
return normalizeWorkflowPortType(link.nodeTo.inputs?.[link.slotTo]?.type || link.nodeTo.inputs?.[link.slotTo]?.name);
|
||||
}
|
||||
const title = String(document.querySelector('.litecontextmenu .litemenu-title')?.textContent || '');
|
||||
if (title.includes('IMAGE') || title.includes('图像')) return 'image';
|
||||
if (title.includes('LATENT') || title.includes('潜空间')) return 'latent';
|
||||
if (title.includes('CONDITIONING') || title.includes('条件')) return 'conditioning';
|
||||
if (title.includes('MODEL') || title.includes('模型')) return 'model';
|
||||
if (title.includes('CLIP')) return 'clip';
|
||||
if (title.includes('VAE')) return 'vae';
|
||||
if (title.includes('MASK') || title.includes('遮罩')) return 'mask';
|
||||
if (title.includes('VIDEO') || title.includes('视频')) return 'video';
|
||||
if (title.includes('AUDIO') || title.includes('音频')) return 'audio';
|
||||
if (title.includes('STRING') || title.includes('TEXT') || title.includes('文本')) return 'string';
|
||||
if (title.includes('INT') || title.includes('整数')) return 'int';
|
||||
if (title.includes('FLOAT') || title.includes('NUMBER') || title.includes('数字')) return 'float';
|
||||
if (title.includes('BOOLEAN') || title.includes('BOOL') || title.includes('布尔')) return 'boolean';
|
||||
} catch (err) {}
|
||||
return '';
|
||||
};
|
||||
const workflowNodeDisplayTitle = (nodeType) => {
|
||||
const registry = window.LiteGraph?.registered_node_types || {};
|
||||
const ctor = registry[nodeType];
|
||||
return String(ctor?.title || ctor?.nodeData?.display_name || ctor?.comfyClass || nodeType || '').trim();
|
||||
};
|
||||
const fallbackWorkflowConnectionItems = (connectionType) => {
|
||||
try {
|
||||
const options = window.__boosapiLastConnectionMenuOptions || {};
|
||||
const liteGraph = window.LiteGraph || {};
|
||||
const canonical = {
|
||||
image: 'IMAGE',
|
||||
latent: 'LATENT',
|
||||
conditioning: 'CONDITIONING',
|
||||
model: 'MODEL',
|
||||
clip: 'CLIP',
|
||||
vae: 'VAE',
|
||||
mask: 'MASK',
|
||||
video: 'VIDEO',
|
||||
audio: 'AUDIO',
|
||||
string: 'STRING',
|
||||
int: 'INT',
|
||||
float: 'FLOAT',
|
||||
boolean: 'BOOLEAN'
|
||||
}[connectionType] || String(connectionType || '').toUpperCase();
|
||||
const defaults = options.nodeFrom
|
||||
? liteGraph.slot_types_default_out?.[canonical]
|
||||
: liteGraph.slot_types_default_in?.[canonical];
|
||||
const registered = options.nodeFrom
|
||||
? liteGraph.registered_slot_in_types?.[String(canonical).toLowerCase()]?.nodes
|
||||
: liteGraph.registered_slot_out_types?.[canonical]?.nodes;
|
||||
const names = [...(Array.isArray(defaults) ? defaults : []), ...(Array.isArray(registered) ? registered : [])]
|
||||
.map((value) => typeof value === 'string' ? value : value?.comfyClass || value?.type || value?.title)
|
||||
.filter(Boolean);
|
||||
return [...new Set(names)].slice(0, 8).map((nodeType) => ({
|
||||
title: workflowNodeDisplayTitle(nodeType),
|
||||
types: [nodeType],
|
||||
hint: `${canonical} 兼容节点`
|
||||
}));
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const findCompatibleSlotIndex = (slots, type) => {
|
||||
const wanted = normalizeWorkflowPortType(type);
|
||||
if (!Array.isArray(slots)) return 0;
|
||||
const exact = slots.findIndex((slot) => normalizeWorkflowPortType(slot?.type || slot?.name || slot?.localized_name) === wanted);
|
||||
return exact >= 0 ? exact : 0;
|
||||
};
|
||||
const connectedNodePosition = (options) => {
|
||||
const event = options?.e || {};
|
||||
if (typeof event.canvasX === 'number' && typeof event.canvasY === 'number') {
|
||||
return [event.canvasX + 24, event.canvasY - 28];
|
||||
}
|
||||
try {
|
||||
const canvas = window.app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
const area = canvas?.ds?.visible_area;
|
||||
if (Array.isArray(area)) return [area[0] + area[2] / 2 - 120, area[1] + area[3] / 2 - 80];
|
||||
} catch (err) {}
|
||||
return [180, 180];
|
||||
};
|
||||
const addConnectedWorkflowQuickNode = (item) => {
|
||||
try {
|
||||
const options = window.__boosapiLastConnectionMenuOptions || {};
|
||||
const nodeType = findWorkflowNodeType(item.types || []);
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = window.app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
if (!nodeType || !graph || !window.LiteGraph?.createNode) return false;
|
||||
const node = window.LiteGraph.createNode(nodeType, item.title || nodeType);
|
||||
if (!node) return false;
|
||||
node.pos = connectedNodePosition(options);
|
||||
graph.add(node);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
if (options.nodeFrom && Number.isInteger(options.slotFrom)) {
|
||||
const output = options.nodeFrom.outputs?.[options.slotFrom];
|
||||
const inputIndex = findCompatibleSlotIndex(node.inputs, output?.type || output?.name);
|
||||
options.nodeFrom.connect(options.slotFrom, node, inputIndex, options.afterRerouteId);
|
||||
} else if (options.nodeTo && Number.isInteger(options.slotTo)) {
|
||||
const input = options.nodeTo.inputs?.[options.slotTo];
|
||||
const outputIndex = findCompatibleSlotIndex(node.outputs, input?.type || input?.name);
|
||||
node.connect(outputIndex, options.nodeTo, options.slotTo, options.afterRerouteId);
|
||||
}
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas?.selectNode?.(node);
|
||||
if (window.LiteGraph?.closeAllContextMenus) window.LiteGraph.closeAllContextMenus(window);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const installWorkflowConnectionMenuMemory = () => {
|
||||
try {
|
||||
const proto = window.LGraphCanvas?.prototype;
|
||||
if (!proto?.showConnectionMenu || proto.__boosapiConnectionMenuMemory) return;
|
||||
const original = proto.showConnectionMenu;
|
||||
proto.showConnectionMenu = function (options) {
|
||||
window.__boosapiLastConnectionMenuOptions = options || {};
|
||||
const result = original.apply(this, arguments);
|
||||
window.setTimeout(enhanceWorkflowConnectionMenu, 0);
|
||||
window.setTimeout(enhanceWorkflowConnectionMenu, 80);
|
||||
return result;
|
||||
};
|
||||
proto.__boosapiConnectionMenuMemory = true;
|
||||
} catch (err) {}
|
||||
};
|
||||
const enhanceWorkflowConnectionMenu = () => {
|
||||
try {
|
||||
const menu = document.querySelector('.litecontextmenu');
|
||||
if (!menu || menu.__boosapiConnectionSuggestions) return;
|
||||
const connectionType = getWorkflowConnectionType();
|
||||
const items = [
|
||||
...(workflowConnectionQuickNodes[connectionType] || []),
|
||||
...fallbackWorkflowConnectionItems(connectionType)
|
||||
];
|
||||
if (!items?.length) return;
|
||||
const available = items
|
||||
.map((item) => ({ ...item, nodeType: findWorkflowNodeType(item.types || []) }))
|
||||
.filter((item) => item.nodeType);
|
||||
if (!available.length) return;
|
||||
menu.__boosapiConnectionSuggestions = true;
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'boosapi-connection-suggestions';
|
||||
panel.style.cssText = 'padding:8px 8px 6px;border-bottom:1px solid rgba(255,255,255,.12);background:rgba(20,24,30,.96);min-width:260px;';
|
||||
const title = document.createElement('div');
|
||||
title.textContent = '最相关';
|
||||
title.style.cssText = 'font-size:12px;font-weight:700;color:#e9f7f5;margin:0 0 6px 2px;';
|
||||
panel.appendChild(title);
|
||||
for (const item of available.slice(0, 6)) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = item.title;
|
||||
button.title = item.hint || item.nodeType;
|
||||
button.style.cssText = 'display:flex;width:100%;align-items:center;justify-content:space-between;gap:12px;margin:2px 0;padding:8px 10px;border:0;border-radius:6px;background:transparent;color:#fff;text-align:left;font-size:13px;cursor:pointer;';
|
||||
button.addEventListener('mouseenter', () => { button.style.background = 'rgba(20,184,166,.22)'; });
|
||||
button.addEventListener('mouseleave', () => { button.style.background = 'transparent'; });
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
addConnectedWorkflowQuickNode(item);
|
||||
}, true);
|
||||
panel.appendChild(button);
|
||||
}
|
||||
menu.insertBefore(panel, menu.firstChild);
|
||||
} catch (err) {}
|
||||
};
|
||||
const installWorkflowConnectionMenuObserver = () => {
|
||||
if (window.__boosapiWorkflowConnectionMenuObserver) return;
|
||||
window.__boosapiWorkflowConnectionMenuObserver = true;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((item) => item.addedNodes && item.addedNodes.length)) {
|
||||
window.requestAnimationFrame(enhanceWorkflowConnectionMenu);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
};
|
||||
const boostWorkflowSlotDefaults = () => {
|
||||
try {
|
||||
const liteGraph = window.LiteGraph;
|
||||
if (!liteGraph) return;
|
||||
const prepend = (target, type, values) => {
|
||||
const current = Array.isArray(target[type]) ? target[type] : [];
|
||||
target[type] = [...values.filter((value) => findWorkflowNodeType([value])), ...current.filter((value) => !values.includes(value))].slice(0, 24);
|
||||
};
|
||||
liteGraph.slot_types_default_out ||= {};
|
||||
liteGraph.slot_types_default_in ||= {};
|
||||
prepend(liteGraph.slot_types_default_out, 'MODEL', ['KSampler', 'LoraLoader', 'ModelSamplingDiscrete']);
|
||||
prepend(liteGraph.slot_types_default_out, 'CLIP', ['CLIPTextEncode', 'CLIPSetLastLayer', 'LoraLoader']);
|
||||
prepend(liteGraph.slot_types_default_out, 'VAE', ['VAEDecode', 'VAEEncode']);
|
||||
prepend(liteGraph.slot_types_default_out, 'IMAGE', ['PreviewImage', 'SaveImage', 'VAEEncode', 'ImageScale', 'ImageScaleBy']);
|
||||
prepend(liteGraph.slot_types_default_in, 'IMAGE', ['LoadImage', 'EmptyImage', 'PreviewImage']);
|
||||
prepend(liteGraph.slot_types_default_out, 'LATENT', ['VAEDecode', 'KSampler']);
|
||||
prepend(liteGraph.slot_types_default_out, 'CONDITIONING', ['KSampler', 'ConditioningCombine']);
|
||||
prepend(liteGraph.slot_types_default_out, 'MASK', ['MaskToImage', 'ImageToMask', 'MaskComposite', 'GrowMask']);
|
||||
prepend(liteGraph.slot_types_default_out, 'VIDEO', ['VHS_VideoCombine', 'VideoCombine', 'SaveAnimatedPNG']);
|
||||
prepend(liteGraph.slot_types_default_out, 'AUDIO', ['PreviewAudio', 'SaveAudio', 'LoadAudio']);
|
||||
prepend(liteGraph.slot_types_default_out, 'STRING', ['PrimitiveNode', 'ShowText', 'PreviewText']);
|
||||
prepend(liteGraph.slot_types_default_out, 'INT', ['PrimitiveNode']);
|
||||
prepend(liteGraph.slot_types_default_out, 'FLOAT', ['PrimitiveNode']);
|
||||
prepend(liteGraph.slot_types_default_out, 'BOOLEAN', ['PrimitiveNode']);
|
||||
} catch (err) {}
|
||||
};
|
||||
const isWorkflowVideoModelName = (modelName) => {
|
||||
const value = String(modelName || '').toLowerCase();
|
||||
return ['seedance', 'wan2', 't2v', 'i2v', 'flf2v', 'video', 'seaweed'].some((keyword) => value.includes(keyword));
|
||||
};
|
||||
const addTextToImageNodeFromModel = (modelName) => {
|
||||
try {
|
||||
const name = String(modelName || '').trim();
|
||||
if (!name || !window.LiteGraph) return false;
|
||||
const app = window.app;
|
||||
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
if (!graph) return false;
|
||||
const node = window.LiteGraph.createNode('boosapi.text_to_image', 'BoosAPI 文生图');
|
||||
if (!node) return false;
|
||||
const area = canvas?.ds?.visible_area;
|
||||
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
|
||||
graph.add(node);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
const widget = node.widgets?.find((item) => item.name === 'model');
|
||||
if (widget) widget.value = name;
|
||||
node.properties ||= {};
|
||||
node.properties['Node name for S&R'] = 'boosapi.text_to_image';
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas?.selectNode?.(node);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const addTextToVideoNodeFromModel = (modelName) => {
|
||||
try {
|
||||
const name = String(modelName || '').trim();
|
||||
if (!name || !window.LiteGraph) return false;
|
||||
const app = window.app;
|
||||
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
if (!graph) return false;
|
||||
const node = window.LiteGraph.createNode('boosapi.text_to_video', 'BoosAPI 文生视频');
|
||||
if (!node) return false;
|
||||
const area = canvas?.ds?.visible_area;
|
||||
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
|
||||
graph.add(node);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
const widget = node.widgets?.find((item) => item.name === 'model');
|
||||
if (widget) widget.value = name;
|
||||
node.properties ||= {};
|
||||
node.properties['Node name for S&R'] = 'boosapi.text_to_video';
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas?.selectNode?.(node);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const patchModelLibraryClicks = () => {
|
||||
document.querySelectorAll('#model-library .p-tree-node-content, #model-library [role="treeitem"], .model-lib-tree-explorer .p-tree-node-content').forEach((el) => {
|
||||
if (el.__boosapiModelClickPatch) return;
|
||||
el.__boosapiModelClickPatch = true;
|
||||
el.addEventListener('dblclick', (event) => {
|
||||
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || text.includes('checkpoints')) return;
|
||||
const added = isWorkflowVideoModelName(text)
|
||||
? addTextToVideoNodeFromModel(text)
|
||||
: addTextToImageNodeFromModel(text);
|
||||
if (added) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
};
|
||||
const getCurrentWorkflowJson = async () => {
|
||||
try {
|
||||
if (window.app?.graphToPrompt) {
|
||||
const result = await window.app.graphToPrompt();
|
||||
return result?.workflow || result?.output || result?.prompt || result;
|
||||
}
|
||||
} catch (err) {}
|
||||
try {
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
if (graph?.serialize) return graph.serialize();
|
||||
} catch (err) {}
|
||||
return null;
|
||||
};
|
||||
const showWorkflowValidationReport = (report) => {
|
||||
const lines = [];
|
||||
const summary = report?.summary || {};
|
||||
lines.push(`节点 ${summary.nodes || 0},连线 ${summary.links || 0},错误 ${summary.errors || 0},警告 ${summary.warnings || 0}`);
|
||||
for (const item of report?.missingNodeTypes || []) {
|
||||
lines.push(`缺失节点: ${item.nodeType} #${item.nodeId}${item.connected ? '(已连接)' : '(未连接)'}`);
|
||||
}
|
||||
for (const item of report?.emptyModelFields || []) {
|
||||
lines.push(`空模型列表: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
}
|
||||
for (const item of report?.missingModelChoices || []) {
|
||||
lines.push(`缺失模型: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
if (item.near?.length) lines.push(` 相近: ${item.near.join(', ')}`);
|
||||
}
|
||||
for (const item of report?.inputFiles || []) {
|
||||
if (item.checked) lines.push(`素材: ${item.filename} ${item.reachable ? '可访问' : '不可访问'}`);
|
||||
}
|
||||
if (report?.hints?.length) lines.push('', ...report.hints);
|
||||
window.alert(lines.join('\n') || '没有发现明显问题');
|
||||
};
|
||||
const runWorkflowValidation = async () => {
|
||||
const workflow = await getCurrentWorkflowJson();
|
||||
if (!workflow) {
|
||||
window.alert('没有读取到当前工作流');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/workflow-ui/api/workflow/validate?check_files=true', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(workflow),
|
||||
});
|
||||
const report = await response.json();
|
||||
showWorkflowValidationReport(report);
|
||||
};
|
||||
const installWorkflowValidationButton = () => {
|
||||
if (document.getElementById('boosapi-workflow-validate-button')) return;
|
||||
const host = document.querySelector('header, nav, .p-menubar, .comfy-menu, .comfyui-menu') || document.body;
|
||||
const button = document.createElement('button');
|
||||
button.id = 'boosapi-workflow-validate-button';
|
||||
button.type = 'button';
|
||||
button.textContent = '检查模板';
|
||||
button.title = '检查当前工作流的节点、模型和输入素材兼容性';
|
||||
button.style.cssText = 'position:fixed;right:112px;top:10px;z-index:10000;height:32px;padding:0 12px;border:1px solid rgba(120,130,150,.45);border-radius:6px;background:rgba(20,24,32,.88);color:#fff;font-size:13px;cursor:pointer;';
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const oldText = button.textContent;
|
||||
button.textContent = '检查中...';
|
||||
try {
|
||||
await runWorkflowValidation();
|
||||
} catch (err) {
|
||||
window.alert('检查失败:' + (err?.message || err));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = oldText;
|
||||
}
|
||||
});
|
||||
host.appendChild(button);
|
||||
};
|
||||
try {
|
||||
const userJwt = localStorage.getItem('user_jwt_token');
|
||||
const adminToken = localStorage.getItem('auth_token');
|
||||
const token = adminToken || userJwt;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
document.cookie = 'boosapi_workflow_token=' + encodeURIComponent(token) + '; Path=/workflow-ui; SameSite=Lax';
|
||||
}
|
||||
} catch (err) {}
|
||||
let shellPatchQueued = false;
|
||||
const patchWorkflowShell = () => {
|
||||
shellPatchQueued = false;
|
||||
installBoosApiFetchAuth();
|
||||
patchWorkflowConnectionTypes();
|
||||
startWorkflowSlotNormalizer();
|
||||
installWorkflowConnectionMenuMemory();
|
||||
installWorkflowConnectionMenuObserver();
|
||||
boostWorkflowSlotDefaults();
|
||||
enhanceWorkflowConnectionMenu();
|
||||
patchModelLibraryClicks();
|
||||
installWorkflowValidationButton();
|
||||
localizeWorkflowMenus();
|
||||
observeWorkflowMenuLocalization();
|
||||
renameComfyUiBrand();
|
||||
};
|
||||
const queueWorkflowShellPatch = () => {
|
||||
if (shellPatchQueued) return;
|
||||
shellPatchQueued = true;
|
||||
window.setTimeout(patchWorkflowShell, 120);
|
||||
};
|
||||
document.addEventListener('click', queueWorkflowShellPatch, true);
|
||||
window.setTimeout(patchWorkflowShell, 250);
|
||||
window.setTimeout(patchWorkflowShell, 1200);
|
||||
})();
|
||||
</script>"#;
|
||||
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
|
||||
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
||||
}
|
||||
@@ -162,6 +162,8 @@ mod ws;
|
||||
use ws::{
|
||||
publish_run_events, workflow_progress_callback_with_run_sender, workflow_ws,
|
||||
};
|
||||
mod bootstrap;
|
||||
use bootstrap::inject_bootstrap;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -933,806 +935,6 @@ pub(super) async fn workflow_context(state: &AppState, caller: &WorkflowCaller)
|
||||
})
|
||||
}
|
||||
|
||||
fn inject_bootstrap(html: String, config: &crate::config::ComfyUIConfig) -> String {
|
||||
if config.uses_native_backend() && !config.uses_cloud_backend() {
|
||||
return inject_external_bootstrap(html);
|
||||
}
|
||||
inject_enhanced_bootstrap(html)
|
||||
}
|
||||
|
||||
fn workflow_secret_cleanup_bootstrap() -> &'static str {
|
||||
r#"<script>
|
||||
(function () {
|
||||
const clearLeakedComfyApiKey = () => {
|
||||
try {
|
||||
localStorage.removeItem('comfy_api_key');
|
||||
sessionStorage.removeItem('comfy_api_key');
|
||||
document.cookie = 'comfy_api_key=; Path=/workflow-ui; Max-Age=0; SameSite=Lax';
|
||||
} catch (err) {}
|
||||
};
|
||||
clearLeakedComfyApiKey();
|
||||
window.addEventListener('DOMContentLoaded', clearLeakedComfyApiKey);
|
||||
})();
|
||||
</script>"#
|
||||
}
|
||||
|
||||
fn inject_external_bootstrap(html: String) -> String {
|
||||
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
|
||||
let shell = r#"<script>
|
||||
(function () {
|
||||
const renameComfyUiBrand = () => {
|
||||
document.title = document.title.replace(/ComfyUI/g, 'BoosAI');
|
||||
const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 80);
|
||||
for (const el of candidates) {
|
||||
if (el.childNodes.length === 1 && el.firstChild && el.firstChild.nodeType === Node.TEXT_NODE) {
|
||||
const value = String(el.textContent || '').trim();
|
||||
if (value === 'ComfyUI' || value === 'Comfy UI') el.textContent = 'BoosAI';
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
const userJwt = localStorage.getItem('user_jwt_token');
|
||||
const adminToken = localStorage.getItem('auth_token');
|
||||
const token = adminToken || userJwt;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
document.cookie = 'boosapi_workflow_token=' + encodeURIComponent(token) + '; Path=/workflow-ui; SameSite=Lax';
|
||||
}
|
||||
} catch (err) {}
|
||||
const getCurrentWorkflowJson = async () => {
|
||||
try {
|
||||
if (window.app?.graphToPrompt) {
|
||||
const result = await window.app.graphToPrompt();
|
||||
return result?.workflow || result?.output || result?.prompt || result;
|
||||
}
|
||||
} catch (err) {}
|
||||
try {
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
if (graph?.serialize) return graph.serialize();
|
||||
} catch (err) {}
|
||||
return null;
|
||||
};
|
||||
const installWorkflowValidationButton = () => {
|
||||
if (document.getElementById('boosapi-workflow-validate-button')) return;
|
||||
const button = document.createElement('button');
|
||||
button.id = 'boosapi-workflow-validate-button';
|
||||
button.type = 'button';
|
||||
button.textContent = '检查模板';
|
||||
button.title = '检查当前工作流的节点、模型和输入素材兼容性';
|
||||
button.style.cssText = 'position:fixed;right:112px;top:10px;z-index:10000;height:32px;padding:0 12px;border:1px solid rgba(120,130,150,.45);border-radius:6px;background:rgba(20,24,32,.88);color:#fff;font-size:13px;cursor:pointer;';
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const oldText = button.textContent;
|
||||
button.textContent = '检查中...';
|
||||
try {
|
||||
const workflow = await getCurrentWorkflowJson();
|
||||
if (!workflow) throw new Error('没有读取到当前工作流');
|
||||
const response = await fetch('/workflow-ui/api/workflow/validate?check_files=true', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(workflow),
|
||||
});
|
||||
const report = await response.json();
|
||||
const summary = report?.summary || {};
|
||||
const lines = [`节点 ${summary.nodes || 0},连线 ${summary.links || 0},错误 ${summary.errors || 0},警告 ${summary.warnings || 0}`];
|
||||
for (const item of report?.missingNodeTypes || []) lines.push(`缺失节点: ${item.nodeType} #${item.nodeId}${item.connected ? '(已连接)' : '(未连接)'}`);
|
||||
for (const item of report?.emptyModelFields || []) lines.push(`空模型列表: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
for (const item of report?.missingModelChoices || []) lines.push(`缺失模型: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
for (const item of report?.inputFiles || []) if (item.checked) lines.push(`素材: ${item.filename} ${item.reachable ? '可访问' : '不可访问'}`);
|
||||
if (report?.hints?.length) lines.push('', ...report.hints);
|
||||
window.alert(lines.join('\n'));
|
||||
} catch (err) {
|
||||
window.alert('检查失败:' + (err?.message || err));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = oldText;
|
||||
}
|
||||
});
|
||||
document.body.appendChild(button);
|
||||
};
|
||||
window.setTimeout(renameComfyUiBrand, 250);
|
||||
window.setTimeout(renameComfyUiBrand, 1200);
|
||||
window.setTimeout(installWorkflowValidationButton, 800);
|
||||
window.setTimeout(installWorkflowValidationButton, 1800);
|
||||
})();
|
||||
</script>"#;
|
||||
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
|
||||
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
||||
}
|
||||
|
||||
fn inject_enhanced_bootstrap(html: String) -> String {
|
||||
let secret_cleanup_bootstrap = workflow_secret_cleanup_bootstrap();
|
||||
let shell = r#"<style id="boosapi-workflow-shell-style">
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
const renameComfyUiBrand = () => {
|
||||
document.title = document.title.replace(/ComfyUI/g, 'BoosAI');
|
||||
const candidates = Array.from(document.querySelectorAll('header *, nav *, .comfy-menu-header *, .comfyui-menu *, .p-menubar *')).slice(0, 120);
|
||||
for (const el of candidates) {
|
||||
if (el.childNodes.length === 1 && el.firstChild && el.firstChild.nodeType === Node.TEXT_NODE) {
|
||||
const value = String(el.textContent || '').trim();
|
||||
if (value === 'ComfyUI' || value === 'Comfy UI') {
|
||||
el.textContent = 'BoosAI';
|
||||
}
|
||||
}
|
||||
for (const attr of ['aria-label', 'title']) {
|
||||
const value = el.getAttribute(attr);
|
||||
if (value && /Comfy\s*UI|ComfyUI/.test(value)) {
|
||||
el.setAttribute(attr, value.replace(/Comfy\s*UI|ComfyUI/g, 'BoosAI'));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const installBoosApiFetchAuth = () => {
|
||||
try {
|
||||
const api = window.app?.api || window.comfyAPI?.api || window.api;
|
||||
if (!api || api.__boosapiFetchAuthPatch || typeof api.fetchApi !== 'function') return;
|
||||
const originalFetchApi = api.fetchApi.bind(api);
|
||||
api.fetchApi = function(route, options) {
|
||||
options ||= {};
|
||||
const headers = new Headers(options.headers || {});
|
||||
const token = localStorage.getItem('auth_token') || localStorage.getItem('user_jwt_token');
|
||||
if (token && !headers.has('Authorization')) headers.set('Authorization', 'Bearer ' + token);
|
||||
options.headers = headers;
|
||||
options.credentials ||= 'same-origin';
|
||||
return originalFetchApi(route, options);
|
||||
};
|
||||
api.__boosapiFetchAuthPatch = true;
|
||||
} catch (err) {}
|
||||
};
|
||||
const workflowTextMap = new Map(Object.entries({
|
||||
'Add Node': '添加节点',
|
||||
'Search': '搜索',
|
||||
'Reroute': '转接点',
|
||||
'PreviewImage': '预览图像',
|
||||
'SaveImage': '保存图像',
|
||||
'Load Image': '加载图像',
|
||||
'CheckpointLoaderSimple': 'Checkpoint 加载器(简易)',
|
||||
'CLIPTextEncode': 'CLIP 文本编码',
|
||||
'KSampler': 'K 采样器',
|
||||
'VAEDecode': 'VAE 解码',
|
||||
'VAEEncode': 'VAE 编码',
|
||||
'EmptyLatentImage': '空潜空间图像',
|
||||
'Latent': '潜空间',
|
||||
'Input': '输入',
|
||||
'Output': '输出',
|
||||
'Util': '工具',
|
||||
'utils': '工具',
|
||||
'ComfyUI': 'BoosAI',
|
||||
'IMAGE': '图像',
|
||||
'LATENT': '潜空间',
|
||||
'MODEL': '模型',
|
||||
'CLIP': 'CLIP',
|
||||
'VAE': 'VAE',
|
||||
'CONDITIONING': '条件',
|
||||
'BoosAPI': 'BoosAPI',
|
||||
'boosapi.image_to_video': 'BoosAPI 图生视频',
|
||||
'boosapi.text_to_video': 'BoosAPI 文生视频',
|
||||
'boosapi.video_status': '查询视频任务',
|
||||
'output.preview_video': '输出:预览视频',
|
||||
'output.preview_image': '输出:预览图像',
|
||||
'input.text': '输入:文本',
|
||||
'boosapi.chat_completion': 'BoosAPI 对话',
|
||||
'boosapi.prompt_rewrite': 'BoosAPI 提示词改写',
|
||||
'boosapi.text_to_image': 'BoosAPI 文生图',
|
||||
'output.save_file': '输出:保存文件'
|
||||
}));
|
||||
const translateWorkflowText = (text) => {
|
||||
const raw = String(text || '');
|
||||
const trimmed = raw.replace(/\s+/g, ' ').trim();
|
||||
if (!trimmed) return null;
|
||||
if (workflowTextMap.has(trimmed)) return workflowTextMap.get(trimmed);
|
||||
return null;
|
||||
};
|
||||
const localizeWorkflowMenus = () => {
|
||||
const selector = [
|
||||
'.litecontextmenu .litemenu-entry',
|
||||
'.litecontextmenu .litemenu-title',
|
||||
'.litecontextmenu .litemenu-submenu',
|
||||
'.litecontextmenu li',
|
||||
'.litecontextmenu div',
|
||||
'.p-contextmenu [role="menuitem"]',
|
||||
'.p-contextmenu .p-menuitem-text',
|
||||
'.p-tieredmenu [role="menuitem"]',
|
||||
'.p-tieredmenu .p-menuitem-text',
|
||||
'[role="menu"] [role="menuitem"]'
|
||||
].join(',');
|
||||
for (const el of document.querySelectorAll(selector)) {
|
||||
if (!el || el.children.length > 1) continue;
|
||||
const textNode = Array.from(el.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && String(node.textContent || '').trim());
|
||||
const target = textNode || (el.children.length === 0 ? el : null);
|
||||
if (!target) continue;
|
||||
const translated = translateWorkflowText(target.textContent);
|
||||
if (translated && target.textContent !== translated) target.textContent = translated;
|
||||
}
|
||||
for (const el of document.querySelectorAll('[title],[aria-label]')) {
|
||||
for (const attr of ['title', 'aria-label']) {
|
||||
const translated = translateWorkflowText(el.getAttribute(attr));
|
||||
if (translated) el.setAttribute(attr, translated);
|
||||
}
|
||||
}
|
||||
};
|
||||
const observeWorkflowMenuLocalization = () => {
|
||||
if (window.__boosapiWorkflowMenuI18nObserver) return;
|
||||
window.__boosapiWorkflowMenuI18nObserver = true;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((item) => item.addedNodes && item.addedNodes.length)) {
|
||||
window.requestAnimationFrame(localizeWorkflowMenus);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
};
|
||||
const normalizeWorkflowPortType = (value) => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'conditioning' || normalized === 'condition' || normalized === 'cond' || normalized === '条件' || normalized === '条件化' || normalized === '正面条件' || normalized === '负面条件') return 'conditioning';
|
||||
if (normalized === 'latent' || normalized === '潜空间') return 'latent';
|
||||
if (normalized === 'model' || normalized === '模型') return 'model';
|
||||
if (normalized === 'clip') return 'clip';
|
||||
if (normalized === 'vae') return 'vae';
|
||||
if (normalized === 'image' || normalized === '图像' || normalized === '图片') return 'image';
|
||||
if (normalized === 'video' || normalized === '视频') return 'video';
|
||||
return normalized;
|
||||
};
|
||||
const normalizeWorkflowSlotType = (slot) => {
|
||||
if (!slot) return;
|
||||
const normalized = normalizeWorkflowPortType(slot.type || slot.name || slot.localized_name);
|
||||
const canonical = {
|
||||
conditioning: 'CONDITIONING',
|
||||
latent: 'LATENT',
|
||||
model: 'MODEL',
|
||||
clip: 'CLIP',
|
||||
vae: 'VAE',
|
||||
image: 'IMAGE',
|
||||
video: 'VIDEO'
|
||||
}[normalized];
|
||||
if (canonical) slot.type = canonical;
|
||||
};
|
||||
const slotName = (slot) => String(slot?.name || slot?.localized_name || slot?.label || '').trim().toLowerCase();
|
||||
const normalizeWorkflowNodeSlots = (node) => {
|
||||
try {
|
||||
if (!node) return;
|
||||
if (node.type === 'CLIPTextEncode' && Array.isArray(node.outputs)) {
|
||||
for (const output of node.outputs) {
|
||||
const name = slotName(output);
|
||||
if (name.includes('conditioning') || name.includes('condition') || name.includes('条件') || name === 'conditioning') {
|
||||
output.type = 'CONDITIONING';
|
||||
output.name = output.name || 'CONDITIONING';
|
||||
output.localized_name = output.localized_name || '条件';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.type === 'KSampler' && Array.isArray(node.inputs)) {
|
||||
for (const input of node.inputs) {
|
||||
const name = slotName(input);
|
||||
if (name === 'positive' || name === 'negative' || name.includes('positive') || name.includes('negative') || name.includes('正面条件') || name.includes('负面条件')) {
|
||||
input.type = 'CONDITIONING';
|
||||
}
|
||||
}
|
||||
}
|
||||
node.inputs?.forEach(normalizeWorkflowSlotType);
|
||||
node.outputs?.forEach(normalizeWorkflowSlotType);
|
||||
} catch (err) {}
|
||||
};
|
||||
const normalizeWorkflowGraphSlots = () => {
|
||||
try {
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
if (!graph?.nodes) return;
|
||||
for (const node of graph.nodes) normalizeWorkflowNodeSlots(node);
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
window.app?.canvas?.setDirty?.(true, true);
|
||||
} catch (err) {}
|
||||
};
|
||||
const patchWorkflowConnectionTypes = () => {
|
||||
try {
|
||||
const liteGraph = window.LiteGraph;
|
||||
if (!liteGraph || liteGraph.__boosapiConnectionPatch) return;
|
||||
const original = liteGraph.isValidConnection;
|
||||
if (typeof original !== 'function') return;
|
||||
liteGraph.isValidConnection = function (typeA, typeB) {
|
||||
const a = normalizeWorkflowPortType(typeA);
|
||||
const b = normalizeWorkflowPortType(typeB);
|
||||
if (a && b && a === b) return true;
|
||||
return original.call(this, typeA, typeB);
|
||||
};
|
||||
const proto = window.LGraphNode?.prototype;
|
||||
if (proto?.connect && !proto.__boosapiConnectPatch) {
|
||||
const originalConnect = proto.connect;
|
||||
proto.connect = function (slot, targetNode, targetSlot, afterRerouteId) {
|
||||
normalizeWorkflowNodeSlots(this);
|
||||
normalizeWorkflowNodeSlots(targetNode);
|
||||
return originalConnect.call(this, slot, targetNode, targetSlot, afterRerouteId);
|
||||
};
|
||||
proto.__boosapiConnectPatch = true;
|
||||
}
|
||||
const originalCreateNode = liteGraph.createNode;
|
||||
if (typeof originalCreateNode === 'function' && !liteGraph.__boosapiCreateNodePatch) {
|
||||
liteGraph.createNode = function () {
|
||||
const node = originalCreateNode.apply(this, arguments);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
return node;
|
||||
};
|
||||
liteGraph.__boosapiCreateNodePatch = true;
|
||||
}
|
||||
liteGraph.__boosapiConnectionPatch = true;
|
||||
normalizeWorkflowGraphSlots();
|
||||
} catch (err) {}
|
||||
};
|
||||
const startWorkflowSlotNormalizer = () => {
|
||||
if (window.__boosapiWorkflowSlotNormalizer) return;
|
||||
window.__boosapiWorkflowSlotNormalizer = true;
|
||||
window.setInterval(normalizeWorkflowGraphSlots, 1000);
|
||||
window.setTimeout(normalizeWorkflowGraphSlots, 100);
|
||||
window.setTimeout(normalizeWorkflowGraphSlots, 600);
|
||||
window.setTimeout(normalizeWorkflowGraphSlots, 1500);
|
||||
};
|
||||
const workflowConnectionQuickNodes = {
|
||||
model: [
|
||||
{ title: 'K 采样器', types: ['KSampler'], hint: '把模型接入采样流程' },
|
||||
{ title: 'LoRA 加载器', types: ['LoraLoader', 'LoraLoaderModelOnly'], hint: '给模型叠加 LoRA' },
|
||||
{ title: '模型采样设置', types: ['ModelSamplingDiscrete', 'ModelSamplingContinuousEDM'], hint: '调整模型采样方式' },
|
||||
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||
],
|
||||
clip: [
|
||||
{ title: 'CLIP 文本编码', types: ['CLIPTextEncode'], hint: '输入提示词生成条件' },
|
||||
{ title: 'CLIP 跳层', types: ['CLIPSetLastLayer'], hint: '设置 CLIP skip' },
|
||||
{ title: 'LoRA 加载器', types: ['LoraLoader'], hint: '同时接入模型与 CLIP' },
|
||||
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||
],
|
||||
vae: [
|
||||
{ title: 'VAE 解码', types: ['VAEDecode', 'VAEDecodeTiled'], hint: '潜空间转图像' },
|
||||
{ title: 'VAE 编码', types: ['VAEEncode', 'VAEEncodeTiled'], hint: '图像转潜空间' },
|
||||
{ title: '保存 Checkpoint', types: ['CheckpointSave'], hint: '保存模型/CLIP/VAE 组合' }
|
||||
],
|
||||
image: [
|
||||
{ title: '预览图像', types: ['PreviewImage'], hint: '查看这一条图像输出' },
|
||||
{ title: '保存图像', types: ['SaveImage'], hint: '保存到输出目录' },
|
||||
{ title: 'VAE 编码', types: ['VAEEncode', 'VAEEncodeTiled'], hint: '图像转潜空间' },
|
||||
{ title: '放大图像', types: ['ImageScale', 'ImageScaleBy', 'ImageUpscaleWithModel'], hint: '调整尺寸/放大' },
|
||||
{ title: '图像对比', types: ['Image Comparer (rgthree)', 'Image Comparer', 'ImageCompare'], hint: '对比输入图像' },
|
||||
{ title: '图像转遮罩', types: ['ImageToMask'], hint: '从图像提取遮罩' }
|
||||
],
|
||||
latent: [
|
||||
{ title: 'VAE 解码', types: ['VAEDecode', 'VAEDecodeTiled'], hint: '潜空间转图像' },
|
||||
{ title: 'K 采样器', types: ['KSampler'], hint: '继续采样' },
|
||||
{ title: '潜空间放大', types: ['LatentUpscale', 'LatentUpscaleBy'], hint: '调整潜空间尺寸' },
|
||||
{ title: '潜空间合成', types: ['LatentComposite'], hint: '合成两个潜空间' }
|
||||
],
|
||||
conditioning: [
|
||||
{ title: 'K 采样器', types: ['KSampler'], hint: '接入正/负条件' },
|
||||
{ title: '合并条件', types: ['ConditioningCombine', 'ConditioningConcat'], hint: '组合提示条件' },
|
||||
{ title: '条件平均', types: ['ConditioningAverage'], hint: '混合两组条件' },
|
||||
{ title: '条件区域', types: ['ConditioningSetArea'], hint: '限制条件作用区域' }
|
||||
],
|
||||
mask: [
|
||||
{ title: '遮罩转图像', types: ['MaskToImage'], hint: '查看/保存遮罩' },
|
||||
{ title: '图像转遮罩', types: ['ImageToMask'], hint: '从图像提取遮罩' },
|
||||
{ title: '遮罩合成', types: ['MaskComposite'], hint: '组合遮罩' },
|
||||
{ title: '遮罩扩张', types: ['GrowMask'], hint: '扩大或收缩遮罩' }
|
||||
],
|
||||
video: [
|
||||
{ title: '预览视频', types: ['VHS_VideoCombine', 'Video Combine 🎥🅥🅗🅢', 'VideoCombine'], hint: '合成为可预览视频' },
|
||||
{ title: '保存动画', types: ['SaveAnimatedPNG', 'SaveAnimatedWEBP'], hint: '保存序列动画' },
|
||||
{ title: '加载视频', types: ['VHS_LoadVideo', 'LoadVideo'], hint: '读取视频素材' }
|
||||
],
|
||||
audio: [
|
||||
{ title: '预览音频', types: ['PreviewAudio', 'SaveAudio'], hint: '试听或保存音频' },
|
||||
{ title: '加载音频', types: ['LoadAudio'], hint: '读取音频素材' }
|
||||
],
|
||||
string: [
|
||||
{ title: '文本输入', types: ['PrimitiveNode', 'StringConstant', 'Text Input'], hint: '创建文本值' },
|
||||
{ title: '显示文本', types: ['ShowText|pysssss', 'ShowText', 'PreviewText'], hint: '查看文本输出' }
|
||||
],
|
||||
int: [
|
||||
{ title: '数值输入', types: ['PrimitiveNode', 'Integer Constant'], hint: '创建整数值' }
|
||||
],
|
||||
float: [
|
||||
{ title: '数值输入', types: ['PrimitiveNode', 'Float Constant'], hint: '创建小数值' }
|
||||
],
|
||||
boolean: [
|
||||
{ title: '开关输入', types: ['PrimitiveNode', 'Boolean Constant'], hint: '创建布尔值' }
|
||||
]
|
||||
};
|
||||
const findWorkflowNodeType = (candidates) => {
|
||||
const registry = window.LiteGraph?.registered_node_types || {};
|
||||
for (const name of candidates) {
|
||||
if (registry[name]) return name;
|
||||
}
|
||||
const entries = Object.keys(registry);
|
||||
for (const name of candidates) {
|
||||
const normalized = String(name).toLowerCase().replace(/[\s_-]+/g, '');
|
||||
const found = entries.find((item) => String(item).toLowerCase().replace(/[\s_-]+/g, '') === normalized);
|
||||
if (found) return found;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const getWorkflowConnectionType = (options) => {
|
||||
try {
|
||||
const link = options || window.__boosapiLastConnectionMenuOptions || {};
|
||||
if (link.nodeFrom && Number.isInteger(link.slotFrom)) {
|
||||
return normalizeWorkflowPortType(link.nodeFrom.outputs?.[link.slotFrom]?.type || link.nodeFrom.outputs?.[link.slotFrom]?.name);
|
||||
}
|
||||
if (link.nodeTo && Number.isInteger(link.slotTo)) {
|
||||
return normalizeWorkflowPortType(link.nodeTo.inputs?.[link.slotTo]?.type || link.nodeTo.inputs?.[link.slotTo]?.name);
|
||||
}
|
||||
const title = String(document.querySelector('.litecontextmenu .litemenu-title')?.textContent || '');
|
||||
if (title.includes('IMAGE') || title.includes('图像')) return 'image';
|
||||
if (title.includes('LATENT') || title.includes('潜空间')) return 'latent';
|
||||
if (title.includes('CONDITIONING') || title.includes('条件')) return 'conditioning';
|
||||
if (title.includes('MODEL') || title.includes('模型')) return 'model';
|
||||
if (title.includes('CLIP')) return 'clip';
|
||||
if (title.includes('VAE')) return 'vae';
|
||||
if (title.includes('MASK') || title.includes('遮罩')) return 'mask';
|
||||
if (title.includes('VIDEO') || title.includes('视频')) return 'video';
|
||||
if (title.includes('AUDIO') || title.includes('音频')) return 'audio';
|
||||
if (title.includes('STRING') || title.includes('TEXT') || title.includes('文本')) return 'string';
|
||||
if (title.includes('INT') || title.includes('整数')) return 'int';
|
||||
if (title.includes('FLOAT') || title.includes('NUMBER') || title.includes('数字')) return 'float';
|
||||
if (title.includes('BOOLEAN') || title.includes('BOOL') || title.includes('布尔')) return 'boolean';
|
||||
} catch (err) {}
|
||||
return '';
|
||||
};
|
||||
const workflowNodeDisplayTitle = (nodeType) => {
|
||||
const registry = window.LiteGraph?.registered_node_types || {};
|
||||
const ctor = registry[nodeType];
|
||||
return String(ctor?.title || ctor?.nodeData?.display_name || ctor?.comfyClass || nodeType || '').trim();
|
||||
};
|
||||
const fallbackWorkflowConnectionItems = (connectionType) => {
|
||||
try {
|
||||
const options = window.__boosapiLastConnectionMenuOptions || {};
|
||||
const liteGraph = window.LiteGraph || {};
|
||||
const canonical = {
|
||||
image: 'IMAGE',
|
||||
latent: 'LATENT',
|
||||
conditioning: 'CONDITIONING',
|
||||
model: 'MODEL',
|
||||
clip: 'CLIP',
|
||||
vae: 'VAE',
|
||||
mask: 'MASK',
|
||||
video: 'VIDEO',
|
||||
audio: 'AUDIO',
|
||||
string: 'STRING',
|
||||
int: 'INT',
|
||||
float: 'FLOAT',
|
||||
boolean: 'BOOLEAN'
|
||||
}[connectionType] || String(connectionType || '').toUpperCase();
|
||||
const defaults = options.nodeFrom
|
||||
? liteGraph.slot_types_default_out?.[canonical]
|
||||
: liteGraph.slot_types_default_in?.[canonical];
|
||||
const registered = options.nodeFrom
|
||||
? liteGraph.registered_slot_in_types?.[String(canonical).toLowerCase()]?.nodes
|
||||
: liteGraph.registered_slot_out_types?.[canonical]?.nodes;
|
||||
const names = [...(Array.isArray(defaults) ? defaults : []), ...(Array.isArray(registered) ? registered : [])]
|
||||
.map((value) => typeof value === 'string' ? value : value?.comfyClass || value?.type || value?.title)
|
||||
.filter(Boolean);
|
||||
return [...new Set(names)].slice(0, 8).map((nodeType) => ({
|
||||
title: workflowNodeDisplayTitle(nodeType),
|
||||
types: [nodeType],
|
||||
hint: `${canonical} 兼容节点`
|
||||
}));
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const findCompatibleSlotIndex = (slots, type) => {
|
||||
const wanted = normalizeWorkflowPortType(type);
|
||||
if (!Array.isArray(slots)) return 0;
|
||||
const exact = slots.findIndex((slot) => normalizeWorkflowPortType(slot?.type || slot?.name || slot?.localized_name) === wanted);
|
||||
return exact >= 0 ? exact : 0;
|
||||
};
|
||||
const connectedNodePosition = (options) => {
|
||||
const event = options?.e || {};
|
||||
if (typeof event.canvasX === 'number' && typeof event.canvasY === 'number') {
|
||||
return [event.canvasX + 24, event.canvasY - 28];
|
||||
}
|
||||
try {
|
||||
const canvas = window.app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
const area = canvas?.ds?.visible_area;
|
||||
if (Array.isArray(area)) return [area[0] + area[2] / 2 - 120, area[1] + area[3] / 2 - 80];
|
||||
} catch (err) {}
|
||||
return [180, 180];
|
||||
};
|
||||
const addConnectedWorkflowQuickNode = (item) => {
|
||||
try {
|
||||
const options = window.__boosapiLastConnectionMenuOptions || {};
|
||||
const nodeType = findWorkflowNodeType(item.types || []);
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = window.app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
if (!nodeType || !graph || !window.LiteGraph?.createNode) return false;
|
||||
const node = window.LiteGraph.createNode(nodeType, item.title || nodeType);
|
||||
if (!node) return false;
|
||||
node.pos = connectedNodePosition(options);
|
||||
graph.add(node);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
if (options.nodeFrom && Number.isInteger(options.slotFrom)) {
|
||||
const output = options.nodeFrom.outputs?.[options.slotFrom];
|
||||
const inputIndex = findCompatibleSlotIndex(node.inputs, output?.type || output?.name);
|
||||
options.nodeFrom.connect(options.slotFrom, node, inputIndex, options.afterRerouteId);
|
||||
} else if (options.nodeTo && Number.isInteger(options.slotTo)) {
|
||||
const input = options.nodeTo.inputs?.[options.slotTo];
|
||||
const outputIndex = findCompatibleSlotIndex(node.outputs, input?.type || input?.name);
|
||||
node.connect(outputIndex, options.nodeTo, options.slotTo, options.afterRerouteId);
|
||||
}
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas?.selectNode?.(node);
|
||||
if (window.LiteGraph?.closeAllContextMenus) window.LiteGraph.closeAllContextMenus(window);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const installWorkflowConnectionMenuMemory = () => {
|
||||
try {
|
||||
const proto = window.LGraphCanvas?.prototype;
|
||||
if (!proto?.showConnectionMenu || proto.__boosapiConnectionMenuMemory) return;
|
||||
const original = proto.showConnectionMenu;
|
||||
proto.showConnectionMenu = function (options) {
|
||||
window.__boosapiLastConnectionMenuOptions = options || {};
|
||||
const result = original.apply(this, arguments);
|
||||
window.setTimeout(enhanceWorkflowConnectionMenu, 0);
|
||||
window.setTimeout(enhanceWorkflowConnectionMenu, 80);
|
||||
return result;
|
||||
};
|
||||
proto.__boosapiConnectionMenuMemory = true;
|
||||
} catch (err) {}
|
||||
};
|
||||
const enhanceWorkflowConnectionMenu = () => {
|
||||
try {
|
||||
const menu = document.querySelector('.litecontextmenu');
|
||||
if (!menu || menu.__boosapiConnectionSuggestions) return;
|
||||
const connectionType = getWorkflowConnectionType();
|
||||
const items = [
|
||||
...(workflowConnectionQuickNodes[connectionType] || []),
|
||||
...fallbackWorkflowConnectionItems(connectionType)
|
||||
];
|
||||
if (!items?.length) return;
|
||||
const available = items
|
||||
.map((item) => ({ ...item, nodeType: findWorkflowNodeType(item.types || []) }))
|
||||
.filter((item) => item.nodeType);
|
||||
if (!available.length) return;
|
||||
menu.__boosapiConnectionSuggestions = true;
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'boosapi-connection-suggestions';
|
||||
panel.style.cssText = 'padding:8px 8px 6px;border-bottom:1px solid rgba(255,255,255,.12);background:rgba(20,24,30,.96);min-width:260px;';
|
||||
const title = document.createElement('div');
|
||||
title.textContent = '最相关';
|
||||
title.style.cssText = 'font-size:12px;font-weight:700;color:#e9f7f5;margin:0 0 6px 2px;';
|
||||
panel.appendChild(title);
|
||||
for (const item of available.slice(0, 6)) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = item.title;
|
||||
button.title = item.hint || item.nodeType;
|
||||
button.style.cssText = 'display:flex;width:100%;align-items:center;justify-content:space-between;gap:12px;margin:2px 0;padding:8px 10px;border:0;border-radius:6px;background:transparent;color:#fff;text-align:left;font-size:13px;cursor:pointer;';
|
||||
button.addEventListener('mouseenter', () => { button.style.background = 'rgba(20,184,166,.22)'; });
|
||||
button.addEventListener('mouseleave', () => { button.style.background = 'transparent'; });
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
addConnectedWorkflowQuickNode(item);
|
||||
}, true);
|
||||
panel.appendChild(button);
|
||||
}
|
||||
menu.insertBefore(panel, menu.firstChild);
|
||||
} catch (err) {}
|
||||
};
|
||||
const installWorkflowConnectionMenuObserver = () => {
|
||||
if (window.__boosapiWorkflowConnectionMenuObserver) return;
|
||||
window.__boosapiWorkflowConnectionMenuObserver = true;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((item) => item.addedNodes && item.addedNodes.length)) {
|
||||
window.requestAnimationFrame(enhanceWorkflowConnectionMenu);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
};
|
||||
const boostWorkflowSlotDefaults = () => {
|
||||
try {
|
||||
const liteGraph = window.LiteGraph;
|
||||
if (!liteGraph) return;
|
||||
const prepend = (target, type, values) => {
|
||||
const current = Array.isArray(target[type]) ? target[type] : [];
|
||||
target[type] = [...values.filter((value) => findWorkflowNodeType([value])), ...current.filter((value) => !values.includes(value))].slice(0, 24);
|
||||
};
|
||||
liteGraph.slot_types_default_out ||= {};
|
||||
liteGraph.slot_types_default_in ||= {};
|
||||
prepend(liteGraph.slot_types_default_out, 'MODEL', ['KSampler', 'LoraLoader', 'ModelSamplingDiscrete']);
|
||||
prepend(liteGraph.slot_types_default_out, 'CLIP', ['CLIPTextEncode', 'CLIPSetLastLayer', 'LoraLoader']);
|
||||
prepend(liteGraph.slot_types_default_out, 'VAE', ['VAEDecode', 'VAEEncode']);
|
||||
prepend(liteGraph.slot_types_default_out, 'IMAGE', ['PreviewImage', 'SaveImage', 'VAEEncode', 'ImageScale', 'ImageScaleBy']);
|
||||
prepend(liteGraph.slot_types_default_in, 'IMAGE', ['LoadImage', 'EmptyImage', 'PreviewImage']);
|
||||
prepend(liteGraph.slot_types_default_out, 'LATENT', ['VAEDecode', 'KSampler']);
|
||||
prepend(liteGraph.slot_types_default_out, 'CONDITIONING', ['KSampler', 'ConditioningCombine']);
|
||||
prepend(liteGraph.slot_types_default_out, 'MASK', ['MaskToImage', 'ImageToMask', 'MaskComposite', 'GrowMask']);
|
||||
prepend(liteGraph.slot_types_default_out, 'VIDEO', ['VHS_VideoCombine', 'VideoCombine', 'SaveAnimatedPNG']);
|
||||
prepend(liteGraph.slot_types_default_out, 'AUDIO', ['PreviewAudio', 'SaveAudio', 'LoadAudio']);
|
||||
prepend(liteGraph.slot_types_default_out, 'STRING', ['PrimitiveNode', 'ShowText', 'PreviewText']);
|
||||
prepend(liteGraph.slot_types_default_out, 'INT', ['PrimitiveNode']);
|
||||
prepend(liteGraph.slot_types_default_out, 'FLOAT', ['PrimitiveNode']);
|
||||
prepend(liteGraph.slot_types_default_out, 'BOOLEAN', ['PrimitiveNode']);
|
||||
} catch (err) {}
|
||||
};
|
||||
const isWorkflowVideoModelName = (modelName) => {
|
||||
const value = String(modelName || '').toLowerCase();
|
||||
return ['seedance', 'wan2', 't2v', 'i2v', 'flf2v', 'video', 'seaweed'].some((keyword) => value.includes(keyword));
|
||||
};
|
||||
const addTextToImageNodeFromModel = (modelName) => {
|
||||
try {
|
||||
const name = String(modelName || '').trim();
|
||||
if (!name || !window.LiteGraph) return false;
|
||||
const app = window.app;
|
||||
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
if (!graph) return false;
|
||||
const node = window.LiteGraph.createNode('boosapi.text_to_image', 'BoosAPI 文生图');
|
||||
if (!node) return false;
|
||||
const area = canvas?.ds?.visible_area;
|
||||
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
|
||||
graph.add(node);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
const widget = node.widgets?.find((item) => item.name === 'model');
|
||||
if (widget) widget.value = name;
|
||||
node.properties ||= {};
|
||||
node.properties['Node name for S&R'] = 'boosapi.text_to_image';
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas?.selectNode?.(node);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const addTextToVideoNodeFromModel = (modelName) => {
|
||||
try {
|
||||
const name = String(modelName || '').trim();
|
||||
if (!name || !window.LiteGraph) return false;
|
||||
const app = window.app;
|
||||
const graph = app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
const canvas = app?.canvas || window.LGraphCanvas?.active_canvas;
|
||||
if (!graph) return false;
|
||||
const node = window.LiteGraph.createNode('boosapi.text_to_video', 'BoosAPI 文生视频');
|
||||
if (!node) return false;
|
||||
const area = canvas?.ds?.visible_area;
|
||||
node.pos = Array.isArray(area) ? [area[0] + area[2] / 2 - 190, area[1] + area[3] / 2 - 120] : [160, 160];
|
||||
graph.add(node);
|
||||
normalizeWorkflowNodeSlots(node);
|
||||
const widget = node.widgets?.find((item) => item.name === 'model');
|
||||
if (widget) widget.value = name;
|
||||
node.properties ||= {};
|
||||
node.properties['Node name for S&R'] = 'boosapi.text_to_video';
|
||||
graph.setDirtyCanvas?.(true, true);
|
||||
canvas?.selectNode?.(node);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const patchModelLibraryClicks = () => {
|
||||
document.querySelectorAll('#model-library .p-tree-node-content, #model-library [role="treeitem"], .model-lib-tree-explorer .p-tree-node-content').forEach((el) => {
|
||||
if (el.__boosapiModelClickPatch) return;
|
||||
el.__boosapiModelClickPatch = true;
|
||||
el.addEventListener('dblclick', (event) => {
|
||||
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || text.includes('checkpoints')) return;
|
||||
const added = isWorkflowVideoModelName(text)
|
||||
? addTextToVideoNodeFromModel(text)
|
||||
: addTextToImageNodeFromModel(text);
|
||||
if (added) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
};
|
||||
const getCurrentWorkflowJson = async () => {
|
||||
try {
|
||||
if (window.app?.graphToPrompt) {
|
||||
const result = await window.app.graphToPrompt();
|
||||
return result?.workflow || result?.output || result?.prompt || result;
|
||||
}
|
||||
} catch (err) {}
|
||||
try {
|
||||
const graph = window.app?.graph || window.LGraphCanvas?.active_canvas?.graph;
|
||||
if (graph?.serialize) return graph.serialize();
|
||||
} catch (err) {}
|
||||
return null;
|
||||
};
|
||||
const showWorkflowValidationReport = (report) => {
|
||||
const lines = [];
|
||||
const summary = report?.summary || {};
|
||||
lines.push(`节点 ${summary.nodes || 0},连线 ${summary.links || 0},错误 ${summary.errors || 0},警告 ${summary.warnings || 0}`);
|
||||
for (const item of report?.missingNodeTypes || []) {
|
||||
lines.push(`缺失节点: ${item.nodeType} #${item.nodeId}${item.connected ? '(已连接)' : '(未连接)'}`);
|
||||
}
|
||||
for (const item of report?.emptyModelFields || []) {
|
||||
lines.push(`空模型列表: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
}
|
||||
for (const item of report?.missingModelChoices || []) {
|
||||
lines.push(`缺失模型: ${item.nodeType} #${item.nodeId} ${item.field} = ${item.selected}`);
|
||||
if (item.near?.length) lines.push(` 相近: ${item.near.join(', ')}`);
|
||||
}
|
||||
for (const item of report?.inputFiles || []) {
|
||||
if (item.checked) lines.push(`素材: ${item.filename} ${item.reachable ? '可访问' : '不可访问'}`);
|
||||
}
|
||||
if (report?.hints?.length) lines.push('', ...report.hints);
|
||||
window.alert(lines.join('\n') || '没有发现明显问题');
|
||||
};
|
||||
const runWorkflowValidation = async () => {
|
||||
const workflow = await getCurrentWorkflowJson();
|
||||
if (!workflow) {
|
||||
window.alert('没有读取到当前工作流');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/workflow-ui/api/workflow/validate?check_files=true', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(workflow),
|
||||
});
|
||||
const report = await response.json();
|
||||
showWorkflowValidationReport(report);
|
||||
};
|
||||
const installWorkflowValidationButton = () => {
|
||||
if (document.getElementById('boosapi-workflow-validate-button')) return;
|
||||
const host = document.querySelector('header, nav, .p-menubar, .comfy-menu, .comfyui-menu') || document.body;
|
||||
const button = document.createElement('button');
|
||||
button.id = 'boosapi-workflow-validate-button';
|
||||
button.type = 'button';
|
||||
button.textContent = '检查模板';
|
||||
button.title = '检查当前工作流的节点、模型和输入素材兼容性';
|
||||
button.style.cssText = 'position:fixed;right:112px;top:10px;z-index:10000;height:32px;padding:0 12px;border:1px solid rgba(120,130,150,.45);border-radius:6px;background:rgba(20,24,32,.88);color:#fff;font-size:13px;cursor:pointer;';
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const oldText = button.textContent;
|
||||
button.textContent = '检查中...';
|
||||
try {
|
||||
await runWorkflowValidation();
|
||||
} catch (err) {
|
||||
window.alert('检查失败:' + (err?.message || err));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = oldText;
|
||||
}
|
||||
});
|
||||
host.appendChild(button);
|
||||
};
|
||||
try {
|
||||
const userJwt = localStorage.getItem('user_jwt_token');
|
||||
const adminToken = localStorage.getItem('auth_token');
|
||||
const token = adminToken || userJwt;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
document.cookie = 'boosapi_workflow_token=' + encodeURIComponent(token) + '; Path=/workflow-ui; SameSite=Lax';
|
||||
}
|
||||
} catch (err) {}
|
||||
let shellPatchQueued = false;
|
||||
const patchWorkflowShell = () => {
|
||||
shellPatchQueued = false;
|
||||
installBoosApiFetchAuth();
|
||||
patchWorkflowConnectionTypes();
|
||||
startWorkflowSlotNormalizer();
|
||||
installWorkflowConnectionMenuMemory();
|
||||
installWorkflowConnectionMenuObserver();
|
||||
boostWorkflowSlotDefaults();
|
||||
enhanceWorkflowConnectionMenu();
|
||||
patchModelLibraryClicks();
|
||||
installWorkflowValidationButton();
|
||||
localizeWorkflowMenus();
|
||||
observeWorkflowMenuLocalization();
|
||||
renameComfyUiBrand();
|
||||
};
|
||||
const queueWorkflowShellPatch = () => {
|
||||
if (shellPatchQueued) return;
|
||||
shellPatchQueued = true;
|
||||
window.setTimeout(patchWorkflowShell, 120);
|
||||
};
|
||||
document.addEventListener('click', queueWorkflowShellPatch, true);
|
||||
window.setTimeout(patchWorkflowShell, 250);
|
||||
window.setTimeout(patchWorkflowShell, 1200);
|
||||
})();
|
||||
</script>"#;
|
||||
let bootstrap = format!("{secret_cleanup_bootstrap}{shell}");
|
||||
html.replace("</head>", &format!("{bootstrap}\n</head>"))
|
||||
}
|
||||
|
||||
pub(super) fn parse_workflow_cookie(raw: &str) -> Option<&str> {
|
||||
raw.split(';')
|
||||
.map(str::trim)
|
||||
@@ -1744,6 +946,7 @@ pub(super) fn parse_workflow_cookie(raw: &str) -> Option<&str> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::bootstrap::inject_enhanced_bootstrap;
|
||||
use super::models::workflow_folder_models;
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user