3025 lines
128 KiB
JavaScript
3025 lines
128 KiB
JavaScript
/**
|
||
* 提示词小助手核心类
|
||
* 统一管理小助手的生命周期、实例创建、UI交互等功能
|
||
*/
|
||
|
||
import { app } from "../../../../scripts/app.js";
|
||
import { logger } from '../utils/logger.js';
|
||
import { FEATURES } from "../services/features.js";
|
||
import { HistoryManager } from "./history.js";
|
||
import { TagManager } from "./tag.js";
|
||
import { HistoryCacheService, TagCacheService, TranslateCacheService, CACHE_CONFIG, CacheService } from "../services/cache.js";
|
||
import { EventManager } from "../utils/eventManager.js";
|
||
import { ResourceManager } from "../utils/resourceManager.js";
|
||
import { UIToolkit } from "../utils/UIToolkit.js";
|
||
import { PromptFormatter } from "../utils/promptFormatter.js";
|
||
import { APIService } from "../services/api.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";
|
||
import { MarkdownNoteTranslate } from "../utils/markdownNoteTranslate.js";
|
||
|
||
|
||
|
||
// ====================== 工具函数 ======================
|
||
|
||
/**
|
||
* 计算小助手UI的预设宽度
|
||
* 根据当前启用的功能数量返回对应的固定宽度值
|
||
* @returns {number} 宽度值(像素)
|
||
*/
|
||
function calculateAssistantWidth() {
|
||
// 统计启用的功能
|
||
const hasHistory = window.FEATURES.history;
|
||
const hasTag = window.FEATURES.tag;
|
||
const hasExpand = window.FEATURES.expand;
|
||
const hasTranslate = window.FEATURES.translate;
|
||
|
||
// 统计非历史功能的数量
|
||
const otherFeaturesCount = [hasTag, hasExpand, hasTranslate].filter(Boolean).length;
|
||
|
||
// 根据功能组合返回预设常量宽度
|
||
if (hasHistory && otherFeaturesCount === 3) {
|
||
return 143; // 所有功能全开 (历史3 + 分隔线1 + 其它3)
|
||
} else if (hasHistory && otherFeaturesCount === 2) {
|
||
return 121; // 历史 + 两个其它
|
||
} else if (hasHistory && otherFeaturesCount === 1) {
|
||
return 99; // 历史 + 一个其它
|
||
} else if (hasHistory && otherFeaturesCount === 0) {
|
||
return 77; // 只有历史功能
|
||
} else if (!hasHistory && otherFeaturesCount === 3) {
|
||
return 72; // 关闭历史的三个功能
|
||
} else if (!hasHistory && otherFeaturesCount === 2) {
|
||
return 50; // 只有两个按钮
|
||
} else if (!hasHistory && otherFeaturesCount === 1) {
|
||
return 28; // 只有一个按钮
|
||
}
|
||
|
||
return 28; // 默认
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 防抖函数
|
||
* 限制函数调用频率,避免频繁触发导致性能问题
|
||
*/
|
||
function debounce(func, wait = 100) {
|
||
return EventManager.debounce(func, wait);
|
||
}
|
||
|
||
/**
|
||
* 获取输入元素的内容
|
||
* 支持普通textarea、Tiptap编辑器、ProseMirror编辑器等
|
||
* @param {object} widget - 小助手widget对象
|
||
* @returns {string} 输入内容
|
||
*/
|
||
function getInputValue(widget, options = {}) {
|
||
if (!widget || !widget.inputEl) {
|
||
return '';
|
||
}
|
||
|
||
const inputEl = widget.inputEl;
|
||
const returnHtml = options.html === true;
|
||
|
||
// 标准textarea
|
||
if (inputEl.tagName === 'TEXTAREA' && inputEl.value !== undefined) {
|
||
return inputEl.value;
|
||
}
|
||
|
||
// Tiptap/ProseMirror/comfy-markdown编辑器
|
||
if (inputEl.classList.contains('tiptap') ||
|
||
inputEl.classList.contains('ProseMirror') ||
|
||
inputEl.classList.contains('comfy-markdown')) {
|
||
|
||
let targetEl = inputEl;
|
||
// 对于comfy-markdown,查找内部编辑器元素
|
||
if (inputEl.classList.contains('comfy-markdown')) {
|
||
const editorEl = inputEl.querySelector('.tiptap, .ProseMirror');
|
||
if (editorEl) {
|
||
targetEl = editorEl;
|
||
}
|
||
}
|
||
|
||
if (returnHtml) {
|
||
return targetEl.innerHTML || '';
|
||
}
|
||
|
||
const textContent = targetEl.textContent || targetEl.innerText || '';
|
||
if (textContent.trim()) {
|
||
return textContent;
|
||
}
|
||
|
||
// 从widget.value获取
|
||
if (widget.value !== undefined) {
|
||
return widget.value;
|
||
}
|
||
|
||
// 从node.widgets找到对应的widget.value
|
||
if (widget.node && widget.node.widgets) {
|
||
const matchingWidget = widget.node.widgets.find(w =>
|
||
w.name === widget.inputId || w.name === 'text'
|
||
);
|
||
if (matchingWidget && matchingWidget.value !== undefined) {
|
||
return matchingWidget.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
// contenteditable元素
|
||
if (inputEl.isContentEditable || inputEl.getAttribute('contenteditable') === 'true') {
|
||
if (returnHtml) {
|
||
return inputEl.innerHTML || '';
|
||
}
|
||
return inputEl.textContent || inputEl.innerText || '';
|
||
}
|
||
|
||
// widget.value
|
||
if (widget.value !== undefined && typeof widget.value === 'string') {
|
||
return widget.value;
|
||
}
|
||
|
||
// inputWidget.value
|
||
if (widget.inputWidget && widget.inputWidget.value !== undefined) {
|
||
return widget.inputWidget.value;
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 设置输入元素的内容
|
||
* 支持普通textarea、Tiptap编辑器、ProseMirror编辑器等
|
||
* @param {object} widget - 小助手widget对象
|
||
* @param {string} content - 要设置的内容
|
||
* @param {object} options - 配置选项
|
||
* @param {boolean} options.html - 是否作为 HTML 内容设置
|
||
* @param {boolean} options.silent - 是否静默更新(不触发事件,用于流式输出)
|
||
* @returns {boolean} 是否设置成功
|
||
*/
|
||
function setInputValue(widget, content, options = {}) {
|
||
if (!widget || !widget.inputEl) {
|
||
return false;
|
||
}
|
||
|
||
const inputEl = widget.inputEl;
|
||
const useHtml = options.html === true;
|
||
const silent = options.silent === true; // 流式更新时不触发事件
|
||
|
||
try {
|
||
// 标准textarea
|
||
if (inputEl.tagName === 'TEXTAREA' && inputEl.value !== undefined) {
|
||
inputEl.value = content;
|
||
|
||
// 关键修复:即使是 silent 模式,也需要同步 widget.value 和 node.widgets[].value
|
||
// 否则后续 getInputValue 会读取到旧值
|
||
if (widget.value !== undefined) {
|
||
widget.value = content;
|
||
}
|
||
if (widget.node && widget.node.widgets) {
|
||
const matchingWidget = widget.node.widgets.find(w =>
|
||
w.name === widget.inputId || w.name === 'text'
|
||
);
|
||
if (matchingWidget) {
|
||
matchingWidget.value = content;
|
||
}
|
||
}
|
||
|
||
if (!silent) {
|
||
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||
inputEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// comfy-markdown或Tiptap/ProseMirror编辑器
|
||
if (inputEl.classList.contains('comfy-markdown') ||
|
||
inputEl.classList.contains('tiptap') ||
|
||
inputEl.classList.contains('ProseMirror')) {
|
||
|
||
// 对于comfy-markdown,找到内部编辑器
|
||
let targetEl = inputEl;
|
||
if (inputEl.classList.contains('comfy-markdown')) {
|
||
const editorEl = inputEl.querySelector('.tiptap, .ProseMirror');
|
||
if (editorEl) {
|
||
targetEl = editorEl;
|
||
}
|
||
}
|
||
|
||
// 设置textContent/innerHTML
|
||
if (targetEl.isContentEditable || targetEl.getAttribute('contenteditable') === 'true') {
|
||
if (useHtml) {
|
||
targetEl.innerHTML = content;
|
||
} else {
|
||
targetEl.textContent = content;
|
||
}
|
||
} else {
|
||
targetEl.innerHTML = content;
|
||
}
|
||
|
||
// 触发输入事件(静默模式下跳过)
|
||
if (!silent) {
|
||
targetEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||
targetEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
|
||
// 同时更新widget.value(无论是否 silent 都需要同步)
|
||
if (widget.value !== undefined) {
|
||
widget.value = content;
|
||
}
|
||
|
||
// 同时更新node.widgets[].value(无论是否 silent 都需要同步)
|
||
if (widget.node && widget.node.widgets) {
|
||
const matchingWidget = widget.node.widgets.find(w =>
|
||
w.name === widget.inputId || w.name === 'text'
|
||
);
|
||
if (matchingWidget) {
|
||
matchingWidget.value = content;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// contenteditable元素
|
||
if (inputEl.isContentEditable || inputEl.getAttribute('contenteditable') === 'true') {
|
||
if (useHtml) {
|
||
inputEl.innerHTML = content;
|
||
} else {
|
||
inputEl.textContent = content;
|
||
}
|
||
|
||
// 关键修复:同步 widget.value 和 node.widgets[].value
|
||
if (widget.value !== undefined) {
|
||
widget.value = content;
|
||
}
|
||
if (widget.node && widget.node.widgets) {
|
||
const matchingWidget = widget.node.widgets.find(w =>
|
||
w.name === widget.inputId || w.name === 'text'
|
||
);
|
||
if (matchingWidget) {
|
||
matchingWidget.value = content;
|
||
}
|
||
}
|
||
|
||
if (!silent) {
|
||
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||
inputEl.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// widget.value
|
||
if (widget.value !== undefined) {
|
||
widget.value = content;
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
} catch (error) {
|
||
logger.error(`[setInputValue] 设置失败 | 错误: ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ====================== 主类实现 ======================
|
||
|
||
/**
|
||
* 提示词小助手主类
|
||
* 统一管理小助手的生命周期、实例和资源
|
||
*/
|
||
class PromptAssistant {
|
||
/** 存储所有小助手实例的Map集合 */
|
||
static instances = new Map();
|
||
|
||
constructor() {
|
||
this.initialized = false;
|
||
}
|
||
|
||
// ---生命周期管理功能---
|
||
/**
|
||
* 判断功能是否被禁用
|
||
*/
|
||
areAllFeaturesDisabled() {
|
||
return !window.FEATURES.enabled;
|
||
}
|
||
|
||
/**
|
||
* 初始化提示词小助手
|
||
*/
|
||
initialize() {
|
||
if (this.initialized) return;
|
||
|
||
try {
|
||
// 检查版本号
|
||
if (!window.PromptAssistant_Version) {
|
||
logger.error("初始化时未找到版本号!这可能导致UI显示异常");
|
||
} else {
|
||
logger.debug(`初始化时检测到版本号: ${window.PromptAssistant_Version}`);
|
||
}
|
||
|
||
// 初始化事件管理器
|
||
EventManager.init();
|
||
|
||
// 从配置加载所有功能开关状态
|
||
FEATURES.loadSettings();
|
||
// 同步到 window.FEATURES 以兼容旧代码
|
||
window.FEATURES.enabled = FEATURES.enabled;
|
||
|
||
// 记录总开关状态(改为调试级别)
|
||
logger.debug(`初始化时检测总开关状态 | 状态:${FEATURES.enabled ? "启用" : "禁用"}`);
|
||
|
||
// 初始化资源管理器
|
||
ResourceManager.init();
|
||
|
||
// 只有在总开关打开时才做完整初始化
|
||
if (window.FEATURES.enabled) {
|
||
|
||
}
|
||
|
||
this.initialized = true;
|
||
logger.log("初始化完成 | 小助手已完全启动");
|
||
} catch (error) {
|
||
logger.error(`初始化失败 | 错误: ${error.message}`);
|
||
// 重置状态
|
||
this.initialized = false;
|
||
window.FEATURES.enabled = false;
|
||
// 确保清理
|
||
this.cleanup();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 统一控制总开关功能
|
||
* 集中管理所有受总开关控制的服务功能
|
||
*/
|
||
async toggleGlobalFeature(enable, force = false) {
|
||
// 更新状态
|
||
const oldValue = window.FEATURES.enabled;
|
||
window.FEATURES.enabled = enable;
|
||
|
||
// 状态未变化时不执行操作,除非force为true
|
||
if (!force && oldValue === enable) {
|
||
return;
|
||
}
|
||
|
||
// 仅当状态真正变化或强制执行时才记录日志
|
||
if (oldValue !== enable || force === true) {
|
||
logger.log(`总开关 | 动作:${enable ? "启用" : "禁用"}`);
|
||
}
|
||
|
||
try {
|
||
if (enable) {
|
||
// === 启用所有服务 ===
|
||
// 确保管理器已初始化
|
||
if (!EventManager.initialized) {
|
||
EventManager.init();
|
||
}
|
||
|
||
if (!ResourceManager.isInitialized()) {
|
||
ResourceManager.init();
|
||
}
|
||
|
||
// 1. 重置节点初始化标记,准备重新检测
|
||
if (app.canvas && app.canvas.graph) {
|
||
const nodes = app.canvas.graph._nodes || [];
|
||
nodes.forEach(node => {
|
||
if (node) {
|
||
node._promptAssistantInitialized = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 2. 设置或恢复节点选择事件监听
|
||
if (app.canvas) {
|
||
// 避免重复设置监听器
|
||
if (!app.canvas._promptAssistantSelectionHandler) {
|
||
app.canvas._promptAssistantSelectionHandler = function (selected_nodes) {
|
||
// 当总开关关闭时,跳过所有节点处理
|
||
if (!window.FEATURES.enabled) {
|
||
return;
|
||
}
|
||
|
||
if (selected_nodes && Object.keys(selected_nodes).length > 0) {
|
||
Object.keys(selected_nodes).forEach(nodeId => {
|
||
const node = app.canvas.graph.getNodeById(nodeId);
|
||
if (!node) return;
|
||
|
||
// 初始化未初始化的节点
|
||
if (!node._promptAssistantInitialized) {
|
||
node._promptAssistantInitialized = true;
|
||
this.checkAndSetupNode(node);
|
||
}
|
||
});
|
||
}
|
||
}.bind(this);
|
||
}
|
||
|
||
// 保存当前监听器并设置新的
|
||
if (app.canvas.onSelectionChange && app.canvas.onSelectionChange !== app.canvas._promptAssistantSelectionHandler) {
|
||
app.canvas._originalSelectionChange = app.canvas.onSelectionChange;
|
||
}
|
||
|
||
app.canvas.onSelectionChange = app.canvas._promptAssistantSelectionHandler;
|
||
|
||
// 3. 如果开启了自动创建,立即扫描所有有效节点
|
||
const creationMode = app.ui.settings.getSettingValue("PromptAssistant.Settings.CreationMode") || "auto";
|
||
if (creationMode === "auto") {
|
||
const nodes = app.canvas.graph._nodes || [];
|
||
nodes.forEach(node => {
|
||
if (node && !node._promptAssistantInitialized) {
|
||
// 避免在扫描过程中重复处理
|
||
node._promptAssistantInitialized = true;
|
||
this.checkAndSetupNode(node);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
// === 禁用所有服务 ===
|
||
|
||
// 1. 计数并清理所有实例
|
||
const instanceCount = PromptAssistant.instances.size;
|
||
this.cleanup(null, true);
|
||
|
||
// 2. 恢复原始节点选择事件监听
|
||
if (app.canvas) {
|
||
if (app.canvas._originalSelectionChange) {
|
||
app.canvas.onSelectionChange = app.canvas._originalSelectionChange;
|
||
} else {
|
||
app.canvas.onSelectionChange = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 按钮可见性更新在features中单独处理
|
||
window.FEATURES.updateButtonsVisibility();
|
||
|
||
|
||
} catch (error) {
|
||
logger.error(`总开关操作失败 | 错误: ${error.message}`);
|
||
// 恢复原始状态
|
||
window.FEATURES.enabled = oldValue;
|
||
}
|
||
}
|
||
|
||
// ---资源管理功能---
|
||
/**
|
||
* 清理所有资源
|
||
*/
|
||
cleanup(nodeId = null, silent = false) {
|
||
// 如果正在切换工作流,则只清理UI实例,不删除缓存
|
||
if (window.PROMPT_ASSISTANT_WORKFLOW_SWITCHING) {
|
||
// 简化日志:工作流切换期间不逐条打印节点清理日志,避免高频刷屏
|
||
// 如需排查问题,可将下行改回 debug 单条输出
|
||
// if (nodeId !== null) { logger.debug(`[清理跳过] 正在切换工作流,仅清理提示词小助手UI,节点ID: ${nodeId}`); }
|
||
|
||
const keysToDelete = Array.from(PromptAssistant.instances.keys())
|
||
.filter(key => nodeId === null || key.startsWith(`${String(nodeId)}_`));
|
||
|
||
keysToDelete.forEach(key => {
|
||
const instance = PromptAssistant.getInstance(key);
|
||
if (instance) {
|
||
this._cleanupInstance(instance, key, false); // false表示从实例集合中移除
|
||
}
|
||
});
|
||
|
||
// 如果是全局清理,清空实例集合
|
||
if (nodeId === null) {
|
||
PromptAssistant.instances.clear();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 检查nodeId是否有效
|
||
if (nodeId !== null && nodeId !== undefined) {
|
||
// 确保nodeId是字符串类型,便于后续比较
|
||
const nodeIdStr = String(nodeId);
|
||
|
||
// 获取当前节点的所有实例键
|
||
const keysToDelete = Array.from(PromptAssistant.instances.keys())
|
||
.filter(key => key === nodeIdStr || key.startsWith(`${nodeIdStr}_`));
|
||
|
||
// 如果有实例需要清理
|
||
if (keysToDelete.length > 0) {
|
||
let historyCount = 0;
|
||
let tagCount = 0;
|
||
let instanceNames = [];
|
||
|
||
try {
|
||
// 统计并清理历史记录
|
||
const allHistory = HistoryCacheService.getAllHistory();
|
||
historyCount = allHistory.filter(item => item.node_id === nodeId).length;
|
||
HistoryCacheService.clearNodeHistory(nodeId);
|
||
|
||
// 统计并清理标签缓存
|
||
keysToDelete.forEach(key => {
|
||
const instance = PromptAssistant.getInstance(key);
|
||
if (instance && instance.inputId) {
|
||
const tags = TagCacheService.getAllRawTags(nodeId, instance.inputId);
|
||
tagCount += tags ? tags.length : 0;
|
||
TagCacheService.clearCache(nodeId, instance.inputId);
|
||
instanceNames.push(instance.inputId);
|
||
}
|
||
});
|
||
|
||
// 清理实例
|
||
keysToDelete.forEach(key => {
|
||
const instance = PromptAssistant.getInstance(key);
|
||
if (instance) {
|
||
this._cleanupInstance(instance, key, true);
|
||
PromptAssistant.instances.delete(key);
|
||
}
|
||
});
|
||
|
||
if (!silent) {
|
||
// 获取当前剩余的统计信息
|
||
const remainingInstances = PromptAssistant.instances.size;
|
||
// 获取标签缓存统计
|
||
const tagStats = TagCacheService.getTagStats();
|
||
const remainingTags = tagStats.total;
|
||
const remainingHistory = HistoryCacheService.getAllHistory().length;
|
||
|
||
logger.log(`[清理汇总] 节点ID: ${nodeId} | 清理实例: ${instanceNames.join(', ')} | 历史记录清理: ${historyCount}条 | 标签缓存清理: ${tagCount}个`);
|
||
}
|
||
} catch (error) {
|
||
logger.error(`[节点清理] 失败 | 节点ID: ${nodeId} | 错误: ${error.message}`);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 清理所有实例和历史
|
||
const beforeCleanupSize = PromptAssistant.instances.size;
|
||
if (beforeCleanupSize > 0) {
|
||
let totalHistoryCount = 0;
|
||
let totalTagCount = 0;
|
||
let allInstanceNames = [];
|
||
|
||
try {
|
||
// 统计并清理所有历史记录
|
||
const allHistory = HistoryCacheService.getAllHistory();
|
||
totalHistoryCount = allHistory.length;
|
||
HistoryCacheService.clearAllHistory();
|
||
|
||
// 统计标签缓存
|
||
const tagStats = TagCacheService.getTagStats();
|
||
totalTagCount = tagStats.total;
|
||
|
||
// 清理所有标签缓存
|
||
TagCacheService.clearAllTagCache();
|
||
|
||
// 清理所有实例
|
||
for (const [key, instance] of PromptAssistant.instances) {
|
||
if (instance) {
|
||
allInstanceNames.push(instance.inputId || key);
|
||
this._cleanupInstance(instance, key, true);
|
||
}
|
||
}
|
||
|
||
// 清空实例集合
|
||
PromptAssistant.instances.clear();
|
||
|
||
if (!silent) {
|
||
logger.log(`[全局清理] 实例: ${allInstanceNames.join(', ')} | 历史: ${totalHistoryCount}条 | 标签: ${totalTagCount}个`);
|
||
logger.log(`[剩余统计] 小助手实例: 0个 | 标签缓存: 0个 | 节点历史缓存: 0条`);
|
||
}
|
||
} catch (error) {
|
||
logger.error(`[全局清理] 失败 | 错误: ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---节点类型检测工具---
|
||
|
||
/**
|
||
* 检查节点是否为使用comfy-markdown的节点
|
||
* 包括 Note、MarkdownNote、PreviewTextNode 等
|
||
* @param {object} node - 节点对象
|
||
* @returns {boolean}
|
||
*/
|
||
_isMarkdownNode(node) {
|
||
if (!node || !node.type) return false;
|
||
const markdownNodeTypes = ['Note', 'MarkdownNote', 'PreviewAny', 'PreviewTextNode'];
|
||
if (markdownNodeTypes.includes(node.type)) {
|
||
return true;
|
||
}
|
||
const typeLower = node.type.toLowerCase();
|
||
return typeLower.includes('markdown') ||
|
||
(typeLower.includes('preview') && typeLower.includes('text')) ||
|
||
typeLower.includes('subgraph'); // 增加对子图的基础判定支持
|
||
}
|
||
|
||
/**
|
||
* 检查节点是否为子图节点 (Subgraph)
|
||
* 子图节点的类型名为 UUID 格式
|
||
* @param {object} node - 节点对象
|
||
* @returns {boolean}
|
||
*/
|
||
_isSubgraphNode(node) {
|
||
if (!node || !node.type) return false;
|
||
// UUID 格式:8-4-4-4-12 字符
|
||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(node.type);
|
||
}
|
||
|
||
// ---实例管理功能---
|
||
/**
|
||
* 检查节点是否有效
|
||
* Vue mode下Note/MarkdownNote/Subgraph节点需要特殊处理
|
||
*/
|
||
static isValidNode(node) {
|
||
if (!node || typeof node.id === 'undefined' || node.id === -1) {
|
||
return false;
|
||
}
|
||
|
||
if (typeof node.type !== 'string') {
|
||
return false;
|
||
}
|
||
|
||
// Vue mode下的特殊节点类型(可能没有标准widgets属性)
|
||
const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true;
|
||
const vueSpecialNodeTypes = ['Note', 'MarkdownNote', 'PreviewAny', 'PreviewTextNode'];
|
||
|
||
// 检查是否为markdown类型节点
|
||
const isMarkdownNode = vueSpecialNodeTypes.includes(node.type) ||
|
||
(node.type && node.type.toLowerCase().includes('markdown')) ||
|
||
(node.type && node.type.toLowerCase().includes('preview') && node.type.toLowerCase().includes('text'));
|
||
|
||
// 检查是否为子图节点
|
||
// 1. UUID 格式类型名 (Node 2.0 动态创建)
|
||
const isUUIDType = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(node.type);
|
||
// 2. 原生 Subgraph 关键字或 workflow/ 前缀
|
||
const isSubgraphType = node.type === 'Subgraph' ||
|
||
node.type.startsWith('workflow/') ||
|
||
(node.constructor && node.constructor.name === 'Subgraph');
|
||
|
||
if (isVueMode && (isMarkdownNode || isUUIDType || isSubgraphType)) {
|
||
// Vue mode下这些节点类型直接视为有效
|
||
return true;
|
||
}
|
||
|
||
// 标准检查:需要有widgets属性
|
||
return !!node.widgets;
|
||
}
|
||
|
||
/**
|
||
* 添加实例到管理器
|
||
*/
|
||
static addInstance(nodeId, widget) {
|
||
if (nodeId != null && widget != null) {
|
||
this.instances.set(String(nodeId), widget);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 获取实例
|
||
*/
|
||
static getInstance(nodeId) {
|
||
if (nodeId == null) return null;
|
||
return this.instances.get(String(nodeId));
|
||
}
|
||
|
||
/**
|
||
* 检查实例是否存在
|
||
*/
|
||
static hasInstance(nodeId) {
|
||
if (nodeId == null) return false;
|
||
return this.instances.has(String(nodeId));
|
||
}
|
||
|
||
/**
|
||
* 检查节点并设置小助手
|
||
* 查找节点中的有效输入控件并创建小助手
|
||
*/
|
||
checkAndSetupNode(node) {
|
||
// 快速检查
|
||
if (!window.FEATURES.enabled || !node) return;
|
||
|
||
const isVueMode = LiteGraph.vueNodesMode === true;
|
||
|
||
|
||
|
||
// Vue mode下特殊节点(Note/Markdown/Subgraph)即使没有 LiteGraph widgets 也是有效的
|
||
if (!node.widgets) {
|
||
|
||
if (isVueMode && PromptAssistant.isValidNode(node)) {
|
||
this._handleVueDomScanNode(node);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 后续检查:如果虽然有 widgets 但不是我们识别的有效节点,也回退处理
|
||
const isValid = PromptAssistant.isValidNode(node);
|
||
if (!isValid) {
|
||
|
||
return;
|
||
}
|
||
|
||
// 获取所有有效的输入控件
|
||
const validInputs = node.widgets.filter(widget => {
|
||
if (!widget.node) widget.node = node;
|
||
const isValidInput = UIToolkit.isValidInput(widget, { debug: false, node: node });
|
||
|
||
return isValidInput;
|
||
});
|
||
|
||
|
||
|
||
if (validInputs.length === 0) {
|
||
// 非目标节点类型(如 LoadImage)没有文本控件是正常的,使用 debug 级别
|
||
logger.debug(`[checkAndSetupNode] 节点无有效控件 | ID: ${node.id} | 类型: ${node.type}`);
|
||
|
||
// Vue mode下节点可能暂时没有识别到 LiteGraph 控件,强制回退到 DOM 扫描模式
|
||
if (isVueMode && isValid) {
|
||
this._handleVueDomScanNode(node);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 为每个有效控件创建小助手
|
||
validInputs.forEach((inputWidget, widgetIndex) => {
|
||
const inputId = inputWidget.name || inputWidget.id;
|
||
|
||
// 生成唯一的 assistantKey
|
||
// 对于同名的多个输入框(如 Show Any 节点的列表输入),使用索引区分
|
||
let assistantKey = `${node.id}_${inputId}`;
|
||
|
||
// 检查是否存在同名的输入框,如果存在则使用索引或 DOM 元素的唯一标识
|
||
const sameNameWidgets = validInputs.filter(w => (w.name || w.id) === inputId);
|
||
if (sameNameWidgets.length > 1) {
|
||
// 多个同名输入框,使用索引或输入框元素的内存地址作为唯一标识
|
||
const inputEl = inputWidget.inputEl || inputWidget.element;
|
||
if (inputEl) {
|
||
// 为输入框元素添加唯一标识
|
||
if (!inputEl.dataset.promptAssistantUniqueId) {
|
||
inputEl.dataset.promptAssistantUniqueId = `${node.id}_${inputId}_${widgetIndex}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
assistantKey = inputEl.dataset.promptAssistantUniqueId;
|
||
} else {
|
||
// 降级方案:使用索引
|
||
assistantKey = `${node.id}_${inputId}_${widgetIndex}`;
|
||
}
|
||
}
|
||
|
||
// 检查实例是否已存在
|
||
if (PromptAssistant.hasInstance(assistantKey)) {
|
||
// 如果实例存在,检查输入控件是否已更新
|
||
const instance = PromptAssistant.getInstance(assistantKey);
|
||
const currentInputEl = inputWidget.inputEl;
|
||
const instanceInputEl = instance?.text_element;
|
||
|
||
// 只有在以下情况下才清理并重建:
|
||
// 1. 两个元素都存在
|
||
// 2. 它们是不同的元素
|
||
// 3. 不是因为临时的 DOM 状态变化(如打开弹窗)
|
||
if (instanceInputEl && currentInputEl && instanceInputEl !== currentInputEl) {
|
||
// 进一步检查:确保确实需要重建(避免误判)
|
||
// 如果当前元素已经从 DOM 中移除,才需要清理
|
||
if (!document.body.contains(instanceInputEl)) {
|
||
logger.debug(() => `[checkAndSetupNode] 输入元素已失效,清理实例 | 节点ID: ${node.id}`);
|
||
this.cleanup(node.id);
|
||
} else {
|
||
// 元素仍然在 DOM 中,可能只是引用变化,不需要清理
|
||
// logger.debug(() => `[checkAndSetupNode] 输入元素引用变化但仍有效,跳过清理 | 节点ID: ${node.id}`);
|
||
return;
|
||
}
|
||
} else if (!currentInputEl && instanceInputEl) {
|
||
// Vue 模式下,inputEl 可能暂时为 null,不应该触发清理
|
||
logger.debug(() => `[checkAndSetupNode] 当前inputEl为null,跳过清理(Vue模式下可能暂时为null) | 节点ID: ${node.id}`);
|
||
return;
|
||
} else {
|
||
// 实例存在且未更新,跳过
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 再次检查总开关状态,确保在创建过程中没有被禁用
|
||
if (!window.FEATURES.enabled) {
|
||
return;
|
||
}
|
||
|
||
// 【防重复挂载检查】在创建前检查 inputEl 是否已被其他实例挂载
|
||
const inputEl = inputWidget.inputEl || inputWidget.element;
|
||
if (inputEl && inputEl._promptAssistantMounted) {
|
||
return;
|
||
}
|
||
|
||
// 创建小助手实例
|
||
const assistant = this.setupNodeAssistant(node, inputWidget, assistantKey);
|
||
if (assistant) {
|
||
logger.debugSample(() => `[小助手] 创建实例 | 节点:${node.id} | 控件:${inputId} | 索引:${widgetIndex}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Vue mode 下对特殊或动态节点(Note/Subgraph等)的 DOM 扫描处理
|
||
* 当 LiteGraph widgets 尚未就绪时,直接从 DOM 中扫描 textarea 并挂载
|
||
*/
|
||
_handleVueDomScanNode(node) {
|
||
if (!node) return;
|
||
|
||
const isMarkdown = this._isMarkdownNode(node);
|
||
const isSubgraph = this._isSubgraphNode(node);
|
||
|
||
// 仅处理我们识别的有效节点
|
||
if (!isMarkdown && !isSubgraph) return;
|
||
|
||
const nodeId = node.id;
|
||
|
||
// 使用 NodeMountService 提供的逻辑,在 DOM 容器中查找所有潜在的输入框
|
||
const nodeContainer = document.querySelector(`[data-node-id="${nodeId}"]`);
|
||
if (!nodeContainer) {
|
||
// 如果容器还没渲染,则启动一次带重试的单次挂载尝试(针对主要输入框)
|
||
if (isMarkdown) {
|
||
this._retryDomScan(node, 'text');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 查找所有 textarea(优先找 PrimeVue 的 .p-textarea)
|
||
const primeTextareas = Array.from(nodeContainer.querySelectorAll('textarea.p-textarea'));
|
||
const textareas = primeTextareas.length > 0 ? primeTextareas : Array.from(nodeContainer.querySelectorAll('textarea'));
|
||
|
||
if (textareas.length === 0) {
|
||
// 可能是 TipTap 编辑器(针对 Note 节点)
|
||
const editor = nodeContainer.querySelector('.tiptap') || nodeContainer.querySelector('.ProseMirror');
|
||
if (editor) {
|
||
this._mountDomAssistant(node, editor, 'text', 0);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 遍历所有找到的 textarea 并尝试挂载
|
||
textareas.forEach((el, index) => {
|
||
// 生成 Key:对于 Note 节点通常只有一个,对于子图有多个
|
||
const inputId = textareas.length === 1 ? 'text' : `input_${index}`;
|
||
this._mountDomAssistant(node, el, inputId, index);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 执行实际的 DOM 挂载
|
||
*/
|
||
_mountDomAssistant(node, element, inputId, index) {
|
||
const assistantKey = `${node.id}_${inputId}`;
|
||
if (PromptAssistant.hasInstance(assistantKey)) return;
|
||
|
||
// 检查元素是否已被挂载
|
||
if (element._promptAssistantMounted) return;
|
||
|
||
// 创建虚拟 widget
|
||
const virtualWidget = {
|
||
name: inputId, id: inputId, type: 'textarea',
|
||
inputEl: element, element: element, node: node,
|
||
_domIndex: index // 记录 DOM 索引
|
||
};
|
||
|
||
const nodeInfo = {
|
||
workflow_id: app.graph?._workflow_id || 'unknown',
|
||
nodeType: node.type, inputType: 'text',
|
||
isNoteNode: this._isMarkdownNode(node),
|
||
isSubgraph: this._isSubgraphNode(node),
|
||
isVueMode: true,
|
||
domIndex: index
|
||
};
|
||
|
||
const assistant = this.createAssistant(node, inputId, virtualWidget, nodeInfo, assistantKey);
|
||
if (assistant) {
|
||
this.showAssistantUI(assistant);
|
||
logger.debugSample(() => `[DOM扫描] ${node.type}节点挂载成功 | ID: ${node.id} | Key: ${assistantKey}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 针对初始 DOM 未就绪的情况进行一次带重试的扫描
|
||
*/
|
||
_retryDomScan(node, inputId) {
|
||
const widgetStub = { name: inputId, node: node };
|
||
nodeMountService.findMountContainerWithRetry(node, widgetStub, { timeout: 2000 })
|
||
.then(result => {
|
||
if (result && result.textarea) {
|
||
this._mountDomAssistant(node, result.textarea, inputId, 0);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 为节点设置小助手
|
||
* 创建小助手实例并初始化显示状态
|
||
*/
|
||
setupNodeAssistant(node, inputWidget, assistantKey = null) {
|
||
|
||
|
||
// 简化参数检查
|
||
if (!node || !inputWidget) {
|
||
logger.error(`[setupNodeAssistant] 参数无效 | node: ${!!node} | inputWidget: ${!!inputWidget}`);
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const nodeId = node.id;
|
||
const inputId = inputWidget.name || inputWidget.id || Math.random().toString(36).substring(2, 10);
|
||
const isNoteNode = this._isMarkdownNode(node);
|
||
const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true;
|
||
|
||
|
||
|
||
// 简化节点信息
|
||
const nodeInfo = {
|
||
workflow_id: app.graph?._workflow_id || 'unknown',
|
||
nodeType: node.type,
|
||
inputType: inputId,
|
||
isNoteNode: isNoteNode,
|
||
isVueMode: isVueMode
|
||
};
|
||
|
||
// 处理inputWidget的inputEl引用
|
||
let processedWidget = inputWidget;
|
||
if (isNoteNode) {
|
||
const inputEl = inputWidget.element || inputWidget.inputEl;
|
||
processedWidget = {
|
||
...inputWidget,
|
||
inputEl: inputEl,
|
||
_needsDelayedTextareaLookup: isVueMode && !inputEl
|
||
};
|
||
|
||
} else {
|
||
|
||
}
|
||
|
||
// 创建小助手实例
|
||
|
||
const assistant = this.createAssistant(
|
||
node,
|
||
inputId,
|
||
processedWidget,
|
||
nodeInfo,
|
||
assistantKey
|
||
);
|
||
|
||
if (assistant) {
|
||
|
||
// 初始化显示状态
|
||
// 初始化显示状态
|
||
this.showAssistantUI(assistant);
|
||
return assistant;
|
||
} else {
|
||
console.warn(`[setupNodeAssistant] ⚠️ createAssistant 返回 null`);
|
||
}
|
||
|
||
return null;
|
||
} catch (error) {
|
||
logger.error(`[setupNodeAssistant] ❌ 异常 | 节点: ${node.id} | 错误:`, error);
|
||
logger.error(`创建小助手失败 | 节点ID: ${node.id} | 原因: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建小助手实例
|
||
* 根据节点和输入控件构建小助手对象并初始化UI
|
||
*/
|
||
createAssistant(node, inputId, inputWidget, nodeInfo = {}, assistantKey = null) {
|
||
|
||
|
||
// 简化前置检查
|
||
if (!window.FEATURES.enabled || !node || !inputId || !inputWidget) {
|
||
logger.error(`[createAssistant] ❌ 前置检查失败 | enabled: ${window.FEATURES.enabled} | node: ${!!node} | inputId: ${inputId} | inputWidget: ${!!inputWidget}`);
|
||
return null;
|
||
}
|
||
|
||
|
||
// 确保widget设置了node引用
|
||
if (!inputWidget.node) {
|
||
inputWidget.node = node;
|
||
}
|
||
|
||
// 验证是否为有效输入
|
||
|
||
if (!UIToolkit.isValidInput(inputWidget, { node: node })) {
|
||
console.warn(`[createAssistant] ⚠️ 输入无效 | 节点: ${node?.id} | 控件: ${inputId}`);
|
||
return null;
|
||
}
|
||
|
||
|
||
// 获取输入元素
|
||
let inputEl = inputWidget.inputEl || inputWidget.element;
|
||
const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true;
|
||
|
||
|
||
|
||
// 非Vue mode下,inputEl必须存在
|
||
if (!inputEl && !isVueMode) {
|
||
logger.error(`[createAssistant] ❌ 非Vue模式下inputEl不存在 | 节点: ${node?.id}`);
|
||
return null;
|
||
}
|
||
|
||
const nodeId = node.id;
|
||
const widgetKey = assistantKey || `${nodeId}_${inputId}`;
|
||
|
||
|
||
|
||
// 检查是否已存在实例
|
||
if (PromptAssistant.hasInstance(widgetKey)) {
|
||
|
||
return PromptAssistant.getInstance(widgetKey);
|
||
}
|
||
|
||
|
||
|
||
// 创建小助手对象
|
||
const widget = {
|
||
type: "prompt_assistant",
|
||
name: inputId,
|
||
nodeId,
|
||
inputId,
|
||
widgetKey,
|
||
buttons: {},
|
||
text_element: inputEl,
|
||
inputEl: inputEl,
|
||
isDestroyed: false,
|
||
nodeInfo: {
|
||
...nodeInfo,
|
||
nodeId: nodeId,
|
||
nodeType: node.type,
|
||
isVueMode: isVueMode
|
||
},
|
||
isTransitioning: false,
|
||
// 保存初始节点引用作为后备(Vue Node 2.0 子图切换场景)
|
||
_initialNode: node
|
||
};
|
||
|
||
// 动态获取节点的 getter,避免持有已删除节点的引用
|
||
// 【修复】优先从 graph 获取,失败时回退到初始引用(解决子图切换时画布未同步问题)
|
||
Object.defineProperty(widget, '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
|
||
});
|
||
|
||
|
||
|
||
// 创建全局输入框映射
|
||
if (!window.PromptAssistantInputWidgetMap) {
|
||
window.PromptAssistantInputWidgetMap = {};
|
||
}
|
||
|
||
window.PromptAssistantInputWidgetMap[widgetKey] = {
|
||
inputEl: inputEl,
|
||
widget: widget
|
||
};
|
||
|
||
|
||
|
||
// 创建UI并添加到实例集合
|
||
this.createAssistantUI(widget, inputWidget);
|
||
|
||
PromptAssistant.addInstance(widgetKey, widget);
|
||
|
||
|
||
|
||
// 初始化绑定
|
||
if (inputEl) {
|
||
this._initializeInputElBindings(widget, inputWidget, node, inputId, nodeInfo);
|
||
} else {
|
||
|
||
}
|
||
|
||
|
||
return widget;
|
||
}
|
||
|
||
/**
|
||
* 初始化inputEl相关的事件绑定
|
||
* 在传统模式下立即调用,Vue mode下在找到textarea后调用
|
||
*/
|
||
_initializeInputElBindings(widget, inputWidget, node, inputId, nodeInfo) {
|
||
const inputEl = inputWidget.inputEl || widget.inputEl;
|
||
if (!inputEl) {
|
||
logger.warn(`[_initializeInputElBindings] inputEl不存在 | 节点ID: ${node?.id}`);
|
||
return;
|
||
}
|
||
|
||
const nodeId = node.id;
|
||
|
||
// 初始化撤销状态(只初始化一次,使用widget级别的标记)
|
||
if (!widget._undoStateInitialized) {
|
||
const initialValue = inputEl.value || '';
|
||
// 如果初始值不为空,则直接添加到历史记录中,确保可以撤销回初始状态
|
||
if (initialValue.trim()) {
|
||
HistoryCacheService.addHistoryAndUpdateUndoState(nodeId, inputId, initialValue, 'input');
|
||
} else {
|
||
HistoryCacheService.initUndoState(nodeId, inputId, initialValue);
|
||
}
|
||
widget._undoStateInitialized = true;
|
||
}
|
||
// 初始化时立即更新撤销/重做按钮状态
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
|
||
// 检查是否已绑定事件(避免重复绑定)
|
||
// 【关键修复】使用 widget 级别的标记来精确控制绑定状态
|
||
// 确保不会因为 _eventCleanupFunctions 中包含其他清理函数(如按钮菜单)而误判
|
||
if (widget._inputEventsBound) {
|
||
logger.debug(`[_initializeInputElBindings] 跳过绑定 | 节点ID: ${nodeId} | 原因: 已绑定`);
|
||
return;
|
||
}
|
||
|
||
// 如果检测到遗留标记,静默处理
|
||
|
||
inputEl._promptAssistantBound = true;
|
||
widget._inputEventsBound = true;
|
||
widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
|
||
|
||
// 绑定输入框失焦事件,写入历史
|
||
// 使用事件管理器添加DOM事件监听
|
||
const removeBlurListener = EventManager.addDOMListener(inputEl, 'blur', async () => {
|
||
// logger.debug(`历史写入准备| 原因:失焦事件触发 node_id=${node.id} input_id=${inputId}`);
|
||
HistoryCacheService.addHistory({
|
||
workflow_id: nodeInfo?.workflow_id || '',
|
||
node_id: node.id,
|
||
input_id: inputId,
|
||
content: inputEl.value,
|
||
operation_type: 'input',
|
||
timestamp: Date.now()
|
||
});
|
||
// 重置撤销状态
|
||
HistoryCacheService.initUndoState(node.id, inputId, inputEl.value);
|
||
// 更新按钮状态
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
// logger.debug(`历史写入完成|原因:输入框失焦 node_id=${node.id} input_id=${inputId}`);
|
||
});
|
||
|
||
// 保存清理函数引用,以便后续清理
|
||
widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
|
||
widget._eventCleanupFunctions.push(removeBlurListener);
|
||
|
||
// 添加输入事件监听,实时更新撤销/重做按钮状态和位置调整
|
||
const removeInputListener = EventManager.addDOMListener(inputEl, 'input', () => {
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
// 检测滚动条状态并调整位置
|
||
this._adjustPositionForScrollbar(widget, inputEl);
|
||
});
|
||
widget._eventCleanupFunctions.push(removeInputListener);
|
||
|
||
// 添加ResizeObserver监听输入框尺寸变化
|
||
if (window.ResizeObserver) {
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
// 延迟执行,确保浏览器完成布局更新
|
||
setTimeout(() => {
|
||
this._adjustPositionForScrollbar(widget, inputEl);
|
||
}, 10);
|
||
});
|
||
|
||
resizeObserver.observe(inputEl);
|
||
|
||
// 添加清理函数
|
||
widget._eventCleanupFunctions.push(() => {
|
||
resizeObserver.disconnect();
|
||
});
|
||
} else {
|
||
// 降级方案:监听window resize事件
|
||
const removeResizeListener = EventManager.addDOMListener(window, 'resize',
|
||
EventManager.debounce(() => {
|
||
this._adjustPositionForScrollbar(widget, inputEl);
|
||
}, 100)
|
||
);
|
||
widget._eventCleanupFunctions.push(removeResizeListener);
|
||
}
|
||
}
|
||
|
||
// ---UI管理功能---
|
||
/**
|
||
* 创建小助手UI
|
||
* 构建DOM元素并设置事件监听和初始样式
|
||
*/
|
||
createAssistantUI(widget, inputWidget) {
|
||
const nodeId = widget.nodeId;
|
||
const inputId = widget.inputId;
|
||
|
||
|
||
|
||
try {
|
||
|
||
// Get location setting
|
||
const locationSetting = app.ui.settings.getSettingValue(
|
||
"PromptAssistant.Location"
|
||
);
|
||
|
||
|
||
|
||
// Create AssistantContainer instance
|
||
const container = new AssistantContainer({
|
||
nodeId: nodeId,
|
||
type: 'prompt',
|
||
anchorPosition: locationSetting,
|
||
enableDragSort: true,
|
||
onButtonOrderChange: (order) => {
|
||
logger.debug(`[排序更新] 节点:${nodeId} | 新顺序: ${order.join(',')}`);
|
||
},
|
||
shouldCollapse: () => {
|
||
return !this._checkAssistantActiveState(widget);
|
||
}
|
||
});
|
||
|
||
|
||
|
||
// Render container
|
||
const containerEl = container.render();
|
||
|
||
|
||
|
||
// Set Icon
|
||
const mainIcon = ResourceManager.getIcon('icon-main.svg');
|
||
if (mainIcon) {
|
||
if (container.indicator) {
|
||
container.indicator.innerHTML = '';
|
||
container.indicator.appendChild(mainIcon);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// Save references
|
||
widget.container = container;
|
||
widget.element = containerEl;
|
||
widget.innerContent = container.content;
|
||
widget.hoverArea = container.hoverArea;
|
||
widget.indicator = container.indicator;
|
||
widget.buttons = {};
|
||
|
||
Object.defineProperty(widget, 'isCollapsed', {
|
||
get: () => container.isCollapsed,
|
||
set: (val) => {
|
||
if (val) container.collapse(); else container.expand();
|
||
}
|
||
});
|
||
Object.defineProperty(widget, 'isTransitioning', {
|
||
get: () => container.isTransitioning,
|
||
set: (val) => { container.isTransitioning = val; }
|
||
});
|
||
|
||
|
||
|
||
// Initialize buttons
|
||
this.addFunctionButtons(widget);
|
||
|
||
|
||
|
||
// Restore button order
|
||
container.restoreOrder();
|
||
|
||
|
||
|
||
// Setup Positioning
|
||
const inputEl = inputWidget.inputEl || widget.inputEl;
|
||
const graphCanvasContainer = document.querySelector('.graphcanvas');
|
||
const canvasContainerRect = graphCanvasContainer?.getBoundingClientRect();
|
||
|
||
|
||
|
||
|
||
this._setupUIPosition(widget, inputEl, containerEl, canvasContainerRect, (success) => {
|
||
|
||
if (!success) {
|
||
logger.debugSample(() => `[小助手] 创建暂缓 | 节点ID: ${nodeId} | 原因: 定位容器未就绪 (等待DOM渲染)`);
|
||
container.destroy();
|
||
const widgetKey = widget.widgetKey;
|
||
if (widgetKey && PromptAssistant.instances.has(widgetKey)) {
|
||
PromptAssistant.instances.delete(widgetKey);
|
||
}
|
||
if (window.PromptAssistantInputWidgetMap && widgetKey) {
|
||
delete window.PromptAssistantInputWidgetMap[widgetKey];
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 定位成功后更新尺寸
|
||
container.updateDimensions();
|
||
});
|
||
|
||
return containerEl;
|
||
} catch (error) {
|
||
console.error(`[createAssistantUI] ❌ 异常 | 节点: ${nodeId} | 错误:`, error);
|
||
logger.error(`创建小助手失败 | 节点ID: ${nodeId} | 原因: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示小助手UI
|
||
* 控制UI显示动画和状态,创建时直接以折叠状态显示
|
||
*/
|
||
showAssistantUI(widget, forceAnimation = false) {
|
||
if (!widget?.element) return;
|
||
|
||
// 避免重复显示
|
||
if (widget.element.classList.contains('assistant-show')) {
|
||
// 确保元素可见
|
||
widget.element.style.display = 'flex';
|
||
widget.element.style.opacity = '1';
|
||
return;
|
||
}
|
||
|
||
// 直接显示,无动画过渡
|
||
widget.element.style.opacity = '1';
|
||
widget.element.style.display = 'flex';
|
||
widget.element.classList.add('assistant-show');
|
||
|
||
// 确保悬停区域可见(用于折叠状态下的交互)
|
||
if (widget.isCollapsed && widget.hoverArea) {
|
||
widget.hoverArea.style.display = 'block';
|
||
}
|
||
|
||
// 重置过渡状态
|
||
widget.isTransitioning = false;
|
||
|
||
// 只有当明确不是折叠状态时才触发自动折叠
|
||
if (!widget.isCollapsed) {
|
||
this.triggerAutoCollapse(widget);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查并触发自动折叠(如果需要)
|
||
*/
|
||
_triggerAutoCollapseIfNeeded(widget) {
|
||
if (widget && widget.container) {
|
||
widget.container.collapse();
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
/**
|
||
* 展开小助手
|
||
*/
|
||
_expandAssistant(widget) {
|
||
if (widget && widget.container) {
|
||
widget.container.expand();
|
||
}
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 公开方法:触发小助手自动折叠
|
||
* 供外部模块调用,用于在操作完成后折叠小助手UI
|
||
*/
|
||
triggerAutoCollapse(widget) {
|
||
return this._triggerAutoCollapseIfNeeded(widget);
|
||
}
|
||
|
||
/**
|
||
* 更新小助手可见性
|
||
* 始终显示小助手,不再根据鼠标悬停状态来决定
|
||
*/
|
||
updateAssistantVisibility(widget) {
|
||
if (!widget) return;
|
||
|
||
// 总开关关闭时不处理可见性更新
|
||
if (!window.FEATURES || !window.FEATURES.enabled) {
|
||
return;
|
||
}
|
||
|
||
// 检查是否有按钮处于激活或处理中状态
|
||
const hasActiveButtons = this._checkAssistantActiveState(widget);
|
||
|
||
// 如果有激活的按钮,强制显示小助手(带动画)并取消自动折叠
|
||
if (hasActiveButtons) {
|
||
this.showAssistantUI(widget, true);
|
||
|
||
// 取消可能的自动折叠定时器
|
||
if (widget._autoCollapseTimer) {
|
||
clearTimeout(widget._autoCollapseTimer);
|
||
widget._autoCollapseTimer = null;
|
||
}
|
||
|
||
// 如果当前是折叠状态,则展开 - 使用requestAnimationFrame
|
||
if (widget.isCollapsed) {
|
||
requestAnimationFrame(() => {
|
||
this._expandAssistant(widget);
|
||
});
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// 始终显示小助手,不再检查鼠标状态
|
||
const isCurrentlyShown = widget.element?.classList.contains('assistant-show');
|
||
if (!isCurrentlyShown) {
|
||
this.showAssistantUI(widget, false);
|
||
logger.debug(`UI显示 | 节点:${widget.nodeId} | 原因:始终显示`);
|
||
} else {
|
||
// 已经显示,检查是否需要自动折叠
|
||
this.triggerAutoCollapse(widget);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查小助手是否有按钮处于激活状态
|
||
*/
|
||
_checkAssistantActiveState(widget) {
|
||
if (!widget || !widget.buttons) return false;
|
||
|
||
// 0. 检查是否正在切换弹窗(切换期间不允许折叠)
|
||
if (PopupManager._isTransitioning) {
|
||
return true;
|
||
}
|
||
|
||
// 1. 检查右键菜单是否可见(并且属于当前 widget)
|
||
if (buttonMenu.isMenuVisible && buttonMenu.menuContext?.widget === widget) {
|
||
return true;
|
||
}
|
||
|
||
// 2. 检查中央按钮状态管理器是否有该widget的激活按钮
|
||
const activeButtonInfo = UIToolkit.getActiveButtonInfo();
|
||
if (activeButtonInfo && activeButtonInfo.widget === widget) {
|
||
return true;
|
||
}
|
||
|
||
// 3. 检查 PopupManager 的活动弹窗是否属于当前 widget
|
||
if (PopupManager.activePopupInfo?.buttonInfo?.widget === widget) {
|
||
return true;
|
||
}
|
||
|
||
// 4. 检查按钮的 active/processing 状态
|
||
for (const buttonId in widget.buttons) {
|
||
const button = widget.buttons[buttonId];
|
||
if (button.classList.contains('button-active') ||
|
||
button.classList.contains('button-processing')) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 更新所有实例的可见性
|
||
* 在按钮状态变化时调用
|
||
*/
|
||
updateAllInstancesVisibility() {
|
||
PromptAssistant.instances.forEach(widget => {
|
||
this.updateAssistantVisibility(widget);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 更新所有实例的预设宽度
|
||
* 在功能开关变更时调用,重新计算并设置宽度
|
||
*/
|
||
updateAllInstancesWidth() {
|
||
// 优化:不再手动计算宽度并注入,而是触发每个容器自身的常量布局逻辑
|
||
logger.debug(`[布局更新] 触发所有实例尺寸重算 | 实例数量:${PromptAssistant.instances.size}`);
|
||
|
||
PromptAssistant.instances.forEach((widget) => {
|
||
if (widget && widget.container && typeof widget.container.updateDimensions === 'function') {
|
||
widget.container.updateDimensions();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 显示状态提示
|
||
* 创建临时提示信息气泡
|
||
*/
|
||
showStatusTip(anchorElement, type, message, position = null) {
|
||
return UIToolkit.showStatusTip(anchorElement, type, message, position);
|
||
}
|
||
|
||
// ---事件处理功能---
|
||
/**
|
||
* 设置UI事件处理
|
||
* 配置按钮事件监听 - 简化版本
|
||
*/
|
||
_setupUIEventHandling(widget, inputEl, containerDiv) {
|
||
// 事件处理已委托给 AssistantContainer
|
||
// 我们保留此方法是为了兼容外部调用,但现在它不执行任何操作。
|
||
}
|
||
|
||
|
||
|
||
// ---辅助功能---
|
||
/**
|
||
* 更新输入框内容并添加高亮效果
|
||
*/
|
||
updateInputWithHighlight(widget, content, options = {}) {
|
||
if (!widget?.inputEl) return;
|
||
|
||
try {
|
||
// 更新输入框内容 - 使用统一的setInputValue函数
|
||
const success = setInputValue(widget, content, options);
|
||
|
||
if (!success) {
|
||
logger.warn(`输入框更新 | 结果:失败 | setInputValue返回false`);
|
||
return;
|
||
}
|
||
|
||
// 使用统一的高亮工具方法 (处理了定时器管理和重绘)
|
||
UIToolkit._highlightInput(widget.inputEl);
|
||
} catch (error) {
|
||
logger.error(`输入框更新 | 结果:异常 | 错误:${error.message}`);
|
||
}
|
||
}
|
||
|
||
// ---按钮管理功能---
|
||
/**
|
||
* 添加功能按钮
|
||
*/
|
||
addFunctionButtons(widget) {
|
||
if (!widget?.element) {
|
||
logger.error('添加按钮 | 结果:失败 | 原因: 容器不存在');
|
||
return;
|
||
}
|
||
|
||
// 检查总开关状态
|
||
if (!FEATURES.enabled) {
|
||
logger.debug('添加按钮 | 结果:跳过 | 原因: 总功能已禁用');
|
||
return;
|
||
}
|
||
|
||
// 检查是否有至少一个功能启用
|
||
const hasEnabledFeatures = FEATURES.history || FEATURES.tag || FEATURES.expand || FEATURES.translate;
|
||
if (!hasEnabledFeatures) {
|
||
logger.debug('添加按钮 | 结果:跳过 | 原因: 没有启用任何功能');
|
||
return;
|
||
}
|
||
|
||
// 检查是否是Note/MarkdownNote节点
|
||
const isNoteNode = widget.nodeInfo && (widget.nodeInfo.isNoteNode === true || widget.nodeInfo.nodeType === 'MarkdownNote');
|
||
|
||
// 获取历史状态(用于初始化撤销/重做按钮状态)
|
||
const canUndo = HistoryCacheService.canUndo(widget.nodeId, widget.inputId);
|
||
const canRedo = HistoryCacheService.canRedo(widget.nodeId, widget.inputId);
|
||
|
||
// 按钮配置
|
||
const buttonConfigs = [
|
||
{
|
||
id: 'history',
|
||
title: '历史',
|
||
icon: 'icon-history',
|
||
onClick: (e, widget) => {
|
||
UIToolkit.handlePopupButtonClick(
|
||
e,
|
||
widget,
|
||
'history',
|
||
HistoryManager.showHistoryPopup.bind(HistoryManager),
|
||
HistoryManager.hideHistoryPopup.bind(HistoryManager)
|
||
);
|
||
},
|
||
visible: !isNoteNode && FEATURES.history, // Note节点不显示此按钮
|
||
initialState: { disabled: false }
|
||
},
|
||
{
|
||
id: 'undo',
|
||
title: '撤销',
|
||
icon: 'icon-undo',
|
||
onClick: (e, widget) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
logger.debug('按钮点击 | 动作: 撤销');
|
||
|
||
// 执行撤销操作
|
||
const undoContent = HistoryCacheService.undo(widget.nodeId, widget.inputId);
|
||
if (undoContent !== null) {
|
||
// 更新输入框内容并添加高亮效果
|
||
this.updateInputWithHighlight(widget, undoContent);
|
||
|
||
// 更新按钮状态
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
|
||
logger.debug(`撤销操作 | 结果:成功 | 节点:${widget.nodeId}`);
|
||
} else {
|
||
logger.debug(`撤销操作 | 结果:失败 | 节点:${widget.nodeId} | 原因:无可用内容`);
|
||
}
|
||
},
|
||
visible: !isNoteNode && FEATURES.history,
|
||
initialState: { disabled: !canUndo }
|
||
},
|
||
{
|
||
id: 'redo',
|
||
title: '重做',
|
||
icon: 'icon-redo',
|
||
onClick: (e, widget) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
logger.debug('按钮点击 | 动作: 重做');
|
||
|
||
// 执行重做操作
|
||
const redoContent = HistoryCacheService.redo(widget.nodeId, widget.inputId);
|
||
if (redoContent !== null) {
|
||
// 更新输入框内容并添加高亮效果
|
||
this.updateInputWithHighlight(widget, redoContent);
|
||
|
||
// 更新按钮状态
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
|
||
logger.debug(`重做操作 | 结果:成功 | 节点:${widget.nodeId}`);
|
||
} else {
|
||
logger.debug(`重做操作 | 结果:失败 | 节点:${widget.nodeId} | 原因:无可用内容`);
|
||
}
|
||
},
|
||
visible: !isNoteNode && FEATURES.history,
|
||
initialState: { disabled: !canRedo }
|
||
},
|
||
{
|
||
id: 'divider1',
|
||
type: 'divider',
|
||
visible: !isNoteNode && FEATURES.history // Note节点不显示,且跟随历史功能开关
|
||
},
|
||
{
|
||
id: 'tag',
|
||
title: '标签工具',
|
||
icon: 'icon-tag',
|
||
onClick: (e, widget) => {
|
||
// 创建一个带有标签选择功能的显示函数
|
||
const showTagPopup = (options) => {
|
||
// 处理标签选择功能
|
||
const enhancedOptions = {
|
||
...options,
|
||
onTagSelect: (tag) => {
|
||
// 获取当前输入框的值和光标位置
|
||
const currentValue = widget.inputEl.value;
|
||
const cursorPos = widget.inputEl.selectionStart;
|
||
const beforeText = currentValue.substring(0, cursorPos);
|
||
const afterText = currentValue.substring(widget.inputEl.selectionEnd);
|
||
|
||
// 添加标签(英文值)
|
||
const newValue = beforeText + tag.en + afterText;
|
||
|
||
// 更新输入框内容并添加高亮效果
|
||
this.updateInputWithHighlight(widget, newValue);
|
||
|
||
// 更新光标位置
|
||
const newPos = cursorPos + tag.en.length;
|
||
widget.inputEl.setSelectionRange(newPos, newPos);
|
||
|
||
// 保持焦点在输入框
|
||
widget.inputEl.focus();
|
||
}
|
||
};
|
||
|
||
// 调用标签管理器显示弹窗
|
||
TagManager.showTagPopup(enhancedOptions);
|
||
};
|
||
|
||
// 使用统一的弹窗按钮点击处理
|
||
UIToolkit.handlePopupButtonClick(
|
||
e,
|
||
widget,
|
||
'tag',
|
||
showTagPopup,
|
||
TagManager.hideTagPopup.bind(TagManager)
|
||
);
|
||
},
|
||
visible: !isNoteNode && FEATURES.tag // Note节点不显示此按钮
|
||
},
|
||
{
|
||
id: 'expand',
|
||
title: '提示词优化',
|
||
icon: 'icon-expand',
|
||
onClick: async (e, widget) => {
|
||
logger.debug('按钮点击 | 动作: 提示词优化');
|
||
|
||
// 如果按钮处于 processing 状态且被点击,直接返回,
|
||
// 让UIToolkit中的取消逻辑接管
|
||
if (e.currentTarget.classList.contains('button-processing')) {
|
||
return;
|
||
}
|
||
|
||
await UIToolkit.handleAsyncButtonOperation(
|
||
widget,
|
||
'expand',
|
||
e.currentTarget,
|
||
async (notifyCancelReady) => {
|
||
try {
|
||
// 获取输入值 - 使用统一的getInputValue函数
|
||
const inputValue = getInputValue(widget);
|
||
logger.debug(`[提示词优化] 获取到的输入值长度: ${inputValue?.length || 0}`);
|
||
|
||
if (!inputValue || inputValue.trim() === '') {
|
||
throw new Error('请输入要优化的提示词');
|
||
}
|
||
|
||
// 生成唯一request_id
|
||
const request_id = APIService.generateRequestId('exp', null, widget.nodeId);
|
||
|
||
// 通知UI可以准备取消操作了
|
||
notifyCancelReady(request_id);
|
||
|
||
// 根据开关选择流式或阻塞式 API
|
||
let result;
|
||
let streamContent = '';
|
||
|
||
if (FEATURES.enableStreaming !== false) {
|
||
// 显示流式优化中提示
|
||
const btnRect = e.currentTarget.getBoundingClientRect();
|
||
UIToolkit.showStatusTip(
|
||
e.currentTarget,
|
||
'loading',
|
||
'提示词优化中',
|
||
{ x: btnRect.left + btnRect.width / 2, y: btnRect.top }
|
||
);
|
||
|
||
result = await APIService.llmExpandPromptStream(
|
||
inputValue,
|
||
request_id,
|
||
(chunk) => {
|
||
// 流式回调:实时更新输入框内容
|
||
streamContent += chunk;
|
||
// 使用 setInputValue 更新输入框(不触发事件,避免频繁抖动)
|
||
setInputValue(widget, streamContent, { silent: true });
|
||
}
|
||
);
|
||
} else {
|
||
// 显示阻塞式优化中提示
|
||
const btnRect = e.currentTarget.getBoundingClientRect();
|
||
UIToolkit.showStatusTip(
|
||
e.currentTarget,
|
||
'loading',
|
||
'提示词优化中',
|
||
{ x: btnRect.left + btnRect.width / 2, y: btnRect.top }
|
||
);
|
||
|
||
result = await APIService.llmExpandPrompt(inputValue, request_id);
|
||
}
|
||
|
||
// 流式完成后,获取最终内容
|
||
const finalContent = streamContent || result?.data?.expanded || '';
|
||
|
||
if (result && result.success && finalContent) {
|
||
// 最终更新(触发事件和高亮)
|
||
this.updateInputWithHighlight(widget, finalContent);
|
||
|
||
// 添加扩写结果到历史记录(只记录最终结果)
|
||
HistoryCacheService.addHistory({
|
||
workflow_id: widget.nodeInfo?.workflow_id || '',
|
||
node_id: widget.nodeId,
|
||
input_id: widget.inputId,
|
||
content: finalContent,
|
||
operation_type: 'expand',
|
||
request_id: request_id,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// 重置撤销状态
|
||
HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, finalContent);
|
||
|
||
// 更新按钮状态
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
|
||
return {
|
||
success: true,
|
||
useCache: false,
|
||
tipType: 'success',
|
||
tipMessage: '提示词优化完成'
|
||
};
|
||
} else {
|
||
// 不在这里显示错误提示,直接抛出错误让 handleAsyncButtonOperation 处理
|
||
throw new Error(result?.error || '扩写失败');
|
||
}
|
||
} catch (error) {
|
||
// 不在这里显示错误提示,直接抛出错误让 handleAsyncButtonOperation 处理
|
||
throw error;
|
||
}
|
||
}
|
||
);
|
||
},
|
||
visible: !isNoteNode && FEATURES.expand, // Note节点不显示此按钮
|
||
// 添加右键菜单配置
|
||
contextMenu: async (widget) => {
|
||
// 获取服务列表和当前激活状态
|
||
let services = [];
|
||
let currentLLMService = null;
|
||
let currentLLMModel = null;
|
||
|
||
// 获取扩写规则
|
||
let activePromptId = null;
|
||
let expandPrompts = [];
|
||
|
||
try {
|
||
// 获取服务列表
|
||
const servicesResp = await fetch(APIService.getApiUrl('/services'));
|
||
if (servicesResp.ok) {
|
||
const servicesData = await servicesResp.json();
|
||
if (servicesData.success) {
|
||
services = servicesData.services || [];
|
||
}
|
||
}
|
||
|
||
// 获取当前激活的LLM服务和模型
|
||
const llmResp = await fetch(APIService.getApiUrl('/config/llm'));
|
||
if (llmResp.ok) {
|
||
const llmConfig = await llmResp.json();
|
||
currentLLMService = llmConfig.provider || null;
|
||
currentLLMModel = llmConfig.model || null;
|
||
}
|
||
|
||
// 获取扩写规则
|
||
const response = await fetch(APIService.getApiUrl('/config/system_prompts'));
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
activePromptId = data.active_prompts?.expand || null;
|
||
|
||
if (data.expand_prompts) {
|
||
const originalOrder = Object.keys(data.expand_prompts);
|
||
originalOrder.forEach(key => {
|
||
const prompt = data.expand_prompts[key];
|
||
const showIn = prompt.showIn || ['frontend', 'node'];
|
||
|
||
// 仅当配置包含 'frontend' 时才在前端菜单显示
|
||
if (showIn.includes('frontend')) {
|
||
expandPrompts.push({
|
||
id: key,
|
||
name: prompt.name || key,
|
||
category: prompt.category || '',
|
||
content: prompt.content,
|
||
showIn: showIn,
|
||
isActive: key === activePromptId
|
||
});
|
||
}
|
||
});
|
||
expandPrompts.sort((a, b) =>
|
||
originalOrder.indexOf(a.id) - originalOrder.indexOf(b.id)
|
||
);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error(`获取提示词优化配置失败: ${error.message}`);
|
||
}
|
||
|
||
// 创建服务菜单项(只显示有LLM模型的服务,不包括百度)
|
||
const serviceMenuItems = services
|
||
.filter(service => service.llm_models && service.llm_models.length > 0)
|
||
.map(service => {
|
||
const isCurrentService = currentLLMService === service.id;
|
||
|
||
// 创建模型子菜单
|
||
const modelChildren = (service.llm_models || []).map(model => {
|
||
const isCurrentModel = isCurrentService && currentLLMModel === 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: 'llm', 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: 'llm', 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) => {
|
||
logger.log(`右键菜单 | 动作: 切换提示词优化 | ID: ${prompt.id}`);
|
||
try {
|
||
const response = await fetch(APIService.getApiUrl('/config/active_prompt'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ type: 'expand', 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 = expandPrompts.filter(p => !p.category);
|
||
const categorizedPrompts = expandPrompts.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
|
||
}
|
||
];
|
||
}
|
||
},
|
||
{
|
||
id: 'translate',
|
||
title: '翻译',
|
||
icon: 'icon-translate',
|
||
onClick: async (e, widget) => {
|
||
logger.debug('按钮点击 | 动作: 翻译');
|
||
|
||
// 如果按钮处于 processing 状态且被点击,直接返回,
|
||
// 让UIToolkit中的取消逻辑接管
|
||
if (e.currentTarget.classList.contains('button-processing')) {
|
||
return;
|
||
}
|
||
|
||
await UIToolkit.handleAsyncButtonOperation(
|
||
widget,
|
||
'translate',
|
||
e.currentTarget,
|
||
async (notifyCancelReady) => {
|
||
try {
|
||
// --- Markdown LiteGraph 模式处理 ---
|
||
// 增强判断逻辑:除了检查nodeType,也检查DOM类名
|
||
const hasMarkdownClass = widget.inputEl?.classList?.contains('comfy-markdown');
|
||
const isMarkdownLiteGraph = (widget.nodeInfo?.nodeType === 'MarkdownNote' || hasMarkdownClass) &&
|
||
widget.nodeInfo?.isVueMode !== true;
|
||
|
||
logger.debug(`[翻译调试] Markdown检测: ${isMarkdownLiteGraph} (Type: ${widget.nodeInfo?.nodeType}, HasClass: ${hasMarkdownClass})`);
|
||
|
||
// 获取输入值 - 根据模式决定是否获取HTML
|
||
const inputValue = getInputValue(widget, { html: isMarkdownLiteGraph });
|
||
|
||
if (!inputValue || inputValue.trim() === '') {
|
||
throw new Error('请输入要翻译的内容');
|
||
}
|
||
|
||
let contentToTranslate = inputValue;
|
||
let mdData = null;
|
||
|
||
if (isMarkdownLiteGraph) {
|
||
mdData = MarkdownNoteTranslate.protectAndExtract(inputValue);
|
||
if (mdData.texts && mdData.texts.length > 0) {
|
||
contentToTranslate = mdData.texts.join('\n');
|
||
} else {
|
||
// 如果提取后没有文本(只有标签/代码),则认为空或者无需翻译
|
||
if (!contentToTranslate || contentToTranslate.trim() === '') {
|
||
// 保持原样或抛出错误,这里选择抛出提示
|
||
throw new Error('没有检测到可翻译的内容');
|
||
}
|
||
// 如果原内容有东西但提取为空,可能全是代码块,保留原内容作为待翻译(实际上API可能跳过)
|
||
// 或者这里 contentToTranslate 为 inputValue ?
|
||
// 不,protectAndExtract 没提取到,说明不该翻译。
|
||
// 但为了流程继续,如果不抛错,我们假设 contentToTranslate 为空导致后续报错
|
||
}
|
||
}
|
||
|
||
if (!contentToTranslate || contentToTranslate.trim() === '') {
|
||
throw new Error('请输入要翻译的内容');
|
||
}
|
||
|
||
// 显示翻译中提示
|
||
const btnRect = e.currentTarget.getBoundingClientRect();
|
||
UIToolkit.showStatusTip(
|
||
e.currentTarget,
|
||
'loading',
|
||
'翻译中',
|
||
{ x: btnRect.left + btnRect.width / 2, y: btnRect.top }
|
||
);
|
||
|
||
// 1. 查询缓存
|
||
let cacheResult = null;
|
||
if (FEATURES.useTranslateCache) {
|
||
cacheResult = TranslateCacheService.queryTranslateCache(contentToTranslate);
|
||
}
|
||
|
||
if (cacheResult) {
|
||
let rawResultText = '';
|
||
let tipMessage = '';
|
||
let useCache = true;
|
||
|
||
// 根据缓存匹配类型处理
|
||
if (cacheResult.type === 'source') {
|
||
// 命中原文,返回译文
|
||
rawResultText = cacheResult.translatedText;
|
||
tipMessage = '译文';
|
||
} else if (cacheResult.type === 'translated') {
|
||
// 命中译文,返回原文
|
||
rawResultText = cacheResult.sourceText;
|
||
tipMessage = '原文';
|
||
}
|
||
|
||
// 处理 Markdown 格式还原
|
||
let finalResultText = rawResultText;
|
||
if (isMarkdownLiteGraph && mdData) {
|
||
const translatedSegments = rawResultText.split('\n');
|
||
finalResultText = MarkdownNoteTranslate.restoreWithTranslations(mdData.placeholderHTML, mdData.placeholders, translatedSegments);
|
||
}
|
||
|
||
// 更新输入框内容并添加高亮效果
|
||
this.updateInputWithHighlight(widget, finalResultText, { html: isMarkdownLiteGraph });
|
||
|
||
// 添加翻译结果到历史记录
|
||
HistoryCacheService.addHistory({
|
||
workflow_id: widget.nodeInfo?.workflow_id || '',
|
||
node_id: widget.nodeId,
|
||
input_id: widget.inputId,
|
||
content: finalResultText,
|
||
operation_type: 'translate',
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// 重置撤销状态
|
||
HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, finalResultText);
|
||
|
||
// 更新按钮状态
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
|
||
return {
|
||
success: true,
|
||
useCache: useCache,
|
||
tipType: 'info',
|
||
tipMessage: tipMessage,
|
||
buttonElement: e.currentTarget // 传递按钮元素
|
||
};
|
||
}
|
||
|
||
// 缓存未命中,使用API翻译
|
||
|
||
// 生成唯一request_id
|
||
const request_id = APIService.generateRequestId('trans', null, widget.nodeId);
|
||
|
||
// 通知UI可以准备取消操作了
|
||
notifyCancelReady(request_id);
|
||
|
||
// 检测语言 (使用提取后的文本)
|
||
const langResult = PromptFormatter.detectLanguage(contentToTranslate);
|
||
|
||
// 获取翻译服务配置
|
||
let result;
|
||
let streamContent = ''; // 用于流式收集内容
|
||
try {
|
||
// 获取翻译配置
|
||
const configResp = await fetch(APIService.getApiUrl('/config/translate'));
|
||
let isBaidu = false;
|
||
|
||
if (configResp.ok) {
|
||
const config = await configResp.json();
|
||
// 检查provider是否为'baidu'
|
||
if (config.provider === 'baidu') {
|
||
isBaidu = true;
|
||
}
|
||
}
|
||
|
||
if (isBaidu) {
|
||
// 百度翻译不支持流式,使用原有接口(自动降级)
|
||
result = await APIService.baiduTranslate(
|
||
contentToTranslate,
|
||
langResult.from,
|
||
langResult.to,
|
||
request_id
|
||
);
|
||
} else if (FEATURES.enableStreaming !== false) {
|
||
// ---流式输出:LLM翻译使用流式 API---
|
||
result = await APIService.llmTranslateStream(
|
||
contentToTranslate,
|
||
langResult.from,
|
||
langResult.to,
|
||
request_id,
|
||
(chunk) => {
|
||
// 流式回调:实时更新输入框内容
|
||
streamContent += chunk;
|
||
// 使用 silent 模式更新,避免频繁触发事件
|
||
setInputValue(widget, streamContent, { silent: true, html: isMarkdownLiteGraph });
|
||
}
|
||
);
|
||
} else {
|
||
// ---阻塞输出:LLM翻译使用普通 API---
|
||
result = await APIService.llmTranslate(
|
||
contentToTranslate,
|
||
langResult.from,
|
||
langResult.to,
|
||
request_id
|
||
);
|
||
}
|
||
|
||
if (!result) {
|
||
throw new Error('翻译服务返回空结果');
|
||
}
|
||
} catch (error) {
|
||
logger.error(`翻译失败 | 错误:${error.message}`);
|
||
throw new Error(`翻译失败: ${error.message}`);
|
||
}
|
||
|
||
if (result.success) {
|
||
// 格式化翻译结果(优先使用流式收集的内容,否则使用API返回的内容)
|
||
const rawTranslated = streamContent || result.data?.translated || '';
|
||
const formattedText = PromptFormatter.formatTranslatedText(rawTranslated);
|
||
|
||
// 处理 Markdown 格式还原
|
||
let finalResultText = formattedText;
|
||
if (isMarkdownLiteGraph && mdData) {
|
||
const translatedSegments = formattedText.split('\n');
|
||
finalResultText = MarkdownNoteTranslate.restoreWithTranslations(mdData.placeholderHTML, mdData.placeholders, translatedSegments);
|
||
}
|
||
|
||
// 添加翻译结果到历史记录
|
||
HistoryCacheService.addHistory({
|
||
workflow_id: widget.nodeInfo?.workflow_id || '',
|
||
node_id: widget.nodeId,
|
||
input_id: widget.inputId,
|
||
content: finalResultText,
|
||
operation_type: 'translate',
|
||
request_id: request_id,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// 更新输入框内容并添加高亮效果
|
||
this.updateInputWithHighlight(widget, finalResultText, { html: isMarkdownLiteGraph });
|
||
|
||
// 重置撤销状态
|
||
HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, finalResultText);
|
||
|
||
// 更新按钮状态
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
|
||
// 只有开启缓存时才写入缓存 (使用提取文本和翻译后的片段文本,以便下次能复用)
|
||
if (FEATURES.useTranslateCache) {
|
||
// 检查是否是混合语言
|
||
const isMixedLang = PromptFormatter.isMixedChineseEnglish(contentToTranslate);
|
||
|
||
// 只有当不是混合语言,或者用户允许缓存混合语言时才写入缓存
|
||
if (!isMixedLang || FEATURES.cacheMixedLangTranslation) {
|
||
TranslateCacheService.addTranslateCache(contentToTranslate, formattedText);
|
||
} else {
|
||
logger.debug(`翻译缓存 | 跳过:混合语言内容`);
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
useCache: false,
|
||
tipType: 'success',
|
||
tipMessage: '翻译完成'
|
||
};
|
||
} else {
|
||
// 不在这里显示错误提示,直接抛出错误让 handleAsyncButtonOperation 处理
|
||
throw new Error(result.error);
|
||
}
|
||
} catch (error) {
|
||
// 不在这里显示错误提示,直接抛出错误让 handleAsyncButtonOperation 处理
|
||
throw error;
|
||
}
|
||
}
|
||
);
|
||
},
|
||
visible: FEATURES.translate, // Note节点只显示此按钮
|
||
// 添加右键菜单配置
|
||
contextMenu: async (widget) => {
|
||
const useTranslateCache = app.ui.settings.getSettingValue("PromptAssistant.Features.UseTranslateCache");
|
||
|
||
// 获取所有服务列表和当前激活状态
|
||
let services = [];
|
||
let currentTranslateService = null;
|
||
let currentTranslateModel = null;
|
||
|
||
try {
|
||
// 获取服务列表
|
||
const servicesResp = await fetch(APIService.getApiUrl('/services'));
|
||
if (servicesResp.ok) {
|
||
const servicesData = await servicesResp.json();
|
||
if (servicesData.success) {
|
||
services = servicesData.services || [];
|
||
}
|
||
}
|
||
|
||
// 获取当前激活的翻译服务和模型
|
||
const translateResp = await fetch(APIService.getApiUrl('/config/translate'));
|
||
if (translateResp.ok) {
|
||
const translateConfig = await translateResp.json();
|
||
currentTranslateService = translateConfig.provider || null;
|
||
currentTranslateModel = translateConfig.model || null;
|
||
}
|
||
} catch (e) {
|
||
logger.error(`获取服务列表失败: ${e.message}`);
|
||
}
|
||
|
||
// 创建服务菜单项
|
||
const serviceMenuItems = [];
|
||
|
||
// 百度翻译项(永远显示在第一位)
|
||
const isBaiduCurrent = currentTranslateService === 'baidu';
|
||
serviceMenuItems.push({
|
||
label: '百度翻译',
|
||
icon: `<span class="pi ${isBaiduCurrent ? '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: 'translate', service_id: 'baidu' })
|
||
});
|
||
if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
|
||
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: 百度翻译`);
|
||
logger.log(`翻译服务切换 | 服务: 百度翻译`);
|
||
|
||
// 派发全局事件通知其他组件同步
|
||
window.dispatchEvent(new CustomEvent('pa-service-changed', {
|
||
detail: { service_type: 'translate', service_id: 'baidu' }
|
||
}));
|
||
} catch (err) {
|
||
logger.error(`切换翻译服务失败: ${err.message}`);
|
||
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 动态添加其他LLM服务
|
||
const otherServiceMenuItems = services
|
||
.filter(service => service.llm_models && service.llm_models.length > 0)
|
||
.map(service => {
|
||
const isCurrentService = currentTranslateService === service.id;
|
||
|
||
// 创建模型子菜单
|
||
const modelChildren = (service.llm_models || []).map(model => {
|
||
const isCurrentModel = isCurrentService && currentTranslateModel === 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: 'translate', 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}`);
|
||
|
||
// 派发全局事件通知其他组件同步
|
||
window.dispatchEvent(new CustomEvent('pa-service-changed', {
|
||
detail: { service_type: 'translate', service_id: service.id, model_name: model.name }
|
||
}));
|
||
} 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: 'translate', service_id: service.id })
|
||
});
|
||
if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
|
||
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${service.name}`);
|
||
logger.log(`翻译服务切换 | 服务: ${service.name}`);
|
||
|
||
// 派发全局事件通知其他组件同步
|
||
window.dispatchEvent(new CustomEvent('pa-service-changed', {
|
||
detail: { service_type: 'translate', service_id: service.id }
|
||
}));
|
||
} catch (err) {
|
||
logger.error(`切换翻译服务失败: ${err.message}`);
|
||
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
|
||
}
|
||
},
|
||
children: modelChildren.length > 0 ? modelChildren : undefined
|
||
};
|
||
});
|
||
|
||
// 将其他服务添加到serviceMenuItems
|
||
serviceMenuItems.push(...otherServiceMenuItems);
|
||
|
||
return [
|
||
{
|
||
label: "选择服务",
|
||
icon: '<span class="pi pi-sparkles"></span>',
|
||
children: serviceMenuItems
|
||
},
|
||
{ type: 'separator' },
|
||
{
|
||
label: "翻译缓存",
|
||
icon: `<span class="pi ${useTranslateCache ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}"></span>`,
|
||
onClick: (context) => {
|
||
const newStatus = !useTranslateCache;
|
||
app.ui.settings.setSettingValue("PromptAssistant.Features.UseTranslateCache", newStatus);
|
||
const statusText = newStatus ? '已开启' : '已关闭';
|
||
logger.log(`右键菜单 | 动作: 切换翻译缓存 | 状态: ${statusText}`);
|
||
UIToolkit.showStatusTip(context.buttonElement, 'success', `翻译缓存${statusText}`);
|
||
}
|
||
}
|
||
];
|
||
}
|
||
},
|
||
];
|
||
|
||
// 记录添加的按钮
|
||
let historyButtons = [];
|
||
let otherButtons = [];
|
||
let divider = null;
|
||
|
||
// ---Add buttons to AssistantContainer---
|
||
for (const config of buttonConfigs) {
|
||
if (config.type === 'divider') {
|
||
// Check visibility for divider
|
||
if (config.visible === false) continue;
|
||
|
||
const divider = document.createElement('div');
|
||
divider.className = 'prompt-assistant-divider';
|
||
// Add divider to container
|
||
widget.container.addButton(divider, config.id || `divider_${Date.now()}`);
|
||
// Save reference if needed
|
||
if (config.id) widget.buttons[config.id] = divider;
|
||
continue;
|
||
}
|
||
|
||
// Check visibility
|
||
if (config.visible === false) continue;
|
||
|
||
// Create button using existing helper
|
||
// Note: addButtonWithIcon returns the button element and saves it to widget.buttons
|
||
const button = this.addButtonWithIcon(widget, config);
|
||
if (!button) continue;
|
||
|
||
// Set initial state
|
||
if (config.initialState) {
|
||
Object.entries(config.initialState).forEach(([stateType, value]) => {
|
||
UIToolkit.setButtonState(widget, config.id, stateType, value);
|
||
});
|
||
}
|
||
|
||
// Add to container
|
||
widget.container.addButton(button, config.id);
|
||
}
|
||
|
||
|
||
}
|
||
|
||
/**
|
||
* 添加带图标的按钮
|
||
*/
|
||
addButtonWithIcon(widget, config) {
|
||
if (!widget?.element || !widget?.innerContent) return null;
|
||
|
||
const { id, title, icon, onClick, contextMenu } = config;
|
||
|
||
// 创建按钮
|
||
const button = document.createElement('button');
|
||
button.className = 'prompt-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, widget);
|
||
});
|
||
}
|
||
|
||
// 添加右键菜单(如果有)
|
||
if (contextMenu && typeof contextMenu === 'function') {
|
||
this._setupButtonContextMenu(button, contextMenu, widget);
|
||
}
|
||
|
||
// 保存引用
|
||
if (id) {
|
||
widget.buttons[id] = button;
|
||
}
|
||
|
||
return button;
|
||
}
|
||
|
||
/**
|
||
* 检测输入框是否有滚动条
|
||
* @param {HTMLElement} inputEl - 输入框元素
|
||
* @returns {boolean} 是否有垂直滚动条
|
||
*/
|
||
_detectScrollbar(inputEl) {
|
||
if (!inputEl || inputEl.tagName !== 'TEXTAREA') {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// 检查垂直滚动条:scrollHeight > clientHeight
|
||
const hasVerticalScrollbar = inputEl.scrollHeight > inputEl.clientHeight;
|
||
// 日志简化:详细滚动条检测日志移至 _adjustPositionForScrollbar,并仅在状态变更时输出
|
||
return hasVerticalScrollbar;
|
||
} catch (error) {
|
||
logger.error(`[滚动条检测] 检测失败 | 错误: ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据滚动条状态调整小助手位置
|
||
* @param {Object} widget - 小助手实例
|
||
* @param {HTMLElement} inputEl - 输入框元素
|
||
* @param {Boolean} forceUpdate - 是否强制更新(用于初始化)
|
||
*/
|
||
_adjustPositionForScrollbar(widget, inputEl, forceUpdate = false) {
|
||
if (!widget?.element || !inputEl) return;
|
||
|
||
const hasScrollbar = this._detectScrollbar(inputEl);
|
||
const containerDiv = widget.element;
|
||
|
||
// 仅在滚动条状态发生变化时更新位置(除非强制更新)
|
||
const prevState = containerDiv.dataset.hasScrollbar === 'true';
|
||
if (!forceUpdate && prevState === hasScrollbar) {
|
||
return; // 状态未变,不做任何操作
|
||
}
|
||
|
||
// 【关键修复】在位置/布局调整前,显式移除输入框的高亮状态
|
||
// 防止浏览器在重排(Relayout)过程中产生动画残留
|
||
UIToolkit.removeHighlight(inputEl);
|
||
|
||
containerDiv.dataset.hasScrollbar = String(hasScrollbar);
|
||
|
||
// 有滚动条时向左偏移,避开滚动条
|
||
const rightOffset = hasScrollbar ? '16px' : '4px';
|
||
containerDiv.style.right = rightOffset;
|
||
}
|
||
|
||
/**
|
||
* 设置UI位置
|
||
* 支持 Vue node2.0 和 litegraph.js 两种渲染模式
|
||
* @param {Function} onComplete - 定位完成回调,接收boolean参数,true表示成功,false表示失败
|
||
*/
|
||
_setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect, onComplete) {
|
||
|
||
|
||
// 清理函数列表
|
||
widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
|
||
|
||
// 【关键修复】直接使用 widget.node 而不是通过 app.graph.getNodeById 查找
|
||
// 因为进入子图后 app.graph 已经切换到子图的 graph,无法找到主画布节点
|
||
const node = widget.node;
|
||
if (!node) {
|
||
logger.debug(`[定位] widget.node 不存在 | ID: ${widget.nodeId}`);
|
||
if (onComplete) onComplete(false);
|
||
return;
|
||
}
|
||
|
||
|
||
// 创建widget对象用于容器查找
|
||
const widgetObj = {
|
||
inputEl: inputEl,
|
||
element: inputEl,
|
||
name: widget.inputId,
|
||
id: widget.inputId
|
||
};
|
||
|
||
// 使用 NodeMountService 进行带重试的容器查找
|
||
// Vue mode下需要更多重试次数和更长间隔
|
||
const isVueMode = typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true;
|
||
nodeMountService.findMountContainerWithRetry(node, widgetObj, {
|
||
maxRetries: isVueMode ? 5 : 3,
|
||
retryInterval: isVueMode ? 800 : 500
|
||
}).then(containerInfo => {
|
||
if (!containerInfo) {
|
||
// logger.debug(`[定位] 容器查找失败 | 节点ID: ${widget.nodeId}`);
|
||
if (onComplete) onComplete(false);
|
||
return;
|
||
}
|
||
|
||
// 根据渲染模式应用不同的定位策略
|
||
if (containerInfo.mode === RENDER_MODE.VUE_NODES) {
|
||
this._applyVueNodesPositioning(widget, containerDiv, containerInfo);
|
||
} else {
|
||
this._applyLitegraphPositioning(widget, containerDiv, containerInfo);
|
||
}
|
||
|
||
// 保存渲染模式到widget,用于后续调整
|
||
widget._renderMode = containerInfo.mode;
|
||
|
||
// 触发回流确保样式生效
|
||
void containerDiv.offsetWidth;
|
||
|
||
// 最终成功日志保持精简
|
||
logger.debug(`[定位] 成功 | ID: ${widget.nodeId} | 模式: ${containerInfo.mode} | 锚点: ${widget.container?.anchorPosition}`);
|
||
if (onComplete) onComplete(true);
|
||
|
||
}).catch(error => {
|
||
logger.error(`[定位] 异常 | 节点ID: ${widget.nodeId} | 错误: ${error.message}`);
|
||
if (onComplete) onComplete(false);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Vue node2.0 模式下的定位逻辑
|
||
*/
|
||
_applyVueNodesPositioning(widget, containerDiv, containerInfo) {
|
||
let { container, textarea, nodeContainer, isNoteNode } = containerInfo;
|
||
|
||
// 【特殊处理】Note节点在Vue mode下可能需要二次查找textarea
|
||
if (!textarea && isNoteNode && nodeContainer) {
|
||
const textareas = nodeContainer.querySelectorAll('textarea');
|
||
if (textareas.length > 0) {
|
||
textarea = textareas[0];
|
||
container = textarea.parentElement;
|
||
} else {
|
||
logger.warn(`[Vue定位] Note节点仍未找到textarea | 节点ID: ${widget.nodeId}`);
|
||
}
|
||
}
|
||
|
||
// 定期更新输入框引用及事件绑定
|
||
if (textarea && textarea !== widget.inputEl) {
|
||
widget.inputEl = textarea;
|
||
widget.text_element = textarea;
|
||
if (window.PromptAssistantInputWidgetMap && window.PromptAssistantInputWidgetMap[widget.widgetKey]) {
|
||
window.PromptAssistantInputWidgetMap[widget.widgetKey].inputEl = textarea;
|
||
}
|
||
|
||
if (!textarea._promptAssistantBound) {
|
||
textarea._promptAssistantBound = true;
|
||
widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
|
||
|
||
widget._eventCleanupFunctions.push(EventManager.addDOMListener(textarea, 'blur', async () => {
|
||
HistoryCacheService.addHistory({
|
||
workflow_id: widget.nodeInfo?.workflow_id || '',
|
||
node_id: widget.nodeId,
|
||
input_id: widget.inputId,
|
||
content: textarea.value,
|
||
operation_type: 'input',
|
||
timestamp: Date.now()
|
||
});
|
||
HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, textarea.value);
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
}));
|
||
|
||
widget._eventCleanupFunctions.push(EventManager.addDOMListener(textarea, 'input', () => {
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
this._adjustPositionForScrollbar(widget, textarea);
|
||
}));
|
||
|
||
if (window.ResizeObserver) {
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
setTimeout(() => this._adjustPositionForScrollbar(widget, textarea), 10);
|
||
});
|
||
resizeObserver.observe(textarea);
|
||
widget._eventCleanupFunctions.push(() => resizeObserver.disconnect());
|
||
}
|
||
|
||
if (!widget._undoStateInitialized) {
|
||
HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, textarea.value);
|
||
widget._undoStateInitialized = true;
|
||
}
|
||
}
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
}
|
||
|
||
// 防重复挂载检查
|
||
if (textarea && textarea._promptAssistantMounted && textarea._promptAssistantWidgetKey !== widget.widgetKey) {
|
||
this._cleanupRedundantWidget(widget);
|
||
return;
|
||
}
|
||
|
||
const existingAssistant = container.querySelector('.assistant-container-common');
|
||
if (existingAssistant && !container.contains(containerDiv)) {
|
||
this._cleanupRedundantWidget(widget);
|
||
return;
|
||
}
|
||
|
||
if (textarea) {
|
||
textarea._promptAssistantMounted = true;
|
||
textarea._promptAssistantWidgetKey = widget.widgetKey;
|
||
}
|
||
|
||
containerDiv.style.position = 'absolute';
|
||
containerDiv.style.zIndex = '10';
|
||
if (window.getComputedStyle(container).position === 'static') {
|
||
container.style.position = 'relative';
|
||
}
|
||
|
||
containerDiv.classList.add('vue-node-mode');
|
||
if (!container.contains(containerDiv)) {
|
||
container.appendChild(containerDiv);
|
||
}
|
||
|
||
if (textarea) {
|
||
requestAnimationFrame(() => this._adjustPositionForScrollbar(widget, textarea, true));
|
||
setTimeout(() => this._adjustPositionForScrollbar(widget, textarea, true), 150);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理冗余的 Widget 实例(当由于并发原因导致重复创建时)
|
||
* @private
|
||
*/
|
||
_cleanupRedundantWidget(widget) {
|
||
if (widget.widgetKey && PromptAssistant.instances.has(widget.widgetKey)) {
|
||
PromptAssistant.instances.delete(widget.widgetKey);
|
||
}
|
||
if (widget.container) {
|
||
widget.container.destroy();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* litegraph.js 模式下的定位逻辑
|
||
* 【修复】添加后备事件绑定逻辑,与 Vue mode 保持一致
|
||
*/
|
||
_applyLitegraphPositioning(widget, containerDiv, containerInfo) {
|
||
const { container: domWidgetContainer, textarea } = containerInfo;
|
||
|
||
// 【关键修复】确保 inputEl 引用正确
|
||
if (textarea && textarea !== widget.inputEl) {
|
||
widget.inputEl = textarea;
|
||
widget.text_element = textarea;
|
||
|
||
// 更新全局输入框映射
|
||
if (window.PromptAssistantInputWidgetMap && window.PromptAssistantInputWidgetMap[widget.widgetKey]) {
|
||
window.PromptAssistantInputWidgetMap[widget.widgetKey].inputEl = textarea;
|
||
}
|
||
|
||
// logger.debug(`[Litegraph定位] 更新inputEl引用 | 节点ID: ${widget.nodeId}`);
|
||
}
|
||
|
||
// 【关键修复】确保事件绑定(与 Vue mode 一致的后备逻辑)
|
||
const inputEl = widget.inputEl || textarea;
|
||
|
||
// 使用 widget 级别的 flag 判断
|
||
const isBound = widget._inputEventsBound;
|
||
|
||
// 精简定位开始日志
|
||
// logger.debug(`[_setupUIPosition] 开始定位 | 节点ID: ${widget.nodeId}`);
|
||
// logger.debug(`[Litegraph定位] 事件绑定检查 | 节点ID: ${widget.nodeId} | inputEl存在: ${!!inputEl} | isBound: ${isBound}`);
|
||
|
||
// 如果没有绑定,则绑定事件
|
||
if (inputEl && !isBound) {
|
||
// 如果是遗留标记,记录日志
|
||
if (inputEl._promptAssistantBound) {
|
||
logger.debug(`[Litegraph定位] 检测到遗留标记,重新绑定 | 节点ID: ${widget.nodeId}`);
|
||
}
|
||
|
||
inputEl._promptAssistantBound = true;
|
||
widget._inputEventsBound = true; // 设置标记
|
||
widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
|
||
// logger.debug(`[Litegraph定位] 开始绑定事件 | 节点ID: ${widget.nodeId}`);
|
||
|
||
// 绑定blur事件用于历史记录
|
||
const removeBlurListener = EventManager.addDOMListener(inputEl, 'blur', async () => {
|
||
// logger.debug(`[Litegraph] 历史写入准备 | 原因:失焦事件触发 node_id=${widget.nodeId} input_id=${widget.inputId}`);
|
||
HistoryCacheService.addHistory({
|
||
workflow_id: widget.nodeInfo?.workflow_id || '',
|
||
node_id: widget.nodeId,
|
||
input_id: widget.inputId,
|
||
content: inputEl.value,
|
||
operation_type: 'input',
|
||
timestamp: Date.now()
|
||
});
|
||
// 重置撤销状态
|
||
HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, inputEl.value);
|
||
// 更新按钮状态
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
});
|
||
widget._eventCleanupFunctions.push(removeBlurListener);
|
||
|
||
// 绑定input事件用于实时更新按钮状态
|
||
const removeInputListener = EventManager.addDOMListener(inputEl, 'input', () => {
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
this._adjustPositionForScrollbar(widget, inputEl);
|
||
});
|
||
widget._eventCleanupFunctions.push(removeInputListener);
|
||
|
||
if (!widget._undoStateInitialized) {
|
||
HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, inputEl.value);
|
||
widget._undoStateInitialized = true;
|
||
}
|
||
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
// logger.debug(`[Litegraph定位] 事件绑定完成 | 节点ID: ${widget.nodeId}`);
|
||
} else if (inputEl && inputEl._promptAssistantBound) {
|
||
// 已绑定,只更新按钮状态
|
||
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
||
}
|
||
|
||
// 【防重复挂载检查】检查 inputEl 是否已被小助手绑定
|
||
if (inputEl && inputEl._promptAssistantMounted) {
|
||
logger.debug(`[Litegraph定位] 跳过挂载 | 原因: inputEl 已被其他小助手绑定 | 节点ID: ${widget.nodeId}`);
|
||
// 清理当前 widget 实例(因为无法正确挂载)
|
||
if (widget.widgetKey && PromptAssistant.instances.has(widget.widgetKey)) {
|
||
PromptAssistant.instances.delete(widget.widgetKey);
|
||
}
|
||
if (widget.container) {
|
||
widget.container.destroy();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 【防重复挂载检查】检查容器内是否已存在小助手元素
|
||
const existingAssistant = domWidgetContainer.querySelector('.assistant-container-common');
|
||
if (existingAssistant) {
|
||
logger.debug(`[Litegraph定位] 跳过挂载 | 原因: 容器内已存在小助手 | 节点ID: ${widget.nodeId}`);
|
||
// 清理当前 widget 实例(因为无法正确挂载)
|
||
if (widget.widgetKey && PromptAssistant.instances.has(widget.widgetKey)) {
|
||
PromptAssistant.instances.delete(widget.widgetKey);
|
||
}
|
||
if (widget.container) {
|
||
widget.container.destroy();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 在 inputEl 上添加挂载标记
|
||
if (inputEl) {
|
||
inputEl._promptAssistantMounted = true;
|
||
inputEl._promptAssistantWidgetKey = widget.widgetKey;
|
||
}
|
||
|
||
// 确保 dom-widget 容器有相对定位
|
||
const containerPosition = window.getComputedStyle(domWidgetContainer).position;
|
||
if (containerPosition === 'static') {
|
||
domWidgetContainer.style.position = 'relative';
|
||
}
|
||
|
||
// 标准模式使用绝对定位
|
||
containerDiv.style.position = 'absolute';
|
||
|
||
|
||
|
||
// 直接添加到dom-widget容器
|
||
domWidgetContainer.appendChild(containerDiv);
|
||
|
||
// 触发回流,确保样式更新
|
||
void containerDiv.offsetWidth;
|
||
|
||
// 挂载完成后检测并调整滚动条位置
|
||
if (inputEl) {
|
||
requestAnimationFrame(() => this._adjustPositionForScrollbar(widget, inputEl, true));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理单个实例的资源
|
||
*/
|
||
_cleanupInstance(instance, instanceKey, skipRemove = false) {
|
||
try {
|
||
// 检查实例是否有效
|
||
if (!instance) {
|
||
logger.debug(`实例清理 | 结果:跳过 | 实例:${instanceKey || 'unknown'} | 原因:实例不存在`);
|
||
return;
|
||
}
|
||
|
||
// 标记实例为已销毁
|
||
instance.isDestroyed = true;
|
||
|
||
// 1. 重置所有按钮状态
|
||
if (instance.buttons) {
|
||
Object.keys(instance.buttons).forEach(buttonId => {
|
||
try {
|
||
const button = instance.buttons[buttonId];
|
||
if (button) {
|
||
// 移除所有状态类
|
||
button.classList.remove('button-active', 'button-processing', 'button-disabled');
|
||
// 移除所有事件监听器
|
||
button.replaceWith(button.cloneNode(true));
|
||
}
|
||
} catch (err) {
|
||
logger.debug(`按钮清理 | 按钮:${buttonId} | 错误:${err.message}`);
|
||
}
|
||
});
|
||
// 清空按钮引用
|
||
instance.buttons = {};
|
||
}
|
||
|
||
// 2. 清理事件监听器
|
||
if (instance.cleanupListeners && typeof instance.cleanupListeners === 'function') {
|
||
try {
|
||
instance.cleanupListeners();
|
||
} catch (err) {
|
||
logger.debug(`监听器清理 | 错误:${err.message}`);
|
||
}
|
||
}
|
||
|
||
// 3. 清理所有保存的事件清理函数
|
||
if (instance._eventCleanupFunctions && Array.isArray(instance._eventCleanupFunctions)) {
|
||
instance._eventCleanupFunctions.forEach(cleanup => {
|
||
if (typeof cleanup === 'function') {
|
||
try {
|
||
cleanup();
|
||
} catch (err) {
|
||
logger.debug(`事件清理 | 错误:${err.message}`);
|
||
}
|
||
}
|
||
});
|
||
instance._eventCleanupFunctions = [];
|
||
}
|
||
|
||
// 3.5【关键修复】重置 inputEl 上的事件绑定标记
|
||
// 确保模式切换后可以重新绑定事件
|
||
if (instance.inputEl && instance.inputEl._promptAssistantBound) {
|
||
instance.inputEl._promptAssistantBound = false;
|
||
}
|
||
if (instance.text_element && instance.text_element._promptAssistantBound) {
|
||
instance.text_element._promptAssistantBound = false;
|
||
}
|
||
|
||
// 3.6【防重复挂载修复】重置 textarea 上的挂载标记
|
||
// 确保清理后可以重新挂载小助手
|
||
if (instance.inputEl && instance.inputEl._promptAssistantMounted) {
|
||
instance.inputEl._promptAssistantMounted = false;
|
||
delete instance.inputEl._promptAssistantWidgetKey;
|
||
}
|
||
if (instance.text_element && instance.text_element._promptAssistantMounted && instance.text_element !== instance.inputEl) {
|
||
instance.text_element._promptAssistantMounted = false;
|
||
delete instance.text_element._promptAssistantWidgetKey;
|
||
}
|
||
|
||
// 同时重置 widget 级别的标记
|
||
instance._undoStateInitialized = false;
|
||
instance._inputEventsBound = false; // 重置输入事件绑定标记
|
||
|
||
|
||
// 4. 从DOM中移除元素
|
||
if (instance.element) {
|
||
try {
|
||
// 确保在移除元素前清理所有子元素的事件
|
||
const allButtons = instance.element.querySelectorAll('button');
|
||
allButtons.forEach(button => {
|
||
button.replaceWith(button.cloneNode(true));
|
||
});
|
||
|
||
// 清理指示器元素
|
||
if (instance.indicator && instance.indicator.parentNode) {
|
||
instance.indicator.innerHTML = '';
|
||
}
|
||
|
||
if (instance.element.parentNode) {
|
||
instance.element.parentNode.removeChild(instance.element);
|
||
}
|
||
} catch (err) {
|
||
logger.debug(`DOM元素清理 | 错误:${err.message}`);
|
||
}
|
||
}
|
||
|
||
// 5. 清理输入框映射
|
||
if (window.PromptAssistantInputWidgetMap && instanceKey) {
|
||
try {
|
||
delete window.PromptAssistantInputWidgetMap[instanceKey];
|
||
} catch (err) {
|
||
logger.debug(`输入框映射清理 | 错误:${err.message}`);
|
||
}
|
||
}
|
||
|
||
// 6. 清理弹窗状态
|
||
if (window.FEATURES && window.FEATURES.updateButtonsVisibility) {
|
||
try {
|
||
window.FEATURES.updateButtonsVisibility();
|
||
} catch (err) {
|
||
logger.debug(`按钮可见性更新 | 错误:${err.message}`);
|
||
}
|
||
}
|
||
|
||
// 7. 从实例集合中移除(除非明确指定跳过)
|
||
if (!skipRemove && instanceKey) {
|
||
try {
|
||
PromptAssistant.instances.delete(instanceKey);
|
||
} catch (err) {
|
||
logger.debug(`实例集合清理 | 错误:${err.message}`);
|
||
}
|
||
}
|
||
|
||
// 8. 清理实例属性
|
||
try {
|
||
Object.keys(instance).forEach(key => {
|
||
try {
|
||
delete instance[key];
|
||
} catch (err) {
|
||
logger.debug(`属性清理 | 属性:${key} | 错误:${err.message}`);
|
||
}
|
||
});
|
||
} catch (err) {
|
||
logger.debug(`属性清理 | 错误:${err.message}`);
|
||
}
|
||
|
||
// logger.debug(`实例清理 | 结果:成功 | 实例:${instanceKey || 'unknown'}`);
|
||
} catch (error) {
|
||
logger.error(`实例清理失败 | 实例:${instanceKey || 'unknown'} | 错误:${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置按钮右键菜单
|
||
* @param {HTMLElement} button 按钮元素
|
||
* @param {Function} getMenuItems 获取菜单项的函数
|
||
* @param {Object} widget 小助手实例
|
||
*/
|
||
_setupButtonContextMenu(button, getMenuItems, widget) {
|
||
if (!button || typeof getMenuItems !== 'function') return;
|
||
|
||
// 设置右键菜单
|
||
const cleanup = buttonMenu.setupButtonMenu(button, () => {
|
||
// 调用getMenuItems函数获取菜单项,传入widget作为上下文
|
||
return getMenuItems(widget);
|
||
}, { widget, buttonElement: button });
|
||
|
||
// 保存清理函数到widget的事件清理函数列表中
|
||
if (cleanup) {
|
||
widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
|
||
widget._eventCleanupFunctions.push(cleanup);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 创建单例实例
|
||
const promptAssistant = new PromptAssistant();
|
||
|
||
// 导出
|
||
export { promptAssistant, PromptAssistant };
|