Files
yawiii-ComfyUI-Prompt-Assis…/js/modules/imageCaption.js
T

1676 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 图像节点小助手类
* 用于检测和处理图像节点,提供图像反推功能
*/
import { app } from "../../../../scripts/app.js";
import { logger } from '../utils/logger.js';
import { ResourceManager } from "../utils/resourceManager.js";
import { UIToolkit } from "../utils/UIToolkit.js";
import { EventManager } from "../utils/eventManager.js";
import { llmVisionService } from '../services/llm_v.js';
import { HistoryCacheService } from '../services/cache.js';
// 调试开关
window.DEBUG_BOUNDS = false; // 显示边界框
window.DEBUG_BOUNDS_INFO = false; // 显示边界框信息
window.DEBUG_BOUNDS_LOG = false; // 显示调试日志
class ImageCaption {
/** 存储所有小助手实例的Map集合 */
static instances = new Map();
constructor() {
this.initialized = false;
}
/**
* 初始化图像小助手
*/
async initialize() {
if (this.initialized) {
return;
}
try {
// 检查总开关状态
if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) {
logger.log("图像小助手初始化跳过:功能已禁用");
return;
}
// 检查app.canvas是否可用
if (!app.canvas) {
throw new Error("画布未初始化");
}
// 初始化事件管理器
EventManager.init();
// 初始化资源管理器
ResourceManager.init();
// 注册节点选择事件监听
this.registerNodeSelectionListener();
this.initialized = true;
logger.log("图像小助手初始化完成");
// 如果有当前选中的节点,立即处理
if (app.canvas.selected_nodes && Object.keys(app.canvas.selected_nodes).length > 0) {
app.canvas._imageCaptionSelectionHandler(app.canvas.selected_nodes);
}
} catch (error) {
logger.error(`图像小助手初始化失败: ${error.message}`);
this.initialized = false;
this.cleanup();
}
}
/**
* 注册节点选择事件监听
*/
registerNodeSelectionListener() {
if (!app.canvas) {
logger.error("画布未初始化,无法注册节点选择事件监听器");
return;
}
// 创建选择事件处理器
const selectionHandler = (selected_nodes) => {
// 当总开关或图像反推功能关闭时,跳过所有节点处理
if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) {
return;
}
// 处理空选择情况
if (!selected_nodes || Object.keys(selected_nodes).length === 0) {
return;
}
// 处理选中的节点
for (const nodeId in selected_nodes) {
const node = app.canvas.graph._nodes_by_id[nodeId];
if (!node || node.id === -1) continue;
// 移除初始化标记判断,每次选中都重新检测节点状态
this.checkAndSetupNode(node);
}
};
// 保存原始的选择事件处理器
if (app.canvas.onSelectionChange && app.canvas.onSelectionChange !== selectionHandler) {
app.canvas._originalImageCaptionSelectionChange = app.canvas.onSelectionChange;
}
// 设置新的选择事件处理器
app.canvas._imageCaptionSelectionHandler = selectionHandler;
// 不覆盖现有的 onSelectionChange,因为提示词小助手可能已经设置了它
// 而是在 index.js 中的 onSelected 方法中调用我们的处理器
// 添加到LiteGraph的事件系统
if (app.canvas.graph) {
// 不覆盖现有的 onNodeSelectionChange,因为提示词小助手可能已经设置了它
app.canvas.graph._imageCaptionNodeSelectionChange = selectionHandler;
}
// 初始检查当前选中的节点
if (app.canvas.selected_nodes && Object.keys(app.canvas.selected_nodes).length > 0) {
selectionHandler(app.canvas.selected_nodes);
}
}
/**
* 检查节点并设置小助手
*/
checkAndSetupNode(node) {
// 检查总开关和图像反推功能开关状态
if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) {
return;
}
// 检查节点是否有效
if (!node) return;
// 检查节点是否被折叠
if (node.flags && node.flags.collapsed) {
// 如果节点被折叠,清理已存在的小助手实例
if (ImageCaption.hasInstance(node.id)) {
this.cleanup(node.id);
}
return;
}
// 检查是否处于低质量渲染状态
if (app.canvas) {
const threshold = app.canvas.low_quality_zoom_threshold || 0.6;
const scale = app.canvas.ds.scale;
// 当处于低质量渲染状态时,清理小助手
if (scale < threshold) {
if (ImageCaption.hasInstance(node.id)) {
this.cleanup(node.id);
}
return;
}
}
// 检查是否有有效图像
if (!this.hasValidImage(node)) {
// 如果节点没有有效图像,清理已存在的小助手实例
if (ImageCaption.hasInstance(node.id)) {
this.cleanup(node.id);
}
return;
}
// 检查实例是否已存在
if (ImageCaption.hasInstance(node.id)) {
// 显示已存在的实例
const instance = ImageCaption.getInstance(node.id);
if (instance) {
this.showAssistantUI(instance);
}
return;
}
// 创建新的小助手实例
const assistant = this.setupNodeAssistant(node);
if (assistant) {
logger.log(`创建图像小助手 | ID: ${node.id}`);
}
}
/**
* 检查节点是否包含有效图像
*/
hasValidImage(node) {
if (!node) return false;
try {
// 检查节点是否被折叠
if (node.flags && node.flags.collapsed) {
return false;
}
// 检查是否处于低质量渲染状态
if (app.canvas) {
// 获取画布的low_quality_zoom_threshold(默认0.6)
const threshold = app.canvas.low_quality_zoom_threshold || 0.6;
// 当前缩放比例
const scale = app.canvas.ds.scale;
// 如果缩放比例小于阈值,说明处于低质量渲染状态
if (scale < threshold) {
return false;
}
}
// 检查节点类型
const imageNodeTypes = [
'PreviewImage',
'SaveImage',
'LoadImage',
'ImageUpload',
'VHS_LoadImage',
'IPAdapter_Preprocessor',
'Image Preprocessor'
];
// 检查图像相关属性
const imageProperties = {
// 标准图像属性
hasImgs: !!(node.imgs && Array.isArray(node.imgs) && node.imgs.length > 0),
hasImageIndex: typeof node.imageIndex === 'number',
hasOverIndex: typeof node.overIndex === 'number',
// 预览相关属性
hasPreview: !!node.preview,
hasPreviewImage: !!node.preview_image,
// 输出相关属性(优化:只有同时满足输出类型为image且有实际图像数据时才返回true)
hasRealImageOutput: false
};
// 检查是否有实际的图像输出
if (node.outputs && node.outputs.some(output => output && (output.type === 'image' || output.name === 'IMAGE'))) {
// 只有当节点同时有图像数据时才认为有真实图像输出
imageProperties.hasRealImageOutput = imageProperties.hasImgs || imageProperties.hasPreview || imageProperties.hasPreviewImage;
}
// 对于PreviewImage和SaveImage类型的节点,只检查是否有实际的图像数据
if (['PreviewImage', 'SaveImage'].includes(node.type)) {
// 检查节点是否有图像数据
if (imageProperties.hasImgs) {
// 如果有imageIndex,检查对应索引的图像是否存在
if (imageProperties.hasImageIndex) {
return !!node.imgs[node.imageIndex];
}
// 如果没有imageIndex但有图像数组,说明有图像
return true;
}
return false;
}
// 其他已知的图像节点类型
if (imageNodeTypes.includes(node.type)) {
return true;
}
// 检查标准图像属性
if (imageProperties.hasImgs) {
if (imageProperties.hasImageIndex && node.imgs[node.imageIndex]) {
return true;
}
if (imageProperties.hasOverIndex && node.imgs[node.overIndex]) {
return true;
}
}
// 检查预览图像
if (imageProperties.hasPreview || imageProperties.hasPreviewImage) {
return true;
}
// 检查图像输出(优化:必须有实际图像数据)
if (imageProperties.hasRealImageOutput) {
return true;
}
return false;
} catch (error) {
logger.error(`检查节点图像失败 | ID: ${node.id} | ${error.message}`);
return false;
}
}
/**
* 为节点设置小助手
*/
setupNodeAssistant(node) {
if (!node) return null;
try {
// 创建小助手实例
const assistant = this.createAssistant(node);
if (assistant) {
// 初始化显示状态
this.showAssistantUI(assistant);
return assistant;
}
return null;
} catch (error) {
logger.error(`创建图像小助手失败 | ID: ${node.id} | ${error.message}`);
return null;
}
}
/**
* 创建小助手实例
*/
createAssistant(node) {
if (!node) return null;
const nodeId = node.id;
// 检查是否已存在实例
if (ImageCaption.hasInstance(nodeId)) {
return ImageCaption.getInstance(nodeId);
}
// 创建小助手对象
const assistant = {
type: "image_caption",
nodeId,
buttons: {},
node,
isTransitioning: false,
isFirstCreate: true, // 标记是否为首次创建
isMouseOver: false, // 鼠标悬停状态
isActive: false // 激活状态
};
// 创建UI并添加到实例集合
this.createAssistantUI(assistant);
ImageCaption.addInstance(nodeId, assistant);
// 设置初始显示状态
assistant.isMouseOver = true;
// 2秒后恢复正常的鼠标检测行为
setTimeout(() => {
if (assistant) {
assistant.isFirstCreate = false;
assistant.isMouseOver = false;
this.updateAssistantVisibility(assistant);
}
}, 2000);
return assistant;
}
/**
* 创建小助手UI
*/
createAssistantUI(assistant) {
if (!assistant?.node) return null;
try {
// 创建内部内容容器
const innerContentDiv = document.createElement('div');
innerContentDiv.className = 'prompt-assistant-inner';
// 创建主容器
const containerDiv = document.createElement('div');
containerDiv.className = 'prompt-assistant-container';
containerDiv.dataset.nodeId = assistant.nodeId;
// 添加内容容器到主容器
containerDiv.appendChild(innerContentDiv);
// 保存引用
assistant.element = containerDiv;
assistant.innerContent = innerContentDiv;
assistant.buttons = {};
// 初始化UI组件和事件
this.addFunctionButtons(assistant);
// 默认隐藏状态
containerDiv.style.display = 'none';
// 使用固定定位样式
containerDiv.style.position = 'fixed';
containerDiv.style.zIndex = '999';
document.body.appendChild(containerDiv);
// 新增:添加鼠标事件监听
this._setupMouseEvents(assistant);
// 新增:延迟设置位置,确保canvas已经初始化
requestAnimationFrame(() => {
this._setupUIPosition(assistant);
});
return containerDiv;
} catch (error) {
logger.error(`图像小助手UI创建失败 | ID: ${assistant.nodeId} | ${error.message}`);
return null;
}
}
/**
* 添加功能按钮
*/
addFunctionButtons(assistant) {
if (!assistant?.element) return;
// 创建反推按钮(中文)
const buttonZh = this.addButtonWithIcon(assistant, {
id: 'caption_zh',
title: '反推提示词(中文)',
icon: 'icon-caption-zh.svg',
onClick: async (e, assistant) => {
e.preventDefault();
e.stopPropagation();
await this.handleImageAnalysis(assistant, 'zh');
}
});
// 创建分割线
const divider = document.createElement('div');
divider.className = 'prompt-assistant-divider';
// 创建反推按钮(英文)
const buttonEn = this.addButtonWithIcon(assistant, {
id: 'caption_en',
title: '反推提示词(英文)',
icon: 'icon-caption-en.svg',
onClick: async (e, assistant) => {
e.preventDefault();
e.stopPropagation();
await this.handleImageAnalysis(assistant, 'en');
}
});
// 按顺序添加元素:中文按钮 -> 分割线 -> 英文按钮
if (buttonZh) {
assistant.innerContent.appendChild(buttonZh);
}
assistant.innerContent.appendChild(divider);
if (buttonEn) {
assistant.innerContent.appendChild(buttonEn);
}
}
/**
* 处理图像分析
*/
async handleImageAnalysis(assistant, lang) {
try {
const node = assistant.node;
if (!node || !node.imgs || node.imgs.length === 0) {
throw new Error('未找到有效的图像');
}
// 获取当前选中的图片
const currentImage = node.imgs[node.imageIndex || 0];
if (!currentImage) {
throw new Error('未找到有效的图像');
}
// 获取按钮元素
const buttonId = lang === 'en' ? 'caption_en' : 'caption_zh';
const buttonElement = assistant.buttons[buttonId];
if (!buttonElement) {
throw new Error('未找到按钮元素');
}
// 设置当前按钮为处理中状态
this._setButtonState(assistant, buttonId, 'processing', true);
// 禁用其他按钮
Object.keys(assistant.buttons).forEach(id => {
if (id !== buttonId) {
this._setButtonState(assistant, id, 'disabled', true);
}
});
// 更新小助手状态为激活状态
this._updateAssistantActiveState(assistant, true);
// 显示加载状态提示
const tipMessage = lang === 'en' ? "反推提示词...(英文)" : "反推提示词...(中文)";
UIToolkit.showStatusTip(
buttonElement,
'loading',
tipMessage,
{ x: buttonElement.getBoundingClientRect().left + buttonElement.offsetWidth / 2, y: buttonElement.getBoundingClientRect().top }
);
// 生成请求ID
const request_id = `${node.id}_${Date.now()}`;
// 调用图像分析服务,传入语言参数
const result = await llmVisionService.analyzeImage(currentImage, request_id, lang);
if (!result.success) {
throw new Error(result.error);
}
// 获取描述文本
const description = result.data.description;
// 尝试复制到剪贴板
let copySuccess = false;
try {
// 优先使用现代的 Clipboard API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(description);
copySuccess = true;
} else {
// 创建一个临时的textarea元素
const textarea = document.createElement('textarea');
textarea.value = description;
textarea.style.position = 'fixed';
textarea.style.left = '0';
textarea.style.top = '0';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
// 尝试聚焦并选中文本
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
// 尝试复制
try {
copySuccess = document.execCommand('copy');
} catch (err) {
logger.warn(`execCommand复制失败: ${err.message}`);
}
// 移除临时元素
document.body.removeChild(textarea);
}
if (copySuccess) {
logger.debug('文本复制成功');
} else {
throw new Error('复制到剪贴板操作未能成功执行');
}
} catch (copyError) {
logger.warn(`复制到剪贴板失败: ${copyError.message}`);
// 即使复制失败也继续执行,不抛出错误,但记录状态
copySuccess = false;
}
// 为当前图像节点创建历史记录
HistoryCacheService.addHistory({
node_id: node.id,
input_id: "image",
content: description,
operation_type: 'caption',
timestamp: Date.now(),
request_id: request_id
});
// 显示成功提示
const successMessage = copySuccess
? (lang === 'en' ? "反推完成,已复制到剪贴板" : "反推完成,已复制到剪贴板")
: (lang === 'en' ? "反推完成,但复制失败" : "反推完成,但复制失败");
UIToolkit.showStatusTip(
buttonElement,
copySuccess ? 'success' : 'warning',
successMessage,
{ x: buttonElement.getBoundingClientRect().left + buttonElement.offsetWidth / 2, y: buttonElement.getBoundingClientRect().top }
);
// 限制Toast显示的文本长度
const maxLength = 40;
const truncatedDescription = description.length > maxLength
? description.substring(0, maxLength) + '...'
: description;
// 显示Toast提示
app.extensionManager.toast.add({
severity: copySuccess ? "success" : "info",
summary: copySuccess
? (lang === 'en' ? "图像反推完成(英文),请使用 ctrl+v 粘贴" : "图像反推完成(中文),请使用 ctrl+v 粘贴")
: (lang === 'en' ? "图像反推完成(英文),但复制失败,请手动复制" : "图像反推完成(中文),但复制失败,请手动复制"),
detail: truncatedDescription,
life: 5000
});
// 如果复制失败,创建一个对话框显示结果,允许用户手动复制
if (!copySuccess) {
this._showCopyDialog(description, lang);
}
} catch (error) {
logger.error(`图像分析失败: ${error.message}`);
// 获取按钮元素
const buttonId = lang === 'en' ? 'caption_en' : 'caption_zh';
const buttonElement = assistant.buttons[buttonId];
if (buttonElement) {
// 显示错误提示
UIToolkit.showStatusTip(
buttonElement,
'error',
error.message,
{ x: buttonElement.getBoundingClientRect().left + buttonElement.offsetWidth / 2, y: buttonElement.getBoundingClientRect().top }
);
}
app.extensionManager.toast.add({
severity: "error",
summary: lang === 'en' ? "Error" : "错误",
detail: error.message,
life: 3000
});
} finally {
// 获取按钮ID
const buttonId = lang === 'en' ? 'caption_en' : 'caption_zh';
// 重置当前按钮状态
this._setButtonState(assistant, buttonId, 'processing', false);
// 恢复其他按钮状态
Object.keys(assistant.buttons).forEach(id => {
if (id !== buttonId) {
this._setButtonState(assistant, id, 'disabled', false);
}
});
// 更新小助手状态为非激活状态
this._updateAssistantActiveState(assistant, false);
}
}
/**
* 显示复制对话框
* 当剪贴板API失败时,提供一个对话框让用户手动复制内容
*/
_showCopyDialog(content, lang) {
// 创建对话框容器
const dialogContainer = document.createElement('div');
dialogContainer.className = 'prompt-assistant-copy-dialog';
dialogContainer.style.position = 'fixed';
dialogContainer.style.top = '50%';
dialogContainer.style.left = '50%';
dialogContainer.style.transform = 'translate(-50%, -50%)';
dialogContainer.style.backgroundColor = 'var(--litegraph-bg-color, #333)';
dialogContainer.style.color = 'var(--litegraph-node-color, #eee)';
dialogContainer.style.padding = '20px';
dialogContainer.style.borderRadius = '8px';
dialogContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)';
dialogContainer.style.zIndex = '9999';
dialogContainer.style.maxWidth = '80%';
dialogContainer.style.maxHeight = '80%';
dialogContainer.style.overflow = 'auto';
dialogContainer.style.display = 'flex';
dialogContainer.style.flexDirection = 'column';
dialogContainer.style.gap = '15px';
// 创建标题
const title = document.createElement('div');
title.style.fontSize = '18px';
title.style.fontWeight = 'bold';
title.style.borderBottom = '1px solid var(--litegraph-node-color, #ccc)';
title.style.paddingBottom = '10px';
title.textContent = lang === 'en' ? '图像反推结果 (英文)' : '图像反推结果 (中文)';
dialogContainer.appendChild(title);
// 创建内容区域
const contentArea = document.createElement('textarea');
contentArea.value = content;
contentArea.style.width = '100%';
contentArea.style.minHeight = '200px';
contentArea.style.padding = '10px';
contentArea.style.border = '1px solid var(--litegraph-node-title, #666)';
contentArea.style.borderRadius = '4px';
contentArea.style.backgroundColor = 'var(--litegraph-node-title-color, #444)';
contentArea.style.color = 'var(--litegraph-node-color, #eee)';
contentArea.readOnly = true;
dialogContainer.appendChild(contentArea);
// 创建按钮容器
const buttonContainer = document.createElement('div');
buttonContainer.style.display = 'flex';
buttonContainer.style.justifyContent = 'space-between';
buttonContainer.style.gap = '10px';
dialogContainer.appendChild(buttonContainer);
// 创建复制按钮
const copyButton = document.createElement('button');
copyButton.textContent = '复制到剪贴板';
copyButton.style.padding = '8px 15px';
copyButton.style.backgroundColor = 'var(--litegraph-node-selected-color, #245)';
copyButton.style.color = 'var(--litegraph-node-color, white)';
copyButton.style.border = 'none';
copyButton.style.borderRadius = '4px';
copyButton.style.cursor = 'pointer';
copyButton.onclick = () => {
contentArea.select();
try {
const success = document.execCommand('copy');
if (success) {
copyButton.textContent = '复制成功!';
setTimeout(() => {
copyButton.textContent = '复制到剪贴板';
}, 2000);
} else {
copyButton.textContent = '复制失败,请手动选择和复制';
contentArea.focus();
}
} catch (err) {
copyButton.textContent = '复制失败,请手动选择和复制';
contentArea.focus();
}
};
buttonContainer.appendChild(copyButton);
// 创建关闭按钮
const closeButton = document.createElement('button');
closeButton.textContent = '关闭';
closeButton.style.padding = '8px 15px';
closeButton.style.backgroundColor = 'var(--litegraph-node-color-terminal, #333)';
closeButton.style.color = 'var(--litegraph-node-color, white)';
closeButton.style.border = 'none';
closeButton.style.borderRadius = '4px';
closeButton.style.cursor = 'pointer';
closeButton.onclick = () => {
document.body.removeChild(dialogContainer);
};
buttonContainer.appendChild(closeButton);
// 添加到文档
document.body.appendChild(dialogContainer);
// 聚焦内容区域,使用户可以立即复制
setTimeout(() => {
contentArea.focus();
contentArea.select();
}, 100);
}
/**
* 设置按钮状态
*/
_setButtonState(assistant, buttonId, stateType, value = true) {
try {
const button = assistant.buttons[buttonId];
if (!button) return;
const stateClass = `button-${stateType}`;
if (value) {
button.classList.add(stateClass);
// 如果是禁用状态,添加disabled属性
if (stateType === 'disabled') {
button.setAttribute('disabled', 'disabled');
}
} else {
button.classList.remove(stateClass);
// 如果取消禁用状态,移除disabled属性
if (stateType === 'disabled') {
button.removeAttribute('disabled');
}
}
// 更新按钮可点击状态
this._updateButtonClickability(button);
} catch (error) {
logger.error(`按钮状态 | 设置失败 | 按钮:${buttonId} | 状态:${stateType} | 错误:${error.message}`);
}
}
/**
* 更新按钮可点击状态
*/
_updateButtonClickability(button) {
// 检查按钮是否处于禁用或处理中状态
const isDisabled = button.classList.contains('button-disabled');
const isProcessing = button.classList.contains('button-processing');
if (isDisabled || isProcessing) {
// 如果按钮被禁用或正在处理中,阻止点击事件
button.style.pointerEvents = 'none';
} else {
// 恢复点击事件
button.style.pointerEvents = 'auto';
}
}
/**
* 检查小助手是否有按钮处于激活状态
*/
_checkAssistantActiveState(assistant) {
if (!assistant || !assistant.buttons) return false;
// 遍历所有按钮,检查是否有按钮处于active或processing状态
for (const buttonId in assistant.buttons) {
const button = assistant.buttons[buttonId];
if (button.classList.contains('button-active') ||
button.classList.contains('button-processing')) {
return true;
}
}
return false;
}
/**
* 更新小助手激活状态
*/
_updateAssistantActiveState(assistant, isActive) {
if (!assistant) return;
// 更新激活状态
assistant.isActive = isActive;
// 如果激活,强制显示小助手
if (isActive) {
this.showAssistantUI(assistant);
} else {
// 如果不再激活,根据鼠标悬停状态决定是否显示
this.updateAssistantVisibility(assistant);
}
}
/**
* 添加带图标的按钮
*/
addButtonWithIcon(assistant, config) {
if (!assistant?.element || !assistant?.innerContent) return null;
const { id, title, icon, onClick } = config;
// 创建按钮
const button = document.createElement('button');
button.className = 'prompt-assistant-button';
button.title = title || '';
button.dataset.id = id || `btn_${Date.now()}`;
button.dataset.icon = icon?.replace('.svg', '');
// 添加图标
if (icon) {
UIToolkit.addIconToButton(button, icon, title || id);
}
// 添加事件
if (typeof onClick === 'function') {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (button.classList.contains('button-processing')) {
// 如果按钮正在处理中,显示"处理中"提示
const tipMessage = id.includes('en') ? "反推提示词...(英文)" : "反推提示词...(中文)";
UIToolkit.showStatusTip(
button,
'loading',
tipMessage,
{ x: button.getBoundingClientRect().left + button.offsetWidth / 2, y: button.getBoundingClientRect().top }
);
return;
}
// 如果按钮被禁用,不执行操作
if (button.classList.contains('button-disabled')) {
return;
}
// 执行点击回调
onClick(e, assistant);
});
}
// 保存引用
if (id) {
assistant.buttons[id] = button;
}
return button;
}
/**
* 设置UI位置
*/
_setupUIPosition(assistant) {
if (!assistant?.element || !assistant?.node) return;
const containerDiv = assistant.element;
// 更新位置的函数
const updatePosition = () => {
if (!assistant.element || !assistant.node) return;
try {
const canvas = app.canvas;
// 如果canvas未初始化,延迟重试
if (!canvas) {
requestAnimationFrame(() => updatePosition());
return;
}
// 获取节点边界
const [nodeX, nodeY, nodeWidth, nodeHeight] = assistant.node.getBounding();
// 计算内部偏移量(用于将小助手放在节点内部)
const INNER_OFFSET_X = 10; // 水平偏移量
const INNER_OFFSET_Y = 18; // 垂直偏移量
// 计算定位点位置(节点右下角)
const anchorX = nodeX + nodeWidth - INNER_OFFSET_X;
const anchorY = nodeY + nodeHeight - INNER_OFFSET_Y;
// 获取画布元素的边界
const rect = canvas.canvas.getBoundingClientRect();
// 使用LGraphCanvas的坐标转换方法
// 将定位点位置转换为屏幕坐标
const canvasPoint = canvas.convertOffsetToCanvas([anchorX, anchorY]);
if (!canvasPoint) return;
// 计算最终的屏幕坐标(考虑画布元素的位置)
const screenX = canvasPoint[0] + rect.left;
const screenY = canvasPoint[1] + rect.top;
// 设置容器位置,使其右下角与定位点对齐
containerDiv.style.right = `${window.innerWidth - screenX}px`;
containerDiv.style.bottom = `${window.innerHeight - screenY}px`;
containerDiv.style.left = 'auto';
containerDiv.style.top = 'auto';
} catch (error) {
logger.error(`更新小助手位置失败: ${error.message}`);
}
};
// 初始更新位置
updatePosition();
// 使用防抖函数优化位置更新
const debouncedUpdatePosition = EventManager.debounce(updatePosition, 16); // 降低到16ms提高流畅度
// 添加窗口resize事件监听
assistant._eventCleanupFunctions = assistant._eventCleanupFunctions || [];
const removeResizeListener = EventManager.addDOMListener(window, 'resize', debouncedUpdatePosition);
assistant._eventCleanupFunctions.push(removeResizeListener);
// 监听画布变化
if (app.canvas) {
// 监听画布重绘
const originalDrawBackground = app.canvas.onDrawBackground;
app.canvas.onDrawBackground = function () {
const ret = originalDrawBackground?.apply(this, arguments);
// 直接调用updatePosition而不是防抖版本,保证重绘时位置准确
updatePosition();
return ret;
};
// 添加画布重绘清理函数
assistant._eventCleanupFunctions.push(() => {
if (originalDrawBackground) {
app.canvas.onDrawBackground = originalDrawBackground;
}
});
// 监听节点移动
if (assistant.node) {
// 使用LiteGraph提供的onNodeMoved事件
const originalOnNodeMoved = app.canvas.onNodeMoved;
app.canvas.onNodeMoved = function(node_dragged) {
if (originalOnNodeMoved) {
originalOnNodeMoved.apply(this, arguments);
}
// 仅当移动的是当前节点时更新位置
if (node_dragged && node_dragged.id === assistant.node.id) {
// 直接调用updatePosition而不是防抖版本,确保拖动时UI跟随节点
updatePosition();
}
};
// 添加节点移动清理函数
assistant._eventCleanupFunctions.push(() => {
if (app.canvas) {
app.canvas.onNodeMoved = originalOnNodeMoved;
}
});
// 为节点本身添加移动监听(兼容性处理)
const nodeOriginalOnNodeMoved = assistant.node.onNodeMoved;
assistant.node.onNodeMoved = function() {
const ret = nodeOriginalOnNodeMoved?.apply(this, arguments);
// 直接调用updatePosition而不是防抖版本
updatePosition();
return ret;
};
// 添加节点自身移动清理函数
assistant._eventCleanupFunctions.push(() => {
if (assistant.node && nodeOriginalOnNodeMoved) {
assistant.node.onNodeMoved = nodeOriginalOnNodeMoved;
}
});
}
// 监听画布缩放
const originalDSModified = app.canvas.ds.onModified;
app.canvas.ds.onModified = function (...args) {
if (originalDSModified) {
originalDSModified.apply(this, args);
}
// 缩放时直接更新,不使用防抖
updatePosition();
};
// 添加画布缩放清理函数
assistant._eventCleanupFunctions.push(() => {
if (app.canvas?.ds) {
app.canvas.ds.onModified = originalDSModified;
}
});
}
// 添加DOM元素清理函数
assistant._eventCleanupFunctions.push(() => {
if (containerDiv && document.body.contains(containerDiv)) {
document.body.removeChild(containerDiv);
}
});
}
/**
* 设置鼠标事件监听
*/
_setupMouseEvents(assistant) {
if (!assistant?.node) return;
const checkMouseInNodeArea = (e) => {
// 检查总开关和图像反推功能开关状态
if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) {
return false;
}
const node = assistant.node;
if (!node) return false;
// 检查节点是否已被删除(关键修复)
if (!app.canvas || !app.canvas.graph || !app.canvas.graph._nodes_by_id[node.id]) {
return false;
}
const canvas = app.canvas;
if (!canvas) return false;
try {
// 获取鼠标在画布中的位置(使用LGraphCanvas的转换方法)
const canvasPos = canvas.convertEventToCanvasOffset(e);
if (!canvasPos) return false;
const [canvasX, canvasY] = canvasPos;
// 获取节点边界 - 使用LiteGraph内置方法
const [nodeX, nodeY, nodeWidth, nodeHeight] = node.getBounding();
// 计算标题栏高度 - 根据ComfyUI的节点样式
const titleHeight = node.flags?.collapsed ? 0 : (node.title_height || 30);
// 计算内容区域(排除标题栏)
const contentY = nodeY + titleHeight;
const contentHeight = nodeHeight - titleHeight;
// 检查鼠标是否在内容区域内(使用画布坐标系)
const isInside = canvasX >= nodeX && canvasX <= nodeX + nodeWidth &&
canvasY >= contentY && canvasY <= contentY + contentHeight;
// 检查鼠标是否在小助手UI上
const isOverUI = assistant.element &&
assistant.element.style.display !== 'none' &&
EventManager.isMouseOverElement(assistant.element);
return isInside || isOverUI;
} catch (error) {
logger.error(`检查节点区域失败: ${error.message}`);
return false;
}
};
// 使用 requestAnimationFrame 优化更新
let rafId = null;
let lastMouseEvent = null;
const updateMouseState = () => {
// 检查总开关和图像反推功能开关状态
if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) {
if (assistant.isMouseOver) {
assistant.isMouseOver = false;
this.updateAssistantVisibility(assistant);
}
rafId = null;
return;
}
// 检查节点是否已被删除(关键修复)
if (!assistant.node || !app.canvas || !app.canvas.graph || !app.canvas.graph._nodes_by_id[assistant.node.id]) {
if (assistant.isMouseOver) {
assistant.isMouseOver = false;
this.updateAssistantVisibility(assistant);
}
rafId = null;
return;
}
// 跳过首次创建的检查(首次创建时已经设置为显示)
if (assistant.isFirstCreate) {
rafId = null;
return;
}
// 如果小助手处于激活状态,保持鼠标悬停状态为true
if (assistant.isActive || this._checkAssistantActiveState(assistant)) {
if (!assistant.isMouseOver) {
assistant.isMouseOver = true;
this.updateAssistantVisibility(assistant);
}
rafId = null;
return;
}
// 只有在有最后鼠标事件的情况下才检查
if (lastMouseEvent) {
const isOver = checkMouseInNodeArea(lastMouseEvent);
// 只在状态变化时更新可见性,避免不必要的重绘
if (isOver !== assistant.isMouseOver) {
assistant.isMouseOver = isOver;
this.updateAssistantVisibility(assistant);
}
}
rafId = null;
};
// 处理鼠标移动
const handleMouseMove = (e) => {
// 检查总开关和图像反推功能开关状态
if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) {
return;
}
lastMouseEvent = e;
if (!rafId) {
rafId = requestAnimationFrame(updateMouseState);
}
};
// 处理画布变换
const handleCanvasTransform = () => {
// 检查总开关和图像反推功能开关状态
if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) {
return;
}
if (lastMouseEvent) {
if (!rafId) {
rafId = requestAnimationFrame(updateMouseState);
}
}
};
// 添加事件监听
if (app.canvas.canvas) {
// 鼠标移动事件
app.canvas.canvas.addEventListener('mousemove', handleMouseMove);
// 画布变换事件
const canvas = app.canvas;
const originalDSModified = canvas.ds.onModified;
canvas.ds.onModified = function (...args) {
if (originalDSModified) {
originalDSModified.apply(this, args);
}
handleCanvasTransform();
};
// 保存清理函数
assistant._eventCleanupFunctions = assistant._eventCleanupFunctions || [];
assistant._eventCleanupFunctions.push(() => {
app.canvas.canvas.removeEventListener('mousemove', handleMouseMove);
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
// 恢复原始的 onModified 处理器
if (canvas.ds) {
canvas.ds.onModified = originalDSModified;
}
});
}
}
/**
* 更新小助手可见性
*/
updateAssistantVisibility(assistant) {
if (!assistant) return;
// 检查总开关和图像反推功能开关状态
if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) {
this.hideAssistantUI(assistant);
return;
}
// 检查节点是否已被删除(关键修复)
if (assistant.node && (!app.canvas || !app.canvas.graph || !app.canvas.graph._nodes_by_id[assistant.node.id])) {
this.hideAssistantUI(assistant);
// 清理已删除节点的实例
this.cleanup(assistant.node.id);
return;
}
// 检查是否处于低质量渲染状态
if (app.canvas) {
const threshold = app.canvas.low_quality_zoom_threshold || 0.6;
const scale = app.canvas.ds.scale;
// 当处于低质量渲染状态时,隐藏小助手
if (scale < threshold) {
this.hideAssistantUI(assistant);
return;
}
}
// 跳过正在过渡的实例,避免动画中断
if (assistant.isTransitioning) {
return;
}
// 检查是否有按钮处于激活状态
const hasActiveButtons = this._checkAssistantActiveState(assistant);
// 如果有激活的按钮,强制显示小助手
if (assistant.isActive || hasActiveButtons) {
this.showAssistantUI(assistant);
return;
}
// 根据鼠标悬停状态或首次创建标记决定显示或隐藏
if (assistant.isFirstCreate || assistant.isMouseOver) {
// 使用requestAnimationFrame确保在下一帧渲染,提高流畅性
requestAnimationFrame(() => {
this.showAssistantUI(assistant);
});
} else {
// 使用requestAnimationFrame确保在下一帧渲染,提高流畅性
requestAnimationFrame(() => {
this.hideAssistantUI(assistant);
});
}
}
/**
* 显示小助手UI
*/
showAssistantUI(assistant, forceAnimation = false) {
if (!assistant?.element) return;
// 避免重复显示
if (assistant.element.classList.contains('assistant-show') && !forceAnimation) {
assistant.element.style.display = 'flex';
assistant.element.style.opacity = '1';
return;
}
// 取消任何隐藏计时器
if (assistant.hideTimeout) {
clearTimeout(assistant.hideTimeout);
assistant.hideTimeout = null;
}
// 设置过渡状态
assistant.isTransitioning = true;
// 优化渲染性能
assistant.element.style.willChange = 'auto';
assistant.element.style.transform = 'translateZ(0)';
assistant.element.style.opacity = '1';
// 显示元素并应用动画类
assistant.element.style.display = 'flex';
void assistant.element.offsetWidth; // 触发回流
assistant.element.classList.remove('assistant-hide');
assistant.element.classList.add('assistant-show');
// 动画结束后重置过渡状态
setTimeout(() => {
assistant.isTransitioning = false;
// 动画结束后检查鼠标状态(关键修复)
setTimeout(() => this.forceUpdateMouseState(assistant), 10);
}, 300);
}
/**
* 隐藏小助手UI
*/
hideAssistantUI(assistant) {
if (!assistant?.element) return;
// 避免重复隐藏
if (!assistant.element.classList.contains('assistant-show')) return;
// 如果小助手处于激活状态且功能开关开启,不隐藏
if (window.FEATURES && window.FEATURES.enabled && window.FEATURES.imageCaption &&
(assistant.isActive || this._checkAssistantActiveState(assistant))) {
return;
}
// 设置过渡状态
assistant.isTransitioning = true;
// 应用隐藏动画类
assistant.element.classList.add('assistant-hide');
assistant.element.classList.remove('assistant-show');
// 触发回流确保动画生效
void assistant.element.offsetWidth;
// 动画结束后隐藏元素
assistant.hideTimeout = setTimeout(() => {
if (assistant.element) {
assistant.element.style.display = 'none';
}
assistant.isTransitioning = false;
// 动画结束后检查鼠标状态(关键修复)
setTimeout(() => this.forceUpdateMouseState(assistant), 10);
}, 300);
}
/**
* 隐藏所有小助手
*/
hideAllAssistants() {
ImageCaption.instances.forEach(assistant => {
this.hideAssistantUI(assistant);
});
}
/**
* 清理资源
*/
cleanup(nodeId = null, silent = false) {
try {
if (nodeId) {
// 清理特定节点
const instance = ImageCaption.getInstance(nodeId);
if (instance) {
// 记录删除前的实例数量
const beforeCount = ImageCaption.instances.size;
logger.debug(`[删除前] 图像小助手实例数量: ${beforeCount}`);
// 清理实例内部资源
this._cleanupInstance(instance, nodeId);
// 从实例集合中移除
ImageCaption.instances.delete(String(nodeId));
// 记录删除后的实例数量
const afterCount = ImageCaption.instances.size;
if (!silent) {
// 获取当前剩余的统计信息
logger.log(`清理图像小助手 | 节点ID: ${nodeId}`);
logger.log(`[剩余统计] 图像小助手实例: ${afterCount}个 | 删除前: ${beforeCount}个`);
}
} else {
// 检查是否有以该nodeId开头的实例(关键修复)
const keysToDelete = Array.from(ImageCaption.instances.keys())
.filter(key => key.startsWith(`${nodeId}_`));
if (keysToDelete.length > 0) {
const beforeCount = ImageCaption.instances.size;
// 清理所有匹配的实例
keysToDelete.forEach(key => {
const instance = ImageCaption.getInstance(key);
if (instance) {
this._cleanupInstance(instance, key);
ImageCaption.instances.delete(key);
}
});
const afterCount = ImageCaption.instances.size;
if (!silent) {
logger.log(`清理图像小助手 | 节点ID: ${nodeId} | 关联实例: ${keysToDelete.length}个`);
logger.log(`[剩余统计] 图像小助手实例: ${afterCount}个 | 删除前: ${beforeCount}个`);
}
}
}
return;
}
// 清理所有实例
const instanceCount = ImageCaption.instances.size;
if (instanceCount > 0) {
logger.debug(`[全部删除前] 图像小助手实例数量: ${instanceCount}`);
ImageCaption.instances.forEach((instance, key) => {
this._cleanupInstance(instance, key);
});
ImageCaption.instances.clear();
if (!silent) {
logger.log(`清理所有图像小助手 | 实例数: ${instanceCount}`);
logger.log(`[剩余统计] 图像小助手实例: 0个`);
}
}
} catch (error) {
logger.error(`清理资源失败: ${error.message}`);
}
}
/**
* 清理单个实例
*/
_cleanupInstance(instance, instanceKey) {
try {
if (!instance) return;
// 1. 重置所有按钮状态
if (instance.buttons) {
Object.keys(instance.buttons).forEach(buttonId => {
try {
const button = instance.buttons[buttonId];
if (button) {
// 移除所有状态类
button.classList.remove('button-active', 'button-processing', 'button-disabled');
// 移除所有事件监听器
button.replaceWith(button.cloneNode(true));
}
} catch (err) {
logger.debug(`按钮清理 | 按钮:${buttonId} | 错误:${err.message}`);
}
});
// 清空按钮引用
instance.buttons = {};
}
// 2. 清理事件监听器
if (instance._eventCleanupFunctions) {
instance._eventCleanupFunctions.forEach(cleanup => {
if (typeof cleanup === 'function') {
try {
cleanup();
} catch (err) {
logger.error(`事件清理失败: ${err.message}`);
}
}
});
instance._eventCleanupFunctions = [];
}
// 3. 从DOM中移除元素
if (instance.element && instance.element.parentNode) {
// 确保在移除元素前清理所有子元素的事件
const allButtons = instance.element.querySelectorAll('button');
allButtons.forEach(button => {
button.replaceWith(button.cloneNode(true));
});
instance.element.parentNode.removeChild(instance.element);
}
// 4. 清理节点引用(关键修复)
if (instance.node) {
delete instance.node;
}
// 5. 清理实例属性
Object.keys(instance).forEach(key => {
try {
delete instance[key];
} catch (err) {
logger.error(`属性清理失败: ${err.message}`);
}
});
logger.debug(`图像小助手实例清理 | 结果:成功 | 实例:${instanceKey}`);
} catch (error) {
logger.error(`图像小助手实例清理失败: ${error.message}`);
}
}
// ---静态方法---
/**
* 添加实例到管理器
*/
static addInstance(nodeId, assistant) {
if (nodeId != null && assistant != null) {
this.instances.set(String(nodeId), assistant);
return true;
}
return false;
}
/**
* 获取实例
*/
static getInstance(nodeId) {
if (nodeId == null) return null;
// 确保nodeId是字符串类型
const key = String(nodeId);
const instance = this.instances.get(key);
// 添加调试日志
if (instance) {
logger.debug(`获取图像小助手实例 | 节点ID: ${key} | 结果: 成功`);
} else {
logger.debug(`获取图像小助手实例 | 节点ID: ${key} | 结果: 未找到`);
}
return instance;
}
/**
* 检查实例是否存在
*/
static hasInstance(nodeId) {
if (nodeId == null) return false;
// 确保nodeId是字符串类型
return this.instances.has(String(nodeId));
}
/**
* 统一控制总开关功能
* 集中管理所有受总开关控制的服务功能
*/
async toggleGlobalFeature(enable, force = false) {
try {
// 更新状态
const oldValue = window.FEATURES.imageCaption;
// 状态未变化时不执行操作,除非force为true
if (!force && oldValue === enable) {
return;
}
logger.log(`图像小助手功能开关 | 动作:${enable ? "启用" : "禁用"}`);
if (enable) {
// === 启用图像小助手 ===
// 1. 初始化图像小助手(如果尚未初始化)
if (!this.initialized) {
await this.initialize();
}
// 2. 重置节点初始化标记,准备重新检测
if (app.canvas && app.canvas.graph) {
const nodes = app.canvas.graph._nodes || [];
nodes.forEach(node => {
if (node) {
node._imageCaptionInitialized = false;
}
});
}
// 3. 设置或恢复节点选择事件监听
this.registerNodeSelectionListener();
// 4. 如果有当前选中的节点,立即处理
if (app.canvas && app.canvas.selected_nodes && Object.keys(app.canvas.selected_nodes).length > 0) {
app.canvas._imageCaptionSelectionHandler(app.canvas.selected_nodes);
}
} else {
// === 禁用图像小助手 ===
// 清理所有图像小助手实例
this.cleanup(null, true);
}
} catch (error) {
logger.error(`图像小助手功能开关操作失败 | 错误: ${error.message}`);
}
}
/**
* 强制更新鼠标悬停状态
*/
forceUpdateMouseState(assistant) {
if (!assistant || !assistant.node || !assistant.element) return;
// 总开关关闭时不处理鼠标状态更新
if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) {
return;
}
// 检查节点是否已被删除(关键修复)
if (!app.canvas || !app.canvas.graph || !app.canvas.graph._nodes_by_id[assistant.node.id]) {
assistant.isMouseOver = false;
this.updateAssistantVisibility(assistant);
return;
}
try {
// 获取鼠标在画布中的位置
const mousePos = EventManager.getMousePosition();
const canvas = app.canvas;
if (!canvas) return;
// 将屏幕坐标转换为画布坐标
const canvasPos = canvas.convertEventToCanvasOffset({
clientX: mousePos.x,
clientY: mousePos.y
});
if (!canvasPos) return;
const [canvasX, canvasY] = canvasPos;
// 获取节点边界
const [nodeX, nodeY, nodeWidth, nodeHeight] = assistant.node.getBounding();
// 计算标题栏高度 - 根据ComfyUI的节点样式
const titleHeight = assistant.node.flags?.collapsed ? 0 : (assistant.node.title_height || 30);
// 计算内容区域(排除标题栏)
const contentY = nodeY + titleHeight;
const contentHeight = nodeHeight - titleHeight;
// 检查鼠标是否在内容区域内
const isOverNode = canvasX >= nodeX && canvasX <= nodeX + nodeWidth &&
canvasY >= contentY && canvasY <= contentY + contentHeight;
// 检查鼠标是否在小助手UI上
const isOverUI = assistant.element.style.display !== 'none' &&
this.isMouseOverElement(assistant.element);
// 更新状态
const oldState = assistant.isMouseOver;
assistant.isMouseOver = isOverNode || isOverUI;
// 如果状态变化,更新可见性
if (oldState !== assistant.isMouseOver) {
logger.debug(`鼠标状态 | 手动更新 | 节点:${assistant.node.id} | 原状态:${oldState} | 新状态:${assistant.isMouseOver}`);
this.updateAssistantVisibility(assistant);
}
return assistant.isMouseOver;
} catch (error) {
logger.error(`强制更新鼠标状态失败: ${error.message}`);
return false;
}
}
/**
* 判断鼠标是否在元素上方
*/
isMouseOverElement(element) {
if (!element) return false;
try {
// 获取鼠标位置
const mousePos = EventManager.getMousePosition();
const rect = element.getBoundingClientRect();
return (
mousePos.x >= rect.left &&
mousePos.x <= rect.right &&
mousePos.y >= rect.top &&
mousePos.y <= rect.bottom
);
} catch (error) {
logger.error(`鼠标位置检测失败 | 错误: ${error.message}`);
return false;
}
}
}
// 创建单例实例
const imageCaption = new ImageCaption();
// 导出
export { imageCaption, ImageCaption };