/**
* 图像节点小助手类
* 用于检测和处理图像节点,提供图像反推功能
*/
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 { APIService } from '../services/api.js';
import { HistoryCacheService } from '../services/cache.js';
import { buttonMenu } from "../services/btnMenu.js";
import { rulesConfigManager } from "./rulesConfigManager.js";
import { nodeMountService, RENDER_MODE } from "../services/NodeMountService.js";
import { AssistantContainer, ANCHOR_POSITION } from "./AssistantContainer.js";
import { PopupManager } from "../utils/popupManager.js";
class ImageCaption {
/** 存储所有小助手实例的Map集合 */
static instances = new Map();
constructor() {
this.initialized = false;
}
/**
* 获取低画质渲染阈值
* 将此方法提取为类方法,确保所有地方使用相同的阈值计算逻辑
*/
_getQualityThreshold() {
// 优先从系统设置获取
if (app.ui?.settings) {
try {
// 修复:不使用已弃用的defaultValue参数
const settingValue = app.ui.settings.getSettingValue('Comfy.Graph.CanvasInfo');
if (typeof settingValue === 'number') {
return settingValue;
}
} catch (e) {
// 忽略错误,使用默认值
}
}
// 从canvas对象获取
return app.canvas?.low_quality_zoom_threshold || 0.6;
}
/**
* 检查节点类型是否是允许的图像节点类型(公开方法)
*/
isSupportedNode(node) {
return this._isAllowedNodeType(node);
}
/**
* 检查节点类型是否是允许的图像节点类型
* @param {object} node - LiteGraph节点对象
* @param {boolean} debug - 是否打印调试日志
* @returns {boolean} 是否是允许的节点类型
*/
_isAllowedNodeType(node, debug = false) {
if (!node || !node.type) return false;
// 允许的节点类型列表(可根据实际情况调整)
const allowedTypes = [
// 加载图像节点
'LoadImage',
'LoadImageFromUrl',
// 预览图像节点
'PreviewImage',
'ImagePreview',
// 保存图像节点
'SaveImage',
'SaveImages'
];
// 检查节点类型是否在允许列表中
// 使用部分匹配方式,以便兼容不同插件中的类似节点
const isAllowed = allowedTypes.some(type =>
node.type.includes(type) ||
(node.title && node.title.includes(type))
);
// 开发阶段可打开调试日志
if (debug && !isAllowed) {
logger.debug(`[图像小助手] 节点类型不允许: ${node.type || '未知'} | 标题: ${node.title || '未知'}`);
}
return isAllowed;
}
/**
* 检查节点和画布状态是否适合显示小助手
* @param {object} node - LiteGraph节点对象
* @returns {object} 返回检测结果对象
*/
_checkNodeAndCanvasState(node) {
const result = {
isValid: false,
isCollapsed: false,
isLowQuality: false,
hasValidImage: false
};
if (!node) {
return result;
}
// 检查节点是否是允许的图像节点类型
if (!this._isAllowedNodeType(node)) {
return result;
}
// 检查节点是否被折叠
if (node.flags && node.flags.collapsed) {
result.isCollapsed = true;
return result;
}
// 检查是否处于低质量渲染状态
if (app.canvas) {
// 获取低画质渲染阈值
const threshold = this._getQualityThreshold();
const scale = app.canvas.ds.scale;
// 添加一个较大的容差值,解决浮点数比较和阈值应用延迟问题
const epsilon = 0.001;
if (scale <= threshold + epsilon) {
result.isLowQuality = true;
return result;
}
}
// 检查节点是否有有效图像
if (node.imgs && Array.isArray(node.imgs) && node.imgs.length > 0) {
const imageIndex = node.imageIndex || 0;
if (imageIndex >= 0 && imageIndex < node.imgs.length && node.imgs[imageIndex]) {
result.hasValidImage = true;
result.isValid = true;
}
}
// --- 补充检测: Vue Node 2.0 DOM 检测 ---
if (!result.hasValidImage && nodeMountService.isVueNodesMode()) {
const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`);
if (nodeContainer) {
const imgElement = nodeContainer.querySelector('img');
if (imgElement && imgElement.src && !imgElement.src.includes('error')) {
// logger.debugSample(() => `[图像小助手] 通过 DOM 检测到图像 | ID: ${node.id}`);
result.hasValidImage = true;
result.isValid = true;
}
}
}
return result;
}
/**
* 检查节点是否有有效图像
* @param {object} node - LiteGraph节点对象
* @returns {boolean} 是否有有效图像
*/
hasValidImage(node) {
if (!node) return false;
// 检查节点类型是否允许
if (!this._isAllowedNodeType(node)) {
return false;
}
// 检查节点是否有imgs属性
if (!node.imgs || !Array.isArray(node.imgs) || node.imgs.length === 0) {
return false;
}
// 获取当前显示的图像
const imageIndex = node.imageIndex || 0;
return imageIndex >= 0 && imageIndex < node.imgs.length && node.imgs[imageIndex] != null;
}
/**
* 获取节点当前显示的图像对象
* @param {object} node - LiteGraph节点对象
* @returns {object|null} 返回图像对象或null
*/
getNodeImage(node) {
// 检查节点类型是否允许
if (!this._isAllowedNodeType(node)) {
return null;
}
const state = this._checkNodeAndCanvasState(node);
if (!state.isValid) {
return null;
}
// 返回当前显示的图像
const imageIndex = node.imageIndex || 0;
return node.imgs[imageIndex];
}
/**
* 初始化图像小助手
*/
async initialize() {
if (this.initialized) return true;
try {
// 检查总开关的初始状态
const initialEnabled = app.ui.settings.getSettingValue("PromptAssistant.Features.ImageCaption");
window.FEATURES.imageCaption = initialEnabled !== undefined ? initialEnabled : true;
// 只在调试模式下记录初始化状态
logger.debug(`图像反推功能初始化 | 状态:${window.FEATURES.imageCaption ? "启用" : "禁用"}`);
// 注册节点选择监听器
this.registerNodeSelectionListener();
// 标记为已初始化
this.initialized = true;
logger.log("图像小助手初始化完成");
return true;
} catch (error) {
logger.error(() => `图像小助手初始化失败 | 错误: ${error.message}`);
this.initialized = false;
return false;
}
}
/**
* 注册节点选择事件监听
*/
registerNodeSelectionListener() {
if (!app.canvas) {
logger.error("画布未初始化,无法注册节点选择事件监听器");
return;
}
// 如果已经注册过选择事件处理器,则不再重复注册
if (app.canvas._imageCaptionSelectionHandler) {
logger.debug("图像小助手节点选择监听器已存在,跳过注册");
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;
// 添加到LiteGraph的事件系统
if (app.canvas.graph) {
app.canvas.graph._imageCaptionNodeSelectionChange = selectionHandler;
}
logger.debug("图像小助手节点选择监听器注册成功");
// 初始检查当前选中的节点
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 (!this._isAllowedNodeType(node)) {
return;
}
// 检查节点是否已被删除(只有在删除时才清理实例)
if (!app.canvas || !app.canvas.graph || !app.canvas.graph._nodes_by_id[node.id]) {
// 节点已被删除,清理实例
this.cleanup(node.id);
return;
}
// 检查节点和画布状态
const nodeState = this._checkNodeAndCanvasState(node);
// 如果节点折叠或画布缩放过小,隐藏已存在的实例但不创建新实例
if (nodeState.isCollapsed || nodeState.isLowQuality) {
if (ImageCaption.hasInstance(node.id)) {
const instance = ImageCaption.getInstance(node.id);
if (instance) {
this.updateAssistantVisibility(instance);
}
}
return;
}
// 验证现有实例是否有效
const existingInstance = ImageCaption.getInstance(node.id);
const isElementValid = existingInstance && existingInstance.element && document.body.contains(existingInstance.element);
const isVueMode = nodeMountService.isVueNodesMode();
// 在 Vue 模式下,element 可能挂载在节点容器内而不是 body
const isVueElementValid = isVueMode && existingInstance && existingInstance.element &&
document.querySelector(`[data-node-id="${node.id}"]`)?.contains(existingInstance.element);
if (existingInstance && (isElementValid || isVueElementValid)) {
// 实例有效,更新其显示状态(内部会处理图片检测)
this.updateAssistantVisibility(existingInstance);
} else {
// 实例无效或不存在,清理并强制创建(白名单机制)
if (existingInstance) {
this.cleanup(node.id);
}
// 创建新的小助手实例
const assistant = this.setupNodeAssistant(node);
if (assistant) {
logger.debug(() => `[图像小助手] 预挂载成功 | ID: ${node.id} | 等待图像加载...`);
}
}
}
/**
* 为节点设置小助手
*/
setupNodeAssistant(node) {
if (!node) return null;
// 创建小助手实例
const assistant = this.createAssistant(node);
if (assistant) {
// 初始化显示状态
this.showAssistantUI(assistant);
return assistant;
}
return null;
}
/**
* 创建小助手实例
*/
createAssistant(node) {
// 检查节点是否有效
if (!node || !node.id || node.id === -1) {
return null;
}
// 检查是否已存在实例
if (ImageCaption.hasInstance(node.id)) {
return ImageCaption.getInstance(node.id);
}
// 保存 nodeId 用于动态获取节点
const nodeId = node.id;
// 创建小助手对象
const assistant = {
nodeId: nodeId,
buttons: {},
isActive: false,
isTransitioning: false,
isDestroyed: false,
_eventCleanupFunctions: [],
_timers: {},
// 保存初始节点引用作为后备(Vue Node 2.0 子图切换场景)
_initialNode: node
};
// 动态获取节点的 getter,避免持有已删除节点的引用
// 【修复】优先从 graph 获取,失败时回退到初始引用(解决子图切换时画布未同步问题)
Object.defineProperty(assistant, 'node', {
get() {
if (this.isDestroyed) return null;
// 优先从当前画布 graph 动态获取
const graphNode = app.canvas?.graph?._nodes_by_id?.[this.nodeId];
if (graphNode) return graphNode;
// 回退:使用初始节点引用(如果仍有效)
if (this._initialNode && this._initialNode.id === this.nodeId) {
return this._initialNode;
}
return null;
},
configurable: true
});
// 创建UI
this.createAssistantUI(assistant);
// 添加到实例集合
ImageCaption.addInstance(nodeId, assistant);
// 设置节点折叠状态监听
this._setupNodeCollapseListener(assistant);
// 设置画布缩放监听
this._setupCanvasScaleListener(assistant);
// 显示小助手
this.updateAssistantVisibility(assistant);
return assistant;
}
/**
* 创建小助手UI
*/
createAssistantUI(assistant) {
// 【修复】使用 nodeId 作为有效性检查,避免因画布切换时 getter 返回 null 导致创建失败
if (!assistant?.nodeId) return null;
try {
// 获取位置设置
let locationSetting = app.ui.settings.getSettingValue(
"ImageCaption.Location"
);
// 根据不同的渲染模式自动分配互不冲突的最佳位置
if (nodeMountService.isVueNodesMode()) {
locationSetting = ANCHOR_POSITION.BOTTOM_RIGHT_H; // Vue模式右下角避开ID
} else {
locationSetting = ANCHOR_POSITION.BOTTOM_LEFT_H; // LiteGraph模式保持左下角
}
// Create AssistantContainer instance
const container = new AssistantContainer({
nodeId: assistant.nodeId,
type: 'image', // Uses image-assistant-container class
anchorPosition: locationSetting,
enableDragSort: true,
onButtonOrderChange: (order) => {
// Order preservation handled by container
},
shouldCollapse: () => {
return !this._checkAssistantActiveState(assistant);
}
});
// Render
const containerEl = container.render();
// Set Icon
const mainIcon = ResourceManager.getIcon('icon-main.svg');
if (mainIcon && container.indicator) {
container.indicator.innerHTML = '';
container.indicator.appendChild(mainIcon);
}
// Save references
assistant.container = container;
assistant.element = containerEl;
assistant.innerContent = container.content;
assistant.hoverArea = container.hoverArea;
assistant.indicator = container.indicator;
assistant.buttons = {};
// Sync state properties
Object.defineProperty(assistant, 'isCollapsed', {
get: () => container.isCollapsed,
set: (val) => {
if (val) container.collapse(); else container.expand();
}
});
Object.defineProperty(assistant, 'isTransitioning', {
get: () => container.isTransitioning,
set: (val) => { container.isTransitioning = val; }
});
// Add buttons
this.addFunctionButtons(assistant);
// Restore order
container.restoreOrder();
// 初始设置
// 注意:原始代码设置位置固定并追加到 body
// AssistantContainer 创建元素但不挂载。
// 原始代码:
// containerDiv.style.position = 'fixed';
// document.body.appendChild(containerDiv);
// 我们遵循原始逻辑,将 Image Assistant 追加到 body
containerEl.style.position = 'fixed';
containerEl.style.zIndex = '1';
document.body.appendChild(containerEl);
// 延迟设置位置
// 优化:使用 requestAnimationFrame 确保在下一次重绘前更新位置
requestAnimationFrame(() => {
this._setupUIPosition(assistant);
if (container) container.updateDimensions();
});
// 折叠/展开设置由 container 处理
// 移除手动事件设置
return containerEl;
} 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',
onClick: async (e, assistant) => {
e.preventDefault();
e.stopPropagation();
await this.handleImageAnalysis(assistant, 'zh');
},
// 添加中文反推按钮的右键菜单
contextMenu: async (assistant) => {
// 获取服务列表和当前激活状态
let services = [];
let currentVLMService = null;
let currentVLMModel = null;
// 获取中文反推规则
let activePromptId = null;
let visionPrompts = [];
try {
// 获取服务列表
const servicesResp = await fetch(APIService.getApiUrl('/services'));
if (servicesResp.ok) {
const servicesData = await servicesResp.json();
if (servicesData.success) {
services = servicesData.services || [];
}
}
// 获取当前激活的VLM服务和模型
const vlmResp = await fetch(APIService.getApiUrl('/config/vision'));
if (vlmResp.ok) {
const vlmConfig = await vlmResp.json();
currentVLMService = vlmConfig.provider || null;
currentVLMModel = vlmConfig.model || null;
}
// 获取中文反推规则
const response = await fetch(APIService.getApiUrl('/config/system_prompts'));
if (response.ok) {
const data = await response.json();
activePromptId = data.active_prompts?.vision_zh || null;
if (data.vision_prompts) {
const originalOrder = Object.keys(data.vision_prompts);
originalOrder.forEach(key => {
if (key.startsWith('vision_zh')) {
const prompt = data.vision_prompts[key];
const showIn = prompt.showIn || ['frontend', 'node'];
// 仅当配置包含 'frontend' 时才在前端菜单显示
if (showIn.includes('frontend')) {
visionPrompts.push({
id: key,
name: prompt.name || key,
category: prompt.category || '',
showIn: showIn,
isActive: key === activePromptId
});
}
}
});
visionPrompts.sort((a, b) =>
originalOrder.indexOf(a.id) - originalOrder.indexOf(b.id)
);
}
}
} catch (error) {
logger.error(() => `获取中文反推配置失败: ${error.message}`);
}
// 创建服务菜单项(只显示有VLM模型的服务)
const serviceMenuItems = services
.filter(service => service.vlm_models && service.vlm_models.length > 0)
.map(service => {
const isCurrentService = currentVLMService === service.id;
// 创建模型子菜单
const modelChildren = (service.vlm_models || []).map(model => {
const isCurrentModel = isCurrentService && currentVLMModel === model.name;
return {
label: model.display_name || model.name,
icon: ``,
onClick: async (context) => {
try {
const res = await fetch(APIService.getApiUrl('/services/current'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service_type: 'vlm', service_id: service.id, model_name: model.name })
});
if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
const modelLabel = model.display_name || model.name;
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name} - ${modelLabel}`);
logger.log(`视觉服务切换 | 服务: ${service.name} | 模型: ${modelLabel}`);
} catch (err) {
logger.error(`切换视觉模型失败: ${err.message}`);
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
}
}
};
});
return {
label: service.name || service.id,
icon: ``,
onClick: async (context) => {
try {
const res = await fetch(APIService.getApiUrl('/services/current'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service_type: 'vlm', service_id: service.id })
});
if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name}`);
logger.log(`视觉服务切换 | 服务: ${service.name}`);
} catch (err) {
logger.error(`切换视觉服务失败: ${err.message}`);
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
}
},
children: modelChildren.length > 0 ? modelChildren : undefined
};
});
// ---创建规则菜单项(支持分类分组)---
const ruleMenuItems = [];
// 辅助函数:创建单个规则菜单项
const createRuleMenuItem = (prompt) => ({
label: prompt.name,
icon: ``,
onClick: async (context) => {
try {
const response = await fetch(APIService.getApiUrl('/config/active_prompt'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'vision_zh', prompt_id: prompt.id })
});
if (response.ok) {
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${prompt.name}`);
} else {
throw new Error(`服务器返回错误: ${response.status}`);
}
} catch (error) {
logger.error(`切换中文反推提示词失败: ${error.message}`);
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${error.message}`);
}
}
});
// 按分类分组规则
const uncategorizedPrompts = visionPrompts.filter(p => !p.category);
const categorizedPrompts = visionPrompts.filter(p => p.category);
// 收集所有分类并排序
const categories = [...new Set(categorizedPrompts.map(p => p.category))].sort();
// 添加无分类的规则(放在顶层)
uncategorizedPrompts.forEach(prompt => {
ruleMenuItems.push(createRuleMenuItem(prompt));
});
// 添加分类分组(每个分类作为二级菜单)
categories.forEach(category => {
const promptsInCategory = categorizedPrompts.filter(p => p.category === category);
const hasActivePrompt = promptsInCategory.some(p => p.isActive);
ruleMenuItems.push({
label: category,
icon: ``,
submenuAlign: 'center',
children: promptsInCategory.map(prompt => createRuleMenuItem(prompt))
});
});
// 添加规则管理选项
ruleMenuItems.push({ type: 'separator' });
ruleMenuItems.push({
label: '规则管理',
icon: '',
onClick: () => {
rulesConfigManager.showRulesConfigModal();
}
});
return [
...ruleMenuItems,
// { type: 'separator' },
{
label: "选择服务",
icon: '',
submenuAlign: 'bottom',
children: serviceMenuItems
}
];
}
});
// 创建反推按钮(英文)
const buttonEn = this.addButtonWithIcon(assistant, {
id: 'caption_en',
title: '反推提示词(英文)',
icon: 'icon-caption-en',
onClick: async (e, assistant) => {
e.preventDefault();
e.stopPropagation();
await this.handleImageAnalysis(assistant, 'en');
},
// 添加英文反推按钮的右键菜单
contextMenu: async (assistant) => {
// 获取服务列表和当前激活状态
let services = [];
let currentVLMService = null;
let currentVLMModel = null;
// 获取英文反推规则
let activePromptId = null;
let visionPrompts = [];
try {
// 获取服务列表
const servicesResp = await fetch(APIService.getApiUrl('/services'));
if (servicesResp.ok) {
const servicesData = await servicesResp.json();
if (servicesData.success) {
services = servicesData.services || [];
}
}
// 获取当前激活的VLM服务和模型
const vlmResp = await fetch(APIService.getApiUrl('/config/vision'));
if (vlmResp.ok) {
const vlmConfig = await vlmResp.json();
currentVLMService = vlmConfig.provider || null;
currentVLMModel = vlmConfig.model || null;
}
// 获取英文反推规则
const response = await fetch(APIService.getApiUrl('/config/system_prompts'));
if (response.ok) {
const data = await response.json();
activePromptId = data.active_prompts?.vision_en || null;
if (data.vision_prompts) {
const originalOrder = Object.keys(data.vision_prompts);
originalOrder.forEach(key => {
if (key.startsWith('vision_en')) {
const prompt = data.vision_prompts[key];
const showIn = prompt.showIn || ['frontend', 'node'];
// 仅当配置包含 'frontend' 时才在前端菜单显示
if (showIn.includes('frontend')) {
visionPrompts.push({
id: key,
name: prompt.name || key,
category: prompt.category || '',
showIn: showIn,
isActive: key === activePromptId
});
}
}
});
visionPrompts.sort((a, b) =>
originalOrder.indexOf(a.id) - originalOrder.indexOf(b.id)
);
}
}
} catch (error) {
logger.error(`获取英文反推配置失败: ${error.message}`);
}
// 创建服务菜单项(只显示有VLM模型的服务)
const serviceMenuItems = services
.filter(service => service.vlm_models && service.vlm_models.length > 0)
.map(service => {
const isCurrentService = currentVLMService === service.id;
// 创建模型子菜单
const modelChildren = (service.vlm_models || []).map(model => {
const isCurrentModel = isCurrentService && currentVLMModel === model.name;
return {
label: model.display_name || model.name,
icon: ``,
onClick: async (context) => {
try {
const res = await fetch(APIService.getApiUrl('/services/current'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service_type: 'vlm', service_id: service.id, model_name: model.name })
});
if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
const modelLabel = model.display_name || model.name;
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name} - ${modelLabel}`);
logger.log(`视觉服务切换 | 服务: ${service.name} | 模型: ${modelLabel}`);
} catch (err) {
logger.error(`切换视觉模型失败: ${err.message}`);
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
}
}
};
});
return {
label: service.name || service.id,
icon: ``,
onClick: async (context) => {
try {
const res = await fetch(APIService.getApiUrl('/services/current'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service_type: 'vlm', service_id: service.id })
});
if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name}`);
logger.log(`视觉服务切换 | 服务: ${service.name}`);
} catch (err) {
logger.error(`切换视觉服务失败: ${err.message}`);
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
}
},
children: modelChildren.length > 0 ? modelChildren : undefined
};
});
// ---创建规则菜单项(支持分类分组)---
const ruleMenuItems = [];
// 辅助函数:创建单个规则菜单项
const createRuleMenuItem = (prompt) => ({
label: prompt.name,
icon: ``,
onClick: async (context) => {
try {
const response = await fetch(APIService.getApiUrl('/config/active_prompt'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'vision_en', prompt_id: prompt.id })
});
if (response.ok) {
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${prompt.name}`);
} else {
throw new Error(`服务器返回错误: ${response.status}`);
}
} catch (error) {
logger.error(`切换英文反推提示词失败: ${error.message}`);
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${error.message}`);
}
}
});
// 按分类分组规则
const uncategorizedPrompts = visionPrompts.filter(p => !p.category);
const categorizedPrompts = visionPrompts.filter(p => p.category);
// 收集所有分类并排序
const categories = [...new Set(categorizedPrompts.map(p => p.category))].sort();
// 添加无分类的规则(放在顶层)
uncategorizedPrompts.forEach(prompt => {
ruleMenuItems.push(createRuleMenuItem(prompt));
});
// 添加分类分组(每个分类作为二级菜单)
categories.forEach(category => {
const promptsInCategory = categorizedPrompts.filter(p => p.category === category);
const hasActivePrompt = promptsInCategory.some(p => p.isActive);
ruleMenuItems.push({
label: category,
icon: ``,
submenuAlign: 'center',
children: promptsInCategory.map(prompt => createRuleMenuItem(prompt))
});
});
// 添加规则管理选项
ruleMenuItems.push({ type: 'separator' });
ruleMenuItems.push({
label: '规则管理',
icon: '',
onClick: () => {
rulesConfigManager.showRulesConfigModal();
}
});
return [
...ruleMenuItems,
// { type: 'separator' },
{
label: "选择服务",
icon: '',
submenuAlign: 'bottom',
children: serviceMenuItems
}
];
}
});
// Create buttons
if (buttonZh) {
assistant.container.addButton(buttonZh, 'caption_zh');
}
if (buttonEn) {
assistant.container.addButton(buttonEn, 'caption_en');
}
}
// ---流式输出辅助方法---
/**
* 创建流式显示浮层
/**
* 创建节点内嵌流式显示容器
*/
_createStreamingOverlay(assistant) {
try {
const nodeId = assistant.node?.id;
// 策略1:优先尝试获取 Vue 节点容器
let mountContainer = document.querySelector(`[data-node-id="${nodeId}"]`);
let isLiteGraph = false;
if (!mountContainer) {
// 策略2:LiteGraph 模式,使用 Body 挂载 + Fixed 定位实现“裁切隔离”
mountContainer = document.body;
isLiteGraph = true;
}
// 创建外层容器
const container = document.createElement('div');
container.className = 'node-streaming-text-container';
if (isLiteGraph) {
container.classList.add('is-litegraph');
}
// 创建文本内容容器
const content = document.createElement('div');
content.className = 'node-streaming-text-content';
container.appendChild(content);
mountContainer.appendChild(container);
// LiteGraph 模式:启动帧同步位置锁定
let syncHandler = null;
if (isLiteGraph) {
const syncPos = () => {
if (!assistant.streamingOverlay || !assistant.streamingOverlay.container) return;
this._syncLiteGraphPosition(assistant, container);
syncHandler = requestAnimationFrame(syncPos);
};
syncHandler = requestAnimationFrame(syncPos);
}
// 触发入场动画
requestAnimationFrame(() => {
container.classList.add('show');
});
return { container, content, isLiteGraph, syncHandler };
} catch (error) {
logger.error(`创建流式显示容器失败: ${error.message}`);
return null;
}
}
/**
* LiteGraph 模式下的位置与几何同步 (Viewport 绝对定位)
*/
_syncLiteGraphPosition(assistant, container) {
const node = assistant.node;
if (!node || !app.canvas) return;
const ds = app.canvas.ds;
const pos = node.pos;
const size = node.size;
const scale = ds?.scale || 1;
// 1. 获取节点左上角的屏幕坐标
let screenX, screenY;
if (ds && typeof ds.canvas_to_screen === 'function') {
const screenPos = ds.canvas_to_screen(pos[0], pos[1]);
screenX = screenPos[0];
screenY = screenPos[1];
} else {
screenX = (pos[0] + (ds?.offset?.[0] || 0)) * scale;
screenY = (pos[1] + (ds?.offset?.[1] || 0)) * scale;
}
// 2. 同步几何尺寸:将节点的逻辑宽度、高度转换成屏幕物理像素
// 关键点:容器的宽高必须随 scale 同步,才能防止文字“溢出”节点物理边界
const physicalWidth = size[0] * scale;
const physicalHeight = 100 * scale; // 逻辑高度 100px 转换为物理像素
// 3. 计算对齐位置
// 底部工具栏高度逻辑值为 35px,转换后为 35 * scale
const bottomOffset = 35 * scale;
const top = screenY + (size[1] * scale) - bottomOffset - physicalHeight;
// 4. 应用样式到同步容器
container.style.width = `${physicalWidth - 16 * scale}px`; // 减去边缘间距
container.style.height = `${physicalHeight}px`;
container.style.left = `${screenX + 8 * scale}px`;
container.style.top = `${top}px`;
// 5. 同步内容缩放:确保字号随节点大小变化
// 注意:我们通过改变容器的 fontSize 来影响内容。
// content 容器内部使用相对单位或依靠父级继承。
const content = container.querySelector('.node-streaming-text-content');
if (content) {
content.style.fontSize = `${10 * scale}px`;
content.style.lineHeight = `${1.6}`;
}
}
/**
* 移除流式内嵌容器
*/
_removeStreamingOverlay(assistant, overlayObj) {
if (!overlayObj || !overlayObj.container) return;
const { container, syncHandler } = overlayObj;
// 停止同步循环
if (syncHandler) {
cancelAnimationFrame(syncHandler);
}
try {
container.classList.remove('show');
setTimeout(() => {
if (container.parentNode) {
container.parentNode.removeChild(container);
}
}, 300);
} catch (error) {
logger.error(`移除流式容器失败: ${error.message}`);
if (container.parentNode) container.parentNode.removeChild(container);
}
}
/**
* 处理图像分析
*/
async handleImageAnalysis(assistant, lang) {
// 存储当前请求ID,用于取消操作
let currentRequestId = null;
try {
const node = assistant.node;
// Vue Node 2.0 模式:从 DOM 获取当前显示的图像
// 传统 LiteGraph 模式:使用 node.imgs 属性
let currentImage = null;
const isVueMode = nodeMountService.isVueNodesMode();
if (isVueMode) {
// Vue Node 2.0 模式:从节点容器的 DOM 中获取图像
const nodeContainer = document.querySelector(`[data-node-id="${node.id}"]`);
if (nodeContainer) {
// 查找节点容器内的 img 元素(优先查找预览图像)
const imgElement = nodeContainer.querySelector('img');
if (imgElement && imgElement.src) {
// 使用 DOM 中的图像 src
currentImage = imgElement.src;
logger.debug(`[图像小助手-Vue模式] 从DOM获取图像 | 节点ID: ${node.id}`);
}
}
}
// 如果 Vue 模式未找到图像,或者是传统模式,使用 node.imgs
if (!currentImage) {
if (!node.imgs || node.imgs.length === 0) {
throw new Error('未找到有效的图像');
}
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('未找到按钮元素');
}
// 检查按钮是否已经处于处理状态,如果是,则取消当前请求
if (buttonElement.classList.contains('button-processing') && assistant.currentRequestId) {
// 取消当前请求
await APIService.cancelRequest(assistant.currentRequestId);
// 显示取消提示
UIToolkit.showStatusTip(
buttonElement,
'info',
'反推已取消',
{ x: buttonElement.getBoundingClientRect().left + buttonElement.offsetWidth / 2, y: buttonElement.getBoundingClientRect().top }
);
// 重置按钮状态
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);
// 清除当前请求ID
assistant.currentRequestId = null;
return;
}
// 设置当前按钮为处理中状态
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
// 生成请求ID
currentRequestId = APIService.generateRequestId('icap', null, node.id);
// 保存到assistant对象中,以便取消操作
assistant.currentRequestId = currentRequestId;
// 将图像转换为Base64
let imageBase64;
try {
imageBase64 = await APIService.imageToBase64(currentImage);
if (!imageBase64) {
throw new Error('图像转换失败');
}
} catch (e) {
throw new Error(`图像转换失败: ${e.message || e}`);
}
// 确保图像数据格式正确
if (typeof imageBase64 !== 'string') {
throw new Error(`图像数据类型错误: ${typeof imageBase64}`);
}
// 确保图像数据是Base64格式
if (!imageBase64.startsWith('data:image')) {
imageBase64 = `data:image/jpeg;base64,${imageBase64}`;
}
// 获取当前激活的提示词内容
let promptContent = '';
try {
const response = await fetch(APIService.getApiUrl('/config/system_prompts'));
if (response.ok) {
const data = await response.json();
const activePromptKey = `vision_${lang}`;
const activePromptId = data.active_prompts?.[activePromptKey];
if (activePromptId && data.vision_prompts?.[activePromptId]) {
promptContent = data.vision_prompts[activePromptId].content;
}
}
if (!promptContent) {
throw new Error(`未找到有效的 ${lang === 'zh' ? '中文' : '英文'} 反推提示词`);
}
} catch (error) {
throw new Error(`获取反推规则失败: ${error.message}`);
}
// 根据开关选择流式或阻塞式 API
let result;
let fullContent = '';
if (FEATURES.enableStreaming !== false) {
// 创建内嵌流式对象
const overlayObj = this._createStreamingOverlay(assistant);
if (!overlayObj) {
logger.error("[图像小助手] 无法创建内嵌流式显示对象");
return;
}
assistant.streamingOverlay = overlayObj;
// 使用流式 API 调用图像分析服务
result = await APIService.llmAnalyzeImageStream(
imageBase64,
promptContent,
currentRequestId,
(chunk) => {
// 流式回调:实时更新浮层内容
fullContent += chunk;
if (assistant.streamingOverlay && assistant.streamingOverlay.content) {
const contentEl = assistant.streamingOverlay.content;
// 更新文本内容
contentEl.textContent = fullContent;
// 滚动容器(保持在底部)
const container = assistant.streamingOverlay.container;
container.scrollTop = container.scrollHeight;
}
}
);
// 确保流式内容在完成后被赋值到结果对象中
if (result && result.success && fullContent) {
if (!result.data) result.data = {};
result.data.description = fullContent;
}
} else {
// ---阻塞输出:直接调用图像分析服务---
result = await APIService.llmAnalyzeImage(
imageBase64,
promptContent,
currentRequestId
);
}
// 移除流式内嵌容器
if (assistant.streamingOverlay) {
this._removeStreamingOverlay(assistant, assistant.streamingOverlay);
assistant.streamingOverlay = null;
}
// 清除当前请求ID
assistant.currentRequestId = null;
// 检查是否被取消
if (result && result.cancelled) {
logger.debug(`图像分析请求已取消 | ID: ${currentRequestId}`);
return;
}
if (!result || !result.success) {
const errorMsg = result?.error || '未知错误';
throw new Error(errorMsg);
}
// 获取描述文本(优先使用流式收集的内容)
const description = fullContent || result.data?.description;
if (!description) {
throw new Error('未获取到图像描述');
}
// 尝试复制到剪贴板
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) {
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: currentRequestId
});
// 显示成功提示
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) {
// 清除当前请求ID
assistant.currentRequestId = null;
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 = 'image-assistant-copy-dialog';
// 创建标题
const title = document.createElement('div');
title.className = 'image-assistant-copy-dialog-title';
title.textContent = '由于剪贴板权限被限制,请手动点击复制提示词内容';
dialogContainer.appendChild(title);
// 创建关闭按钮
const closeButton = document.createElement('button');
closeButton.className = 'image-assistant-copy-dialog-close';
closeButton.onclick = () => {
document.body.removeChild(dialogContainer);
};
// 添加关闭图标
UIToolkit.addIconToButton(closeButton, 'pi-times', '关闭');
dialogContainer.appendChild(closeButton);
// 创建文本区域
const contentArea = document.createElement('textarea');
contentArea.className = 'image-assistant-copy-dialog-textarea';
contentArea.value = content;
contentArea.readOnly = true;
dialogContainer.appendChild(contentArea);
// 创建复制按钮
const copyButton = document.createElement('button');
copyButton.className = 'image-assistant-copy-dialog-copy-btn';
copyButton.textContent = '复制到剪贴板';
copyButton.onclick = () => {
contentArea.select();
try {
const success = document.execCommand('copy');
if (success) {
copyButton.textContent = '复制成功!';
// 复制成功后,延迟500ms关闭弹窗,让用户看到成功提示
setTimeout(() => {
if (document.body.contains(dialogContainer)) {
document.body.removeChild(dialogContainer);
}
}, 500);
} else {
copyButton.textContent = '复制失败,请手动选择和复制';
contentArea.focus();
}
} catch (err) {
copyButton.textContent = '复制失败,请手动选择和复制';
contentArea.focus();
}
};
dialogContainer.appendChild(copyButton);
// 添加到文档
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, stateType, value);
} catch (error) {
logger.error(`按钮状态 | 设置失败 | 按钮:${buttonId} | 状态:${stateType} | 错误:${error.message}`);
}
}
/**
* 更新按钮可点击状态
*/
_updateButtonClickability(button, stateType, value) {
// 检查按钮是否处于禁用状态
const isDisabled = button.classList.contains('button-disabled');
// 处理中的按钮仍然可点击(用于取消操作)
const isProcessing = button.classList.contains('button-processing');
if (isDisabled) {
// 如果按钮被禁用,阻止点击事件
button.style.pointerEvents = 'none';
} else {
// 恢复点击事件,包括处理中的按钮
button.style.pointerEvents = 'auto';
}
}
/**
* 检查小助手是否有按钮处于激活状态
*/
_checkAssistantActiveState(assistant) {
if (!assistant || !assistant.buttons) return false;
// 0. 检查是否正在切换弹窗(切换期间不允许折叠)
if (PopupManager._isTransitioning) {
return true;
}
// 1. 检查右键菜单是否可见(并且属于当前 assistant)
if (buttonMenu.isMenuVisible && buttonMenu.menuContext?.widget === assistant) {
return true;
}
// 2. 检查 PopupManager 的活动弹窗是否属于当前 assistant
if (PopupManager.activePopupInfo?.buttonInfo?.widget === assistant) {
return true;
}
// 3. 检查按钮的 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);
// 然后手动触发自动折叠(如果小助手仍然可见且处于展开状态)
if (assistant.element &&
assistant.element.style.display !== 'none' &&
!assistant.isCollapsed &&
!assistant.isTransitioning) {
// 延迟一点时间再触发折叠,让用户有时间看到结果
setTimeout(() => {
this.triggerAutoCollapse(assistant);
}, 1500); // 1.5秒后自动折叠,给用户足够时间查看结果
}
}
}
/**
* 显示小助手UI
*/
showAssistantUI(assistant) {
if (!assistant?.element) return;
// 避免重复显示
if (assistant.element.classList.contains('image-assistant-show')) {
// 确保元素可见
assistant.element.style.display = 'flex';
assistant.element.style.opacity = '1';
return;
}
// 直接显示,无动画过渡
assistant.element.style.opacity = '1';
assistant.element.style.display = 'flex';
assistant.element.classList.add('image-assistant-show');
// 确保悬停区域可见(用于折叠状态下的交互)
if (assistant.isCollapsed && assistant.hoverArea) {
assistant.hoverArea.style.display = 'block';
}
// 重置过渡状态
assistant.isTransitioning = false;
// 只有当明确不是折叠状态时才触发自动折叠
if (!assistant.isCollapsed) {
this.triggerAutoCollapse(assistant);
}
}
/**
* 隐藏小助手UI
*/
hideAssistantUI(assistant) {
if (!assistant?.element) return;
// 清除自动折叠定时器
if (assistant._autoCollapseTimer) {
clearTimeout(assistant._autoCollapseTimer);
assistant._autoCollapseTimer = null;
}
// 隐藏元素
assistant.element.style.display = 'none';
assistant.element.classList.remove('image-assistant-show');
// 重置状态
assistant.isTransitioning = false;
}
/**
* 更新小助手可见性
*/
updateAssistantVisibility(assistant) {
// 检查实例是否已销毁
if (!assistant || assistant.isDestroyed) return;
// 动态获取节点
const node = assistant.node;
// 记录当前显示状态,用于检测变化
const wasVisible = assistant.element &&
assistant.element.style.display !== 'none' &&
assistant.element.classList.contains('image-assistant-show');
// 检查总开关和图像反推功能开关状态
if (!window.FEATURES || !window.FEATURES.enabled || !window.FEATURES.imageCaption) {
this.cleanup(assistant.nodeId);
return;
}
// 检查节点是否已被删除
if (!node) {
this.cleanup(assistant.nodeId);
return;
}
// 检查节点是否已被删除
if (!node) {
this.cleanup(assistant.nodeId);
return;
}
// --- 修复:LiteGraph 子图“幽灵”图标问题 ---
// 检查节点所属的 graph 是否为当前激活的 graph
// 如果当前处在子图中,而节点在父图,则应隐藏
// 注意:app.canvas.graph 是当前视图显示的 graph
if (app.canvas && app.canvas.graph && node.graph && node.graph !== app.canvas.graph) {
// 节点不在当前显示的 graph 中,强制隐藏
// logger.debugSample(() => `[图像小助手] 节点不在当前视图中,强制隐藏 | ID: ${assistant.nodeId}`);
this.hideAssistantUI(assistant);
return;
}
// 使用统一的状态检测方法
const nodeState = this._checkNodeAndCanvasState(assistant.node);
// 检查是否有按钮处于激活状态
const hasActiveButtons = this._checkAssistantActiveState(assistant);
// 确定新的可见性状态
let shouldBeVisible = true;
// 如果有激活的按钮,强制显示小助手(覆盖其他隐藏条件)
if (hasActiveButtons) {
this.showAssistantUI(assistant);
// 如果当前是折叠状态,则展开
if (assistant.isCollapsed) {
this._expandAssistant(assistant);
}
return;
}
// 如果节点折叠或画布缩放过小,隐藏小助手但不清理实例
if (nodeState.isCollapsed || nodeState.isLowQuality) {
shouldBeVisible = false;
}
// 如果节点没有有效图像,隐藏小助手但不清理实例
if (!nodeState.hasValidImage) {
shouldBeVisible = false;
// ---异步图像检测逻辑 (针对性能优化)---
// 如果节点是支持的类型但暂时没图,启动一个内部轻量级探测器
if (!assistant._imageDetectionTimer) {
const detectImage = () => {
// 检查实例是否已被清理或节点是否已不再图中
if (assistant.isDestroyed || !assistant.node) {
assistant._imageDetectionTimer = null;
return;
}
const currentState = this._checkNodeAndCanvasState(assistant.node);
if (currentState.hasValidImage) {
logger.debug(() => `[图像小助手] 异步图像加载成功 | ID: ${assistant.nodeId}`);
assistant._imageDetectionTimer = null;
// 额外检查:如果 element 已经丢失(可能是 Vue 重新渲染),重新初始化
const isVueMode = nodeMountService.isVueNodesMode();
const nodeContainer = isVueMode ? document.querySelector(`[data-node-id="${assistant.nodeId}"]`) : null;
const isElementPresent = isVueMode ? nodeContainer?.contains(assistant.element) : document.body.contains(assistant.element);
if (!assistant.element || !isElementPresent) {
this.checkAndSetupNode(assistant.node);
} else {
this.updateAssistantVisibility(assistant);
}
} else {
// 继续等待,每秒检测一次 (开销极低)
assistant._imageDetectionTimer = setTimeout(detectImage, 1000);
}
};
assistant._imageDetectionTimer = setTimeout(detectImage, 1000);
}
} else {
// 已有图像,清理探测定时器
if (assistant._imageDetectionTimer) {
clearTimeout(assistant._imageDetectionTimer);
assistant._imageDetectionTimer = null;
}
}
// 跳过正在过渡的实例,避免动画中断
if (assistant.isTransitioning) {
return;
}
// 根据可见性状态更新UI
if (shouldBeVisible) {
// 条件满足时显示小助手
this.showAssistantUI(assistant);
} else {
// 隐藏小助手
this.hideAssistantUI(assistant);
}
}
/**
* 展开小助手
*/
_expandAssistant(assistant) {
if (assistant && assistant.container) {
assistant.container.expand();
}
}
/**
* 触发自动折叠
*/
triggerAutoCollapse(assistant) {
if (assistant && assistant.container) {
assistant.container.collapse();
}
}
/**
* 添加带图标的按钮
*/
addButtonWithIcon(assistant, config) {
if (!assistant?.element || !assistant?.innerContent) return null;
const { id, title, icon, onClick, contextMenu } = config;
// 创建按钮
const button = document.createElement('button');
button.className = 'image-assistant-button';
button.title = title || '';
button.dataset.id = id || `btn_${Date.now()}`;
// 添加图标 - 使用UIToolkit的SVG图标方法
if (icon) {
UIToolkit.addIconToButton(button, icon, title || '');
}
// 添加事件
if (typeof onClick === 'function') {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// 即使按钮正在处理中也允许点击,用于取消操作
// 如果按钮被禁用,不执行操作
if (button.classList.contains('button-disabled')) {
return;
}
// 执行点击回调
onClick(e, assistant);
});
}
// 添加右键菜单(如果有)
if (contextMenu && typeof contextMenu === 'function') {
this._setupButtonContextMenu(button, contextMenu, assistant);
}
// 保存引用
if (id) {
assistant.buttons[id] = button;
}
return button;
}
/**
* 设置UI位置
* 支持 Vue node2.0 和 litegraph.js 两种渲染模式
*/
_setupUIPosition(assistant) {
if (!assistant?.element || !assistant?.node) return;
const containerDiv = assistant.element;
const renderMode = nodeMountService.detectRenderMode();
// 保存渲染模式到assistant
assistant._renderMode = renderMode;
if (renderMode === RENDER_MODE.VUE_NODES) {
// Vue node2.0 模式:使用相对定位挂载到节点容器内
this._setupUIPositionVueNodes(assistant, containerDiv);
} else {
// litegraph.js 模式:使用fixed定位和画布坐标计算
this._setupUIPositionLitegraph(assistant, containerDiv);
}
}
/**
* Vue node2.0 模式下的定位逻辑
*/
_setupUIPositionVueNodes(assistant, containerDiv) {
const containerInfo = nodeMountService.findImageNodeContainer(assistant.node);
if (!containerInfo || !containerInfo.container) {
logger.warn(`[图像小助手-Vue定位] 未找到节点容器 | ID: ${assistant.node.id}`);
// 降级到litegraph模式
this._setupUIPositionLitegraph(assistant, containerDiv);
return;
}
const { container: nodeContainer } = containerInfo;
// Vue模式:使用绝对定位,挂载到节点容器内(右侧对齐,无需上移避让由于左侧的ID)
containerDiv.style.position = 'absolute';
containerDiv.style.right = '6px';
containerDiv.style.bottom = '6px';
containerDiv.style.left = 'auto';
containerDiv.style.top = 'auto';
containerDiv.style.zIndex = '10';
// 移除fixed定位相关样式
containerDiv.style.transform = 'none';
// 添加Vue模式标记类
containerDiv.classList.add('vue-node-mode');
// 从document.body移除(如果之前挂载过)
if (containerDiv.parentElement === document.body) {
document.body.removeChild(containerDiv);
}
// 挂载到节点容器
nodeContainer.appendChild(containerDiv);
// 添加清理函数
assistant._eventCleanupFunctions = assistant._eventCleanupFunctions || [];
assistant._eventCleanupFunctions.push(() => {
if (containerDiv && containerDiv.parentElement) {
containerDiv.parentElement.removeChild(containerDiv);
}
});
logger.debug(`[图像小助手-Vue定位] 完成 | 节点ID: ${assistant.node.id}`);
}
/**
* litegraph.js 模式下的定位逻辑(原有逻辑)
*/
_setupUIPositionLitegraph(assistant, containerDiv) {
// 更新位置的函数
const updatePosition = () => {
if (!assistant.element || !assistant.node) return;
try {
const canvas = app.canvas;
// 如果canvas未初始化,延迟重试
if (!canvas) {
requestAnimationFrame(() => updatePosition());
return;
}
// 获取画布缩放比例
const scale = canvas.ds.scale;
// 获取节点边界
const [nodeX, nodeY, nodeWidth, nodeHeight] = assistant.node.getBounding();
// 计算内部偏移量(用于将小助手放在节点内部)
const INNER_OFFSET_X = 6; // 水平偏移量
const INNER_OFFSET_Y = 6; // 垂直偏移量
// 计算定位点位置(节点左下角)
const anchorX = nodeX + INNER_OFFSET_X;
const anchorY = nodeY + nodeHeight - INNER_OFFSET_Y;
// 获取画布元素的边界
const rect = canvas.canvas.getBoundingClientRect();
// 将定位点位置转换为屏幕坐标
const canvasPoint = canvas.convertOffsetToCanvas([anchorX, anchorY]);
if (!canvasPoint) return;
// 计算最终的屏幕坐标(考虑画布元素的位置)
const screenX = canvasPoint[0] + rect.left;
const screenY = canvasPoint[1] + rect.top;
// 设置容器位置,使其左下角与定位点对齐
containerDiv.style.left = `${screenX}px`;
containerDiv.style.bottom = `${window.innerHeight - screenY}px`;
containerDiv.style.right = 'auto';
containerDiv.style.top = 'auto';
// 应用缩放
containerDiv.style.setProperty('--assistant-scale', scale);
} catch (error) {
// 仅在首次出错时记录
if (!assistant._lastPositionError) {
logger.error(() => `更新小助手位置失败: ${error.message}`);
assistant._lastPositionError = Date.now();
}
}
};
// 初始更新位置
updatePosition();
// 使用防抖函数优化位置更新
const debouncedUpdatePosition = EventManager.debounce(updatePosition, 16);
// 添加窗口resize事件监听
assistant._eventCleanupFunctions = assistant._eventCleanupFunctions || [];
const removeResizeListener = EventManager.addDOMListener(window, 'resize', debouncedUpdatePosition);
assistant._eventCleanupFunctions.push(removeResizeListener);
// 监听画布变化
if (app.canvas) {
// 监听画布重绘
// 优化:使用requestAnimationFrame在每一帧更新位置,或者直接利用 LiteGraph 的渲染循环
// 这里为了跟随平滑,直接在 drawBackground 钩子中更新
const originalDrawBackground = app.canvas.onDrawBackground;
const onDrawWrapper = function () {
const ret = originalDrawBackground?.apply(this, arguments);
updatePosition();
return ret;
};
app.canvas.onDrawBackground = onDrawWrapper;
// 添加画布重绘清理函数
assistant._eventCleanupFunctions.push(() => {
if (app.canvas.onDrawBackground === onDrawWrapper) {
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();
}
};
// 添加节点移动清理函数
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();
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);
}
});
}
/**
* 设置节点折叠状态监听
*/
_setupNodeCollapseListener(assistant) {
if (!assistant?.node) return;
const node = assistant.node;
// 保存原始的 collapse 方法
const originalCollapse = node.collapse;
// 重写 collapse 方法
node.collapse = function () {
// 调用原始的 collapse 方法
originalCollapse.apply(this, arguments);
// 调用统一的可见性更新方法,而不是直接操作DOM
const imageCaptionInstance = window.imageCaptionInstance || imageCaption;
if (imageCaptionInstance && typeof imageCaptionInstance.updateAssistantVisibility === 'function') {
imageCaptionInstance.updateAssistantVisibility(assistant);
}
};
// 保存清理函数
assistant._eventCleanupFunctions.push(() => {
// 恢复原始的 collapse 方法
if (node.collapse !== originalCollapse) {
node.collapse = originalCollapse;
}
});
}
/**
* 设置画布缩放监听
*/
_setupCanvasScaleListener(assistant) {
if (!assistant?.node || !app.canvas) return;
let lastScale = app.canvas.ds.scale;
// 使用类方法获取阈值,确保一致性
const threshold = this._getQualityThreshold();
// 直接检测缩放状态并更新UI可见性
const checkScaleAndUpdate = () => {
if (!assistant.element || !assistant.node || !app.canvas) return;
const currentScale = app.canvas.ds.scale;
const threshold = imageCaption._getQualityThreshold(); // 修复:使用全局实例的方法
const epsilon = 0.001; // 增加容差值
// 计算与上次缩放的差值
const scaleDiff = Math.abs(currentScale - lastScale);
// 更新上次缩放值
lastScale = currentScale;
// 判断当前画质状态(使用容差值)
const isCurrentlyLowQuality = currentScale <= threshold + epsilon;
// 无条件更新UI可见性,让_checkNodeAndCanvasState方法决定是否显示
this.updateAssistantVisibility(assistant);
};
// 确保立即响应
const immediateUpdate = checkScaleAndUpdate;
// 监听画布的鼠标滚轮事件(缩放)
const wheelHandler = (e) => {
if (e.ctrlKey || e.metaKey) {
// 立即更新并安排延迟检查
immediateUpdate();
setTimeout(immediateUpdate, 50);
}
};
// 监听画布的触摸事件(移动端缩放)
const touchHandler = (e) => {
if (e.touches && e.touches.length === 2) {
// 立即更新并安排延迟检查
immediateUpdate();
setTimeout(immediateUpdate, 50);
}
};
// 添加事件监听器
const canvas = app.canvas.canvas;
if (canvas) {
canvas.addEventListener('wheel', wheelHandler, { passive: true });
canvas.addEventListener('touchmove', touchHandler, { passive: true });
}
// 定期检查缩放变化
const scaleCheckInterval = setInterval(immediateUpdate, 50);
// 初始检查一次当前状态
immediateUpdate();
// 保存清理函数
assistant._eventCleanupFunctions.push(() => {
if (canvas) {
canvas.removeEventListener('wheel', wheelHandler);
canvas.removeEventListener('touchmove', touchHandler);
}
clearInterval(scaleCheckInterval);
});
// 监听画布缩放事件(直接监听ds.scale变化)
const originalDSScale = Object.getOwnPropertyDescriptor(app.canvas.ds, 'scale');
if (originalDSScale && originalDSScale.set) {
const originalSetter = originalDSScale.set;
Object.defineProperty(app.canvas.ds, 'scale', {
get: originalDSScale.get,
set: function (value) {
// 获取旧值
const oldValue = this.scale;
const threshold = imageCaption._getQualityThreshold();
// 调用原始setter
originalSetter.call(this, value);
// 检测是否跨越了阈值边界
const epsilon = 0.001;
const crossedThreshold =
(oldValue <= threshold + epsilon && value > threshold + epsilon) ||
(oldValue > threshold + epsilon && value <= threshold + epsilon);
if (crossedThreshold) {
// 如果跨越了阈值,只记录一次日志
// 使用静态变量记录上次跨越阈值的时间,避免短时间内重复输出
const now = Date.now();
if (!ImageCaption._lastThresholdCrossTime || now - ImageCaption._lastThresholdCrossTime > 500) {
logger.log(`[图像小助手-缩放监听] 跨越阈值 | 旧值: ${oldValue.toFixed(4)} | 新值: ${value.toFixed(4)} | 阈值: ${threshold}`);
ImageCaption._lastThresholdCrossTime = now;
}
}
// 无论是否跨越阈值,都立即更新
immediateUpdate();
// 多次检查确保状态正确应用
setTimeout(immediateUpdate, 10);
setTimeout(immediateUpdate, 50);
setTimeout(() => {
// 更新所有已存在的图像小助手实例
ImageCaption.instances.forEach((instance) => {
if (instance && instance.node) {
imageCaption.updateAssistantVisibility(instance);
}
});
}, 100);
},
configurable: true
});
// 添加清理函数
assistant._eventCleanupFunctions.push(() => {
if (app.canvas && app.canvas.ds) {
Object.defineProperty(app.canvas.ds, 'scale', originalDSScale);
}
});
}
}
/**
* 设置展开折叠事件
*/
_setupCollapseExpandEvents(assistant) {
// Event handling is delegated to AssistantContainer
}
/**
* 设置按钮右键菜单
* @param {HTMLElement} button 按钮元素
* @param {Function} getMenuItems 获取菜单项的函数
* @param {Object} assistant 小助手实例
*/
_setupButtonContextMenu(button, getMenuItems, assistant) {
if (!button || typeof getMenuItems !== 'function') return;
// 确保assistant对象具有正确的类型标识,方便右键菜单关闭时识别
assistant.type = 'image_caption_assistant';
const cleanup = buttonMenu.setupButtonMenu(button, () => {
return getMenuItems(assistant);
}, { widget: assistant, buttonElement: button }); // 传递正确的上下文
if (cleanup) {
assistant._eventCleanupFunctions = assistant._eventCleanupFunctions || [];
assistant._eventCleanupFunctions.push(cleanup);
}
}
// ---静态方法---
/**
* 添加实例到管理器
*/
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);
return instance;
}
/**
* 检查实例是否存在
*/
static hasInstance(nodeId) {
if (nodeId == null) return false;
// 确保nodeId是字符串类型
return this.instances.has(String(nodeId));
}
/**
* 清理小助手实例
* @param {string|null} nodeId - 节点ID,如果为null则清理所有实例
* @param {boolean} silent - 是否静默清理(不输出日志)
*/
cleanup(nodeId = null, silent = false) {
// 如果正在切换工作流,完全清理图像小助手实例
if (window.PROMPT_ASSISTANT_WORKFLOW_SWITCHING) {
// 简化日志:工作流切换期间不逐条打印节点清理日志
if (nodeId === null) {
// 清理所有实例,并从集合中移除
const instanceCount = ImageCaption.instances.size;
if (instanceCount > 0) {
ImageCaption.instances.forEach((assistant, id) => {
this._cleanupSingleInstance(assistant);
});
// 清空实例集合
ImageCaption.instances.clear();
}
} else {
// 清理特定节点实例
const searchId = String(nodeId);
const keysToDelete = Array.from(ImageCaption.instances.keys())
.filter(key => key === searchId || key.startsWith(`${searchId}_`));
keysToDelete.forEach(key => {
const assistant = ImageCaption.instances.get(key);
if (assistant) {
this._cleanupSingleInstance(assistant);
ImageCaption.instances.delete(key);
}
});
}
return;
}
try {
if (nodeId === null) {
// 清理所有实例
const instanceCount = ImageCaption.instances.size;
if (instanceCount > 0) {
ImageCaption.instances.forEach((assistant, id) => {
this._cleanupSingleInstance(assistant);
});
ImageCaption.instances.clear();
if (!silent) {
logger.log(`清理所有图像小助手实例 | 数量: ${instanceCount}`);
}
}
} else {
// 清理指定节点的所有实例
const searchId = String(nodeId);
const keysToDelete = Array.from(ImageCaption.instances.keys())
.filter(key => key === searchId || key.startsWith(`${searchId}_`));
keysToDelete.forEach(key => {
const assistant = ImageCaption.instances.get(key);
if (assistant) {
this._cleanupSingleInstance(assistant);
ImageCaption.instances.delete(key);
if (!silent) {
logger.log(`清理图像小助手实例 | 键: ${key}`);
}
}
});
}
} catch (error) {
logger.error(`清理图像小助手实例失败 | ${error.message}`);
}
}
/**
* 清理单个实例的内部方法
* @param {object} assistant - 小助手实例
*/
_cleanupSingleInstance(assistant) {
if (!assistant) return;
// 标记实例为已销毁
assistant.isDestroyed = true;
try {
// 清理DOM元素
if (assistant.element && assistant.element.parentNode) {
assistant.element.parentNode.removeChild(assistant.element);
}
// 清理事件监听器
if (assistant._eventCleanupFunctions && Array.isArray(assistant._eventCleanupFunctions)) {
assistant._eventCleanupFunctions.forEach(cleanup => {
if (typeof cleanup === 'function') {
cleanup();
}
});
assistant._eventCleanupFunctions = [];
}
// 清理定时器
if (assistant._timers) {
Object.values(assistant._timers).forEach(timer => {
if (timer) {
clearTimeout(timer);
}
});
assistant._timers = {};
}
if (assistant._imageDetectionTimer) {
clearTimeout(assistant._imageDetectionTimer);
assistant._imageDetectionTimer = null;
}
// 清理引用(node 属性为动态 getter,无需手动清理)
assistant.element = null;
assistant.innerContent = null;
assistant.hoverArea = null;
assistant.indicator = null;
assistant.buttons = {};
} catch (error) {
logger.error(`清理单个实例失败 | ${error.message}`);
}
}
/**
* 统一控制总开关功能
*/
async toggleGlobalFeature(enable, force = false) {
// 更新状态
const oldValue = window.FEATURES.imageCaption;
window.FEATURES.imageCaption = enable;
// 状态未变化时不执行操作,除非force为true
if (!force && oldValue === enable) {
return;
}
// 仅当状态变化或强制执行时才记录日志
if (oldValue !== enable || force) {
logger.log(`图像反推功能 | 动作:${enable ? "启用" : "禁用"}`);
}
try {
if (enable) {
// === 启用图像反推功能 ===
// 确保管理器已初始化
if (!EventManager.initialized) {
EventManager.init();
}
// 1. 重置节点初始化标记,准备重新检测
if (app.canvas && app.canvas.graph) {
const nodes = app.canvas.graph._nodes || [];
nodes.forEach(node => {
if (node) {
node._imageCaptionInitialized = false;
}
});
// 2. 如果开启了自动创建,立即扫描所有有效节点
const icCreationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.ImageCaptionCreationMode") || "auto";
if (icCreationMode === "auto") {
nodes.forEach(node => {
if (node && this.hasValidImage(node) && !node._imageCaptionInitialized) {
node._imageCaptionInitialized = true;
this.checkAndSetupNode(node);
}
});
}
}
// 3. 设置或恢复节点选择事件监听
this.registerNodeSelectionListener();
// 4. 检查当前选中的节点
if (app.canvas && app.canvas.selected_nodes) {
app.canvas._imageCaptionSelectionHandler(app.canvas.selected_nodes);
}
} else {
// === 禁用图像反推功能 ===
// 1. 清理所有实例
this.cleanup(null, true);
}
// 更新按钮可见性
if (window.FEATURES.updateButtonsVisibility) {
window.FEATURES.updateButtonsVisibility();
}
} catch (error) {
logger.error(`图像小助手功能开关操作失败 | 错误: ${error.message}`);
}
}
/**
* 更新所有实例的预设尺寸
* 当功能开关或配置变更时调用,触发容器的常量布局计算逻辑
*/
updateAllInstancesWidth() {
logger.debug(`[图像小助手] 触发所有实例尺寸重算 | 实例数量: ${ImageCaption.instances.size}`);
ImageCaption.instances.forEach((assistant) => {
if (assistant && assistant.container && typeof assistant.container.updateDimensions === 'function') {
assistant.container.updateDimensions();
}
});
}
}
// 创建单例实例
const imageCaption = new ImageCaption();
// 导出
export { imageCaption, ImageCaption };