/**
* 提示词小助手核心类
* 统一管理小助手的生命周期、实例创建、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;
}
/**
* 【核心优化】统一获取助手实例的唯一键名
* 解决子图节点 ID 冲突及不同扫描模式下的键名不一致问题
*/
_getAssistantKey(node, inputId) {
if (!node) return null;
const graph = node.graph || app.graph;
// 优先顺序:graph.id (Locator ID) -> graph._workflow_id -> 'main'
const graphId = graph?.id || graph?._workflow_id || 'main';
return `${graphId}_${node.id}_${inputId}`;
}
// ---生命周期管理功能---
/**
* 判断功能是否被禁用
*/
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;
}
// 检查Id是否有效
if (nodeId !== null && nodeId !== undefined) {
// 确保nodeId是字符串类型,便于后续比较
const searchId = String(nodeId);
// 获取所有匹配的实例键
// 逻辑:匹配精确键 (graphId_nodeId_inputId) 或者以 nodeId_ 开头的旧键,或者包含 _nodeId_ 的全量键
const keysToDelete = Array.from(PromptAssistant.instances.keys())
.filter(key => {
// 1. 精确匹配(如果传入的是 assistantKey)
if (key === searchId) return true;
// 2. 匹配 nodeId (旧格式)
if (key.startsWith(`${searchId}_`)) return true;
// 3. 匹配带 graphId 前缀的格式 (graphId_nodeId_inputId)
const parts = key.split('_');
return parts.length >= 2 && parts[1] === searchId;
});
// 如果有实例需要清理
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)) {
// logger.debug(`[isValidNode] Vue+Special match: ${node.type}`);
return true;
}
const valid = !!node.widgets;
if (!valid) {
// logger.debug(`[isValidNode] Fallback check: ${node.type}`);
}
// 标准检查:需要有widgets属性
return valid;
}
/**
* 添加实例到管理器
*/
static addInstance(nodeId, widget) {
if (nodeId != null && widget != null) {
this.instances.set(String(nodeId), widget);
return true;
}
return false;
}
/**
* 获取实例
*/
static getInstance(key) {
if (key == null) return null;
return this.instances.get(String(key));
}
/**
* 检查实例是否存在
*/
static hasInstance(key) {
if (key == null) return false;
return this.instances.has(String(key));
}
/**
* 检查节点并设置小助手
* 查找节点中的有效输入控件并创建小助手
*/
checkAndSetupNode(node) {
// 快速检查
if (!window.FEATURES.enabled || !node) return;
const isVueMode = LiteGraph.vueNodesMode === true;
const graph = node.graph || app.graph;
const graphId = graph?.id || graph?._workflow_id || 'main';
// 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;
// --- 核心修复:多图支持的唯一键 ---
let assistantKey = this._getAssistantKey(node, 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 = `${graphId}_${node.id}_${inputId}_${widgetIndex}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
assistantKey = inputEl.dataset.promptAssistantUniqueId;
} else {
// 降级方案:使用索引
assistantKey = `${graphId}_${node.id}_${inputId}_${widgetIndex}`;
}
}
// 检查实例是否已存在
if (PromptAssistant.hasInstance(assistantKey)) {
// 如果实例存在,检查输入控件是否已更新,或者 UI 元素是否已丢失
const instance = PromptAssistant.getInstance(assistantKey);
const currentInputEl = inputWidget.inputEl;
const instanceInputEl = instance?.text_element;
const instanceUIEl = instance?.element;
// 检查 UI 元素是否仍然在 DOM 中
const isVueMode = nodeMountService.isVueNodesMode();
const nodeContainer = isVueMode ? document.querySelector(`[data-node-id="${node.id}"]`) : null;
const isUIPresent = isVueMode ? nodeContainer?.contains(instanceUIEl) : document.body.contains(instanceUIEl);
// --- 修复:处理挂载竞争状态 ---
// 如果 UI 元素丢失,或者输入元素引用变化且原元素已移除,则清理并重建
// 增加 _isMounting 标记检查,避免异步挂载期间被误判为丢失
if (!instanceUIEl || (!isUIPresent && !instance._isMounting)) {
logger.debug(() => `[checkAndSetupNode] UI 元素已丢失,清理实例以触发重建 | 节点ID: ${node.id} | 键: ${assistantKey}`);
// 传入完整的 assistantKey 以确保精确清理
this.cleanup(assistantKey);
} else if (!isUIPresent && instance._isMounting) {
// 正在挂载中,跳过
return;
} else if (instanceInputEl && currentInputEl && instanceInputEl !== currentInputEl) {
// 进一步检查:确保确实需要重建(避免误判)
// 如果当前元素已经从 DOM 中移除,才需要清理
if (!document.body.contains(instanceInputEl)) {
logger.debug(() => `[checkAndSetupNode] 输入元素已失效,清理实例 | 节点ID: ${node.id}`);
this.cleanup(node.id);
} else {
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);
// logger.debug(`[_handleVueDomScanNode] Scanning: ${node.type}`);
// 仅处理我们识别的有效节点
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');
// logger.debug(`[Vue扫描] 节点无文本框 | ID: ${node.id}`);
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 = this._getAssistantKey(node, inputId);
if (PromptAssistant.hasInstance(assistantKey)) {
const instance = PromptAssistant.getInstance(assistantKey);
const isVueMode = nodeMountService.isVueNodesMode();
const nodeContainer = isVueMode ? document.querySelector(`[data-node-id="${node.id}"]`) : null;
const isUIPresent = isVueMode ? nodeContainer?.contains(instance?.element) : document.body.contains(instance?.element);
if (instance?.element && isUIPresent) {
return;
}
// 实例存在但 UI 丢失,清理旧实例以便重建
logger.debug(() => `[_mountDomAssistant] 检测到孤立实例,清理重建 | 节点ID: ${node.id}`);
this.cleanup(node.id);
}
// 检查元素是否已被挂载 (基于 DOM 属性判断)
if (element._promptAssistantMounted) {
// 如果属性还在但实例在 Map 中没了,或者 UI 确实不可见了,应该允许重新挂载
// 这里我们保持原样,通过上面的 cleanup 保证一致性
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
};
}
// 补充注入查找到的真实 textarea (解决 Litegraph 下 PromotedWidgetView 获取不到 inputEl 导致中止的问题)
const mountContainer = nodeMountService.findMountContainer(node, inputWidget);
if (mountContainer && mountContainer.textarea) {
processedWidget = Object.create(processedWidget);
processedWidget.inputEl = mountContainer.textarea;
processedWidget.element = mountContainer.textarea;
}
// 创建小助手实例
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 || this._getAssistantKey(node, 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,
_isMounting: true, // 标记挂载中状态
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, inputWidget, (success) => {
if (widget.isDestroyed) {
logger.debug(`[定位] 回调跳过:实例已销毁 | ID: ${nodeId}`);
return;
}
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();
// 挂载完成,清除标记
widget._isMounting = false;
});
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: ``,
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: ``,
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: ``,
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: ``,
submenuAlign: 'center',
children: promptsInCategory.map(prompt => createRuleMenuItem(prompt))
});
});
// 添加规则管理选项
ruleMenuItems.push({ type: 'separator' });
ruleMenuItems.push({
label: '规则管理',
icon: '',
onClick: () => {
rulesConfigManager.showRulesConfigModal();
}
});
return [
...ruleMenuItems,
// { type: 'separator' },
{
label: "选择服务",
icon: '',
submenuAlign: 'bottom',
children: serviceMenuItems
}
];
}
},
{
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: ``,
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: ``,
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: ``,
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: '',
children: serviceMenuItems
},
{ type: 'separator' },
{
label: "翻译缓存",
icon: ``,
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, inputWidget, 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;
}
// 使用真实的 inputWidget,用于适配 Subgraph 的 PromotedWidgetView 解包
const widgetObj = inputWidget || {
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 };