/** * 图像节点小助手类 * 用于检测和处理图像节点,提供图像反推功能 */ 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 };