2552 lines
99 KiB
JavaScript
2552 lines
99 KiB
JavaScript
/**
|
||
* 图像节点小助手类
|
||
* 用于检测和处理图像节点,提供图像反推功能
|
||
*/
|
||
|
||
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: `<span class="pi ${isCurrentModel ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}"></span>`,
|
||
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: `<span class="pi ${isCurrentService ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}"></span>`,
|
||
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: `<span class="pi ${prompt.isActive ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}"></span>`,
|
||
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: `<span class="pi ${hasActivePrompt ? 'pi-folder-open' : 'pi-folder'}"></span>`,
|
||
submenuAlign: 'center',
|
||
children: promptsInCategory.map(prompt => createRuleMenuItem(prompt))
|
||
});
|
||
});
|
||
|
||
|
||
// 添加规则管理选项
|
||
ruleMenuItems.push({ type: 'separator' });
|
||
ruleMenuItems.push({
|
||
label: '规则管理',
|
||
icon: '<span class="pi pi-pen-to-square"></span>',
|
||
onClick: () => {
|
||
rulesConfigManager.showRulesConfigModal();
|
||
}
|
||
});
|
||
|
||
return [
|
||
...ruleMenuItems,
|
||
// { type: 'separator' },
|
||
{
|
||
label: "选择服务",
|
||
icon: '<span class="pi pi-sparkles"></span>',
|
||
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: `<span class="pi ${isCurrentModel ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}"></span>`,
|
||
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: `<span class="pi ${isCurrentService ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}"></span>`,
|
||
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: `<span class="pi ${prompt.isActive ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}"></span>`,
|
||
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: `<span class="pi ${hasActivePrompt ? 'pi-folder-open' : 'pi-folder'}"></span>`,
|
||
submenuAlign: 'center',
|
||
children: promptsInCategory.map(prompt => createRuleMenuItem(prompt))
|
||
});
|
||
});
|
||
|
||
|
||
// 添加规则管理选项
|
||
ruleMenuItems.push({ type: 'separator' });
|
||
ruleMenuItems.push({
|
||
label: '规则管理',
|
||
icon: '<span class="pi pi-pen-to-square"></span>',
|
||
onClick: () => {
|
||
rulesConfigManager.showRulesConfigModal();
|
||
}
|
||
});
|
||
|
||
return [
|
||
...ruleMenuItems,
|
||
// { type: 'separator' },
|
||
{
|
||
label: "选择服务",
|
||
icon: '<span class="pi pi-sparkles"></span>',
|
||
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 }; |