修复扩写、翻译使用302.ai服务时报错问题,ollama无法自动释放问题; 所有节点添加ollama释放选项 移除llm和vlm的强制直连参数,在设置界面中添加是否直连选项; 优化控制台日志输出格式;
2462 lines
102 KiB
JavaScript
2462 lines
102 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 { CLIP_NODE_TYPES } from "../services/interceptor.js";
|
|
import { buttonMenu } from "../services/btnMenu.js";
|
|
import { rulesConfigManager } from "./rulesConfigManager.js";
|
|
|
|
/**
|
|
* 更新指定CLIP编码器节点的自动翻译指示器状态
|
|
* 使用从interceptor.js导入的CLIP_NODE_TYPES,确保UI指示器与实际功能一致
|
|
* @param {boolean} enabled
|
|
*/
|
|
/*
|
|
export function updateAutoTranslateIndicators(enabled) {
|
|
if (!app.canvas?.graph) return;
|
|
|
|
app.canvas.graph._nodes.forEach(node => {
|
|
// 检查是否为指定的CLIP编码器节点
|
|
const isClipNode = node.type && CLIP_NODE_TYPES.includes(node.type);
|
|
|
|
if (isClipNode) {
|
|
// 查找该节点的小助手实例
|
|
const nodeId = node.id;
|
|
if (node.widgets) {
|
|
node.widgets.forEach(widget => {
|
|
const inputId = widget.name || widget.id;
|
|
if (inputId) {
|
|
const assistantKey = `${nodeId}_${inputId}`;
|
|
const assistantInstance = PromptAssistant.getInstance(assistantKey);
|
|
|
|
// 如果找到小助手实例,更新其样式
|
|
if (assistantInstance && assistantInstance.element) {
|
|
assistantInstance.element.classList.toggle('auto-translate-enabled', enabled);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
*/
|
|
|
|
/**
|
|
* 暂时禁用的自动翻译指示器功能的空占位函数
|
|
* 保持导出以避免其他模块的导入错误
|
|
*/
|
|
export function updateAutoTranslateIndicators(enabled) {
|
|
// 空实现,不执行任何操作
|
|
logger.debug("自动翻译指示器功能已暂时禁用");
|
|
return;
|
|
}
|
|
|
|
// ====================== 工具函数 ======================
|
|
|
|
/**
|
|
* 计算小助手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 179.04;
|
|
} else if (hasHistory && otherFeaturesCount === 2) {
|
|
// 历史 + 两个其他功能
|
|
return 151.36;
|
|
} else if (!hasHistory && otherFeaturesCount === 3) {
|
|
// 只有三个其他功能
|
|
return 87.21;
|
|
} else if (hasHistory && otherFeaturesCount === 1) {
|
|
// 历史 + 一个其他功能
|
|
return 123.69;
|
|
} else if (!hasHistory && otherFeaturesCount === 2) {
|
|
// 只有两个其他功能
|
|
return 59.54;
|
|
} else if (hasHistory && otherFeaturesCount === 0) {
|
|
// 只有历史功能
|
|
return 87.21;
|
|
} else if (!hasHistory && otherFeaturesCount === 1) {
|
|
// 只有一个其他功能
|
|
return 31.87;
|
|
}
|
|
|
|
// 默认值(理论上不应该到这里)
|
|
return 31.87;
|
|
}
|
|
|
|
/**
|
|
* 生成唯一请求ID
|
|
*/
|
|
function generateRequestId(prefix = 'baidu_trans', type = null) {
|
|
// 如果提供了type,根据type确定前缀
|
|
if (type) {
|
|
switch (type) {
|
|
case 'llm':
|
|
prefix = 'llm_trans';
|
|
break;
|
|
case 'baidu':
|
|
prefix = 'baidu_trans';
|
|
break;
|
|
case 'expand':
|
|
prefix = 'glm4_expand';
|
|
break;
|
|
default:
|
|
// 保持传入的prefix不变
|
|
break;
|
|
}
|
|
}
|
|
|
|
const timestamp = Date.now();
|
|
const random = Math.random().toString(36).substr(2, 6);
|
|
return `${prefix}_${timestamp}_${random}`;
|
|
}
|
|
|
|
/**
|
|
* 防抖函数
|
|
* 限制函数调用频率,避免频繁触发导致性能问题
|
|
*/
|
|
function debounce(func, wait = 100) {
|
|
return EventManager.debounce(func, wait);
|
|
}
|
|
|
|
// ====================== 主类实现 ======================
|
|
|
|
/**
|
|
* 提示词小助手主类
|
|
* 统一管理小助手的生命周期、实例和资源
|
|
*/
|
|
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();
|
|
|
|
// 检查总开关的初始状态
|
|
const initialEnabled = app.ui.settings.getSettingValue("PromptAssistant.Features.Enabled");
|
|
window.FEATURES.enabled = initialEnabled !== undefined ? initialEnabled : true;
|
|
|
|
// 记录总开关状态(改为调试级别)
|
|
logger.debug(`初始化时检测总开关状态 | 状态:${window.FEATURES.enabled ? "启用" : "禁用"}`);
|
|
|
|
// 初始化资源管理器
|
|
ResourceManager.init();
|
|
|
|
// 只有在总开关打开时才做完整初始化
|
|
if (window.FEATURES.enabled) {
|
|
// 更新CLIP节点的指示器状态
|
|
updateAutoTranslateIndicators(window.FEATURES.autoTranslate);
|
|
}
|
|
|
|
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;
|
|
}
|
|
} 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();
|
|
|
|
// 如果功能已启用,更新自动翻译指示器状态
|
|
/* 暂时禁用自动翻译指示器
|
|
if (enable) {
|
|
updateAutoTranslateIndicators(window.FEATURES.autoTranslate);
|
|
}
|
|
*/
|
|
} 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}个`);
|
|
logger.log(`[剩余统计] 小助手实例: ${remainingInstances}个 | 标签缓存: ${remainingTags}个 | 节点历史缓存: ${remainingHistory}条`);
|
|
}
|
|
} 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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---实例管理功能---
|
|
/**
|
|
* 检查节点是否有效
|
|
*/
|
|
static isValidNode(node) {
|
|
return node &&
|
|
typeof node.id !== 'undefined' &&
|
|
node.id !== -1 && // 防止id为-1的无效节点
|
|
node.widgets && // 确保有widgets属性
|
|
typeof node.type === 'string'; // 确保有类型
|
|
}
|
|
|
|
/**
|
|
* 添加实例到管理器
|
|
*/
|
|
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 || !node.widgets) {
|
|
return;
|
|
}
|
|
|
|
// 获取所有有效的输入控件
|
|
const validInputs = node.widgets.filter(widget => {
|
|
return UIToolkit.isValidInput(widget);
|
|
});
|
|
|
|
if (validInputs.length === 0) {
|
|
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);
|
|
if (instance && instance.text_element !== inputWidget.inputEl) {
|
|
// 如果输入控件已更新,清理旧实例并创建新实例
|
|
this.cleanup(node.id);
|
|
} else {
|
|
return; // 跳过已存在且未更新的实例
|
|
}
|
|
}
|
|
|
|
// 再次检查总开关状态,确保在创建过程中没有被禁用
|
|
if (!window.FEATURES.enabled) {
|
|
return;
|
|
}
|
|
|
|
// 创建小助手实例
|
|
const assistant = this.setupNodeAssistant(node, inputWidget, assistantKey);
|
|
if (assistant) {
|
|
logger.log(`创建小助手 | 节点:${node.id} | 控件:${inputId} | 索引:${widgetIndex} | 实例:${assistantKey}`);
|
|
|
|
// 检查是否为指定的CLIP编码器节点,并应用自动翻译样式
|
|
// 使用从interceptor.js导入的CLIP_NODE_TYPES,确保UI指示器与实际功能一致
|
|
// 暂时禁用自动翻译检测
|
|
/*
|
|
const isClipNode = node.type && CLIP_NODE_TYPES.includes(node.type);
|
|
|
|
if (isClipNode && assistant.element) {
|
|
assistant.element.classList.toggle('auto-translate-enabled', window.FEATURES.autoTranslate);
|
|
}
|
|
*/
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 为节点设置小助手
|
|
* 创建小助手实例并初始化显示状态
|
|
*/
|
|
setupNodeAssistant(node, inputWidget, assistantKey = null) {
|
|
// 简化参数检查
|
|
if (!node || !inputWidget) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const nodeId = node.id;
|
|
const inputId = inputWidget.name || inputWidget.id || Math.random().toString(36).substring(2, 10);
|
|
const isNoteNode = node.type === "Note";
|
|
|
|
// 简化节点信息
|
|
const nodeInfo = {
|
|
workflow_id: app.graph?._workflow_id || 'unknown',
|
|
nodeType: node.type,
|
|
inputType: inputId,
|
|
isNoteNode: isNoteNode
|
|
};
|
|
|
|
// 创建小助手实例
|
|
const assistant = this.createAssistant(
|
|
node,
|
|
inputId,
|
|
isNoteNode ? { ...inputWidget, inputEl: inputWidget.element } : inputWidget, // Note节点特殊处理
|
|
nodeInfo,
|
|
assistantKey
|
|
);
|
|
|
|
if (assistant) {
|
|
// 初始化显示状态,始终显示
|
|
this.showAssistantUI(assistant);
|
|
return assistant;
|
|
}
|
|
|
|
return null;
|
|
} catch (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 || !UIToolkit.isValidInput(inputWidget)) {
|
|
return null;
|
|
}
|
|
|
|
// 确保输入元素存在
|
|
const inputEl = inputWidget.inputEl || inputWidget.element;
|
|
if (!inputEl) {
|
|
return null;
|
|
}
|
|
|
|
const nodeId = node.id;
|
|
// 使用传入的 assistantKey,如果没有则使用默认生成方式
|
|
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, // 直接添加inputEl引用,方便历史记录回写
|
|
// 不再直接设置 node 引用,而是保存 nodeId
|
|
nodeInfo: {
|
|
...nodeInfo,
|
|
nodeId: node.id,
|
|
nodeType: node.type
|
|
},
|
|
isTransitioning: false // 添加状态标记,避免频繁切换
|
|
};
|
|
|
|
// 创建全局输入框映射(如果不存在)
|
|
if (!window.PromptAssistantInputWidgetMap) {
|
|
window.PromptAssistantInputWidgetMap = {};
|
|
}
|
|
|
|
// 将当前输入框添加到映射
|
|
window.PromptAssistantInputWidgetMap[widgetKey] = {
|
|
inputEl: inputEl,
|
|
widget: widget
|
|
};
|
|
|
|
logger.debug(`输入框映射 | 添加映射 | 键:${widgetKey}`);
|
|
|
|
// 创建UI并添加到实例集合
|
|
this.createAssistantUI(widget, inputWidget);
|
|
PromptAssistant.addInstance(widgetKey, widget);
|
|
|
|
// 初始化撤销状态
|
|
HistoryCacheService.initUndoState(nodeId, inputId, inputEl.value);
|
|
// 初始化时立即更新撤销/重做按钮状态
|
|
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
|
|
|
// 绑定输入框失焦事件,写入历史
|
|
if (inputWidget.inputEl) {
|
|
// 使用事件管理器添加DOM事件监听
|
|
const removeBlurListener = EventManager.addDOMListener(inputWidget.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: inputWidget.inputEl.value,
|
|
operation_type: 'input',
|
|
timestamp: Date.now()
|
|
});
|
|
// 重置撤销状态
|
|
HistoryCacheService.initUndoState(node.id, inputId, inputWidget.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(inputWidget.inputEl, 'input', () => {
|
|
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
|
// 检测滚动条状态并调整位置
|
|
this._adjustPositionForScrollbar(widget, inputWidget.inputEl);
|
|
});
|
|
widget._eventCleanupFunctions.push(removeInputListener);
|
|
|
|
// 添加ResizeObserver监听输入框尺寸变化
|
|
if (window.ResizeObserver) {
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
// 延迟执行,确保浏览器完成布局更新
|
|
setTimeout(() => {
|
|
this._adjustPositionForScrollbar(widget, inputWidget.inputEl);
|
|
}, 10);
|
|
});
|
|
|
|
resizeObserver.observe(inputWidget.inputEl);
|
|
|
|
// 添加清理函数
|
|
widget._eventCleanupFunctions.push(() => {
|
|
resizeObserver.disconnect();
|
|
});
|
|
} else {
|
|
// 降级方案:监听window resize事件
|
|
const removeResizeListener = EventManager.addDOMListener(window, 'resize',
|
|
EventManager.debounce(() => {
|
|
this._adjustPositionForScrollbar(widget, inputWidget.inputEl);
|
|
}, 100)
|
|
);
|
|
widget._eventCleanupFunctions.push(removeResizeListener);
|
|
}
|
|
}
|
|
|
|
return widget;
|
|
}
|
|
|
|
// ---UI管理功能---
|
|
/**
|
|
* 创建小助手UI
|
|
* 构建DOM元素并设置事件监听和初始样式
|
|
*/
|
|
createAssistantUI(widget, inputWidget) {
|
|
const nodeId = widget.nodeId;
|
|
const inputId = widget.inputId;
|
|
|
|
try {
|
|
// 获取DOM相关信息
|
|
const inputEl = inputWidget.inputEl;
|
|
const graphCanvasContainer = document.querySelector('.graphcanvas');
|
|
const canvasContainerRect = graphCanvasContainer?.getBoundingClientRect();
|
|
|
|
// 创建内部内容容器(分离视觉效果)
|
|
const innerContentDiv = document.createElement('div');
|
|
innerContentDiv.className = 'prompt-assistant-inner prompt-assistant-content-fixed';
|
|
|
|
// 创建主容器 - 简化样式,减少触发栅格化的属性
|
|
const containerDiv = document.createElement('div');
|
|
containerDiv.className = 'prompt-assistant-container prompt-assistant-transition';
|
|
containerDiv.dataset.nodeId = nodeId;
|
|
containerDiv.dataset.inputId = inputId;
|
|
|
|
// 添加内容容器到主容器
|
|
containerDiv.appendChild(innerContentDiv);
|
|
|
|
// 创建悬停区域 - 用于检测鼠标悬停
|
|
const hoverAreaDiv = document.createElement('div');
|
|
hoverAreaDiv.className = 'prompt-assistant-hover-area';
|
|
// 将悬停区域添加到容器中
|
|
containerDiv.appendChild(hoverAreaDiv);
|
|
|
|
// 创建折叠状态指示器图标
|
|
const indicatorDiv = document.createElement('div');
|
|
indicatorDiv.className = 'prompt-assistant-indicator animate-creation';
|
|
|
|
// 从ResourceManager获取图标并添加到指示器
|
|
const mainIcon = ResourceManager.getIcon('icon-main.svg');
|
|
if (mainIcon) {
|
|
indicatorDiv.appendChild(mainIcon);
|
|
}
|
|
|
|
// 将指示器添加到容器中
|
|
containerDiv.appendChild(indicatorDiv);
|
|
|
|
// 保存引用并初始化
|
|
widget.element = containerDiv;
|
|
widget.innerContent = innerContentDiv;
|
|
widget.hoverArea = hoverAreaDiv;
|
|
widget.indicator = indicatorDiv;
|
|
widget.buttons = {};
|
|
widget.isCollapsed = true; // 初始状态为折叠
|
|
|
|
// 初始化UI组件和事件
|
|
this.addFunctionButtons(widget);
|
|
this._setupUIEventHandling(widget, inputEl, containerDiv);
|
|
|
|
// 异步设置UI位置,并在定位失败时清理资源
|
|
this._setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect, (success) => {
|
|
if (!success) {
|
|
// 定位失败,清理所有资源
|
|
logger.error(`创建小助手失败 | 节点ID: ${nodeId} | 原因: 定位失败,未找到dom-widget容器`);
|
|
|
|
// 清理DOM元素
|
|
if (containerDiv.parentNode) {
|
|
containerDiv.parentNode.removeChild(containerDiv);
|
|
}
|
|
|
|
// 清理事件监听器
|
|
if (widget.cleanupListeners && typeof widget.cleanupListeners === 'function') {
|
|
widget.cleanupListeners();
|
|
}
|
|
|
|
// 清理图标
|
|
if (indicatorDiv) {
|
|
indicatorDiv.innerHTML = '';
|
|
}
|
|
|
|
// 从实例集合中移除
|
|
const widgetKey = widget.widgetKey;
|
|
if (widgetKey && PromptAssistant.instances.has(widgetKey)) {
|
|
PromptAssistant.instances.delete(widgetKey);
|
|
}
|
|
|
|
// 清理输入框映射
|
|
if (window.PromptAssistantInputWidgetMap && widgetKey) {
|
|
delete window.PromptAssistantInputWidgetMap[widgetKey];
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// 定位成功,继续初始化
|
|
// 立即设置预设的展开宽度,避免首次展开时需要测量
|
|
const presetWidth = calculateAssistantWidth();
|
|
containerDiv.style.setProperty('--expanded-width', `${presetWidth}px`);
|
|
logger.debug(`[宽度预设] 节点:${nodeId} | 宽度:${presetWidth}px`);
|
|
|
|
// 初始滚动条检测和位置调整
|
|
setTimeout(() => {
|
|
this._adjustPositionForScrollbar(widget, inputEl);
|
|
}, 100); // 延迟执行,确保DOM完全渲染
|
|
|
|
// 设置初始折叠状态
|
|
containerDiv.classList.add('collapsed');
|
|
hoverAreaDiv.style.display = 'block'; // 显示悬停区域以便用户展开
|
|
|
|
// 默认隐藏状态
|
|
containerDiv.style.display = 'none';
|
|
|
|
// 移除动画类的定时器
|
|
setTimeout(() => {
|
|
if (indicatorDiv && indicatorDiv.classList.contains('animate-creation')) {
|
|
indicatorDiv.classList.remove('animate-creation');
|
|
}
|
|
}, 1000);
|
|
});
|
|
|
|
// 立即返回容器,定位将异步完成
|
|
return containerDiv;
|
|
} catch (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) {
|
|
// 如果widget没有初始化或者已经处于折叠状态,则不处理
|
|
if (!widget || !widget.element || widget.isCollapsed || widget.isTransitioning) return;
|
|
|
|
// 如果有活跃按钮,不自动折叠
|
|
if (this._checkAssistantActiveState(widget)) return;
|
|
|
|
// 如果鼠标当前悬停在容器上,不自动折叠
|
|
if (widget.isMouseOver) return;
|
|
|
|
// 设置自动折叠定时器
|
|
if (widget._autoCollapseTimer) {
|
|
clearTimeout(widget._autoCollapseTimer);
|
|
}
|
|
|
|
widget._autoCollapseTimer = setTimeout(() => {
|
|
// 再次检查条件
|
|
if (!widget.isCollapsed && !widget.isTransitioning &&
|
|
!this._checkAssistantActiveState(widget) && !widget.isMouseOver) {
|
|
|
|
const containerDiv = widget.element;
|
|
|
|
// 保存当前宽度用于展开动画
|
|
if (containerDiv.offsetWidth > 0) {
|
|
containerDiv.style.setProperty('--expanded-width', `${containerDiv.offsetWidth}px`);
|
|
}
|
|
|
|
// 设置过渡状态
|
|
widget.isTransitioning = true;
|
|
|
|
// 直接添加折叠类
|
|
containerDiv.classList.add('collapsed');
|
|
widget.isCollapsed = true;
|
|
|
|
// 显示悬停区域,用于检测鼠标悬停以展开UI
|
|
if (widget.hoverArea) {
|
|
widget.hoverArea.style.display = 'block';
|
|
}
|
|
|
|
// 动画结束后重置过渡状态
|
|
setTimeout(() => {
|
|
widget.isTransitioning = false;
|
|
}, 300);
|
|
}
|
|
|
|
widget._autoCollapseTimer = null;
|
|
}, 1000);
|
|
|
|
// 确保清理函数中包含这个定时器
|
|
if (widget._eventCleanupFunctions) {
|
|
widget._eventCleanupFunctions.push(() => {
|
|
if (widget._autoCollapseTimer) {
|
|
clearTimeout(widget._autoCollapseTimer);
|
|
widget._autoCollapseTimer = null;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 展开小助手
|
|
*/
|
|
_expandAssistant(widget) {
|
|
if (!widget || !widget.element) return;
|
|
|
|
// 如果已经是展开状态,直接返回
|
|
if (!widget.isCollapsed && !widget.isTransitioning) return;
|
|
|
|
// 如果正在折叠中,允许打断折叠动画
|
|
if (widget.isTransitioning) {
|
|
// 清理折叠动画的定时器
|
|
if (widget._transitionTimer) {
|
|
clearTimeout(widget._transitionTimer);
|
|
widget._transitionTimer = null;
|
|
}
|
|
// 重置过渡状态,准备执行展开
|
|
widget.isTransitioning = false;
|
|
}
|
|
|
|
// 如果不是折叠状态,不执行展开
|
|
if (!widget.isCollapsed) return;
|
|
|
|
widget.isTransitioning = true;
|
|
|
|
// 隐藏悬停区域,避免覆盖按钮
|
|
if (widget.hoverArea) {
|
|
widget.hoverArea.style.display = 'none';
|
|
}
|
|
|
|
const containerDiv = widget.element;
|
|
|
|
// 使用预设宽度,不再实时测量
|
|
let targetWidth = containerDiv.style.getPropertyValue('--expanded-width');
|
|
|
|
// 如果没有预设宽度,立即计算并设置
|
|
if (!targetWidth || targetWidth === '') {
|
|
const presetWidth = calculateAssistantWidth();
|
|
targetWidth = `${presetWidth}px`;
|
|
containerDiv.style.setProperty('--expanded-width', targetWidth);
|
|
logger.debug(`[宽度预设] 展开时设置 | 宽度:${targetWidth}`);
|
|
}
|
|
|
|
// 手动设置宽度转换
|
|
containerDiv.style.width = '28px'; // 起始宽度
|
|
|
|
// 强制回流
|
|
void containerDiv.offsetWidth;
|
|
|
|
// 移除折叠类
|
|
containerDiv.classList.remove('collapsed');
|
|
|
|
// 设置目标宽度以触发过渡
|
|
containerDiv.style.width = targetWidth;
|
|
|
|
// 清理之前的过渡定时器
|
|
if (widget._transitionTimer) {
|
|
clearTimeout(widget._transitionTimer);
|
|
}
|
|
|
|
// 动画结束后清理
|
|
widget._transitionTimer = setTimeout(() => {
|
|
// 移除固定宽度,恢复自动宽度
|
|
containerDiv.style.width = '';
|
|
widget.isCollapsed = false;
|
|
widget.isTransitioning = false;
|
|
widget._transitionTimer = null;
|
|
}, 300);
|
|
}
|
|
|
|
/**
|
|
* 公开方法:触发小助手自动折叠
|
|
* 供外部模块调用,用于在操作完成后折叠小助手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;
|
|
}
|
|
|
|
// 如果当前是折叠状态,则展开
|
|
if (widget.isCollapsed) {
|
|
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;
|
|
|
|
// 首先检查中央按钮状态管理器是否有该widget的激活按钮
|
|
const activeButtonInfo = UIToolkit.getActiveButtonInfo();
|
|
if (activeButtonInfo && activeButtonInfo.widget === widget) {
|
|
return true;
|
|
}
|
|
|
|
// 再遍历所有按钮,检查是否有按钮处于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() {
|
|
const newWidth = calculateAssistantWidth();
|
|
logger.debug(`[宽度更新] 计算新宽度:${newWidth}px | 实例数量:${PromptAssistant.instances.size}`);
|
|
|
|
PromptAssistant.instances.forEach((widget) => {
|
|
if (widget && widget.element) {
|
|
widget.element.style.setProperty('--expanded-width', `${newWidth}px`);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 显示状态提示
|
|
* 创建临时提示信息气泡
|
|
*/
|
|
showStatusTip(anchorElement, type, message, position = null) {
|
|
return UIToolkit.showStatusTip(anchorElement, type, message, position);
|
|
}
|
|
|
|
// ---事件处理功能---
|
|
/**
|
|
* 设置UI事件处理
|
|
* 配置按钮事件监听 - 简化版本
|
|
*/
|
|
_setupUIEventHandling(widget, inputEl, containerDiv) {
|
|
// 设置容器可以接收事件,而不是穿透
|
|
containerDiv.style.pointerEvents = 'auto';
|
|
widget.innerContent.style.pointerEvents = 'auto'; // 确保内容容器可以接收事件
|
|
widget.hoverArea.style.pointerEvents = 'auto'; // 确保悬停区域可以接收事件
|
|
widget.hoverArea.style.display = 'none'; // 初始状态下隐藏悬停区域,避免覆盖按钮
|
|
|
|
// 确保所有按钮可点击
|
|
const buttons = containerDiv.querySelectorAll('button');
|
|
buttons.forEach(button => {
|
|
// button.style.pointerEvents = 'auto'; // 移除此行,由CSS控制指针事件
|
|
button.classList.add('prompt-assistant-button-active');
|
|
});
|
|
|
|
// 初始化过渡状态标记
|
|
widget.isTransitioning = false;
|
|
|
|
// 使用清理函数数组存储所有事件清理函数
|
|
widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
|
|
|
|
// 折叠和展开功能
|
|
const setupCollapseExpandEvents = () => {
|
|
// 记录原始宽度,用于展开动画
|
|
const saveOriginalWidth = () => {
|
|
if (!widget.isCollapsed && containerDiv.offsetWidth > 0) {
|
|
containerDiv.style.setProperty('--expanded-width', `${containerDiv.offsetWidth}px`);
|
|
}
|
|
};
|
|
|
|
// 延迟保存宽度,确保DOM已完全渲染
|
|
setTimeout(saveOriginalWidth, 300);
|
|
|
|
// 过渡动画定时器,保存到widget对象上供所有函数访问
|
|
widget._transitionTimer = null;
|
|
|
|
// 折叠函数
|
|
const collapseAssistant = () => {
|
|
if (widget.isCollapsed || widget.isTransitioning) return;
|
|
|
|
// 保存当前宽度用于展开动画
|
|
saveOriginalWidth();
|
|
widget.isTransitioning = true;
|
|
|
|
// 直接添加折叠类,不使用动画类
|
|
containerDiv.classList.add('collapsed');
|
|
widget.isCollapsed = true;
|
|
|
|
// 显示悬停区域,用于检测鼠标悬停以展开UI
|
|
widget.hoverArea.style.display = 'block';
|
|
|
|
// 清理之前的过渡定时器
|
|
if (widget._transitionTimer) {
|
|
clearTimeout(widget._transitionTimer);
|
|
}
|
|
|
|
// 动画结束后重置过渡状态
|
|
widget._transitionTimer = setTimeout(() => {
|
|
widget.isTransitioning = false;
|
|
widget._transitionTimer = null;
|
|
}, 300);
|
|
};
|
|
|
|
// 展开函数
|
|
const expandAssistant = () => {
|
|
// 如果已经是展开状态,直接返回
|
|
if (!widget.isCollapsed && !widget.isTransitioning) return;
|
|
|
|
// 如果正在折叠中,允许打断折叠动画
|
|
if (widget.isTransitioning) {
|
|
// 清理折叠动画的定时器
|
|
if (widget._transitionTimer) {
|
|
clearTimeout(widget._transitionTimer);
|
|
widget._transitionTimer = null;
|
|
}
|
|
// 重置过渡状态,准备执行展开
|
|
widget.isTransitioning = false;
|
|
}
|
|
|
|
// 如果不是折叠状态,不执行展开
|
|
if (!widget.isCollapsed) return;
|
|
|
|
widget.isTransitioning = true;
|
|
|
|
// 隐藏悬停区域,避免覆盖按钮
|
|
widget.hoverArea.style.display = 'none';
|
|
|
|
// 使用预设宽度,不再实时测量
|
|
let targetWidth = widget.element.style.getPropertyValue('--expanded-width');
|
|
|
|
// 如果没有预设宽度,立即计算并设置
|
|
if (!targetWidth || targetWidth === '') {
|
|
const presetWidth = calculateAssistantWidth();
|
|
targetWidth = `${presetWidth}px`;
|
|
containerDiv.style.setProperty('--expanded-width', targetWidth);
|
|
logger.debug(`[宽度预设] 事件处理中设置 | 宽度:${targetWidth}`);
|
|
}
|
|
|
|
// 手动设置宽度转换
|
|
containerDiv.style.width = '28px'; // 起始宽度
|
|
|
|
// 强制回流
|
|
void containerDiv.offsetWidth;
|
|
|
|
// 移除折叠类
|
|
containerDiv.classList.remove('collapsed');
|
|
|
|
// 设置目标宽度以触发过渡
|
|
containerDiv.style.width = targetWidth;
|
|
|
|
// 清理之前的过渡定时器
|
|
if (widget._transitionTimer) {
|
|
clearTimeout(widget._transitionTimer);
|
|
}
|
|
|
|
// 动画结束后清理
|
|
widget._transitionTimer = setTimeout(() => {
|
|
// 移除固定宽度,恢复自动宽度
|
|
containerDiv.style.width = '';
|
|
widget.isCollapsed = false;
|
|
widget.isTransitioning = false;
|
|
widget._transitionTimer = null;
|
|
}, 300);
|
|
};
|
|
|
|
// 创建折叠定时器变量
|
|
let collapseTimer = null;
|
|
let autoCollapseTimer = null;
|
|
|
|
// 鼠标离开容器时折叠
|
|
const handleMouseLeave = () => {
|
|
// 如果有活跃按钮,不折叠
|
|
if (this._checkAssistantActiveState(widget)) return;
|
|
|
|
// 设置延时,避免鼠标短暂离开就触发折叠
|
|
collapseTimer = setTimeout(() => {
|
|
collapseAssistant();
|
|
}, 500);
|
|
};
|
|
|
|
// 鼠标进入容器(展开时)以保持展开状态
|
|
const handleContainerMouseEnter = () => {
|
|
// 仅当小助手展开时,进入容器才会阻止其自动折叠
|
|
if (widget.isCollapsed) return;
|
|
|
|
if (collapseTimer) {
|
|
clearTimeout(collapseTimer);
|
|
collapseTimer = null;
|
|
}
|
|
if (autoCollapseTimer) {
|
|
clearTimeout(autoCollapseTimer);
|
|
autoCollapseTimer = null;
|
|
}
|
|
};
|
|
|
|
// 鼠标进入悬停区域(折叠时)以展开
|
|
const handleHoverAreaMouseEnter = () => {
|
|
if (collapseTimer) {
|
|
clearTimeout(collapseTimer);
|
|
collapseTimer = null;
|
|
}
|
|
if (autoCollapseTimer) {
|
|
clearTimeout(autoCollapseTimer);
|
|
autoCollapseTimer = null;
|
|
}
|
|
// 如果当前是折叠状态,则展开
|
|
if (widget.isCollapsed) {
|
|
expandAssistant();
|
|
}
|
|
};
|
|
|
|
// 为容器添加鼠标事件
|
|
const removeContainerMouseLeave = EventManager.addDOMListener(containerDiv, 'mouseleave', handleMouseLeave);
|
|
const removeContainerMouseEnter = EventManager.addDOMListener(containerDiv, 'mouseenter', handleContainerMouseEnter);
|
|
|
|
// 为悬停区域添加鼠标事件
|
|
const removeHoverAreaMouseEnter = EventManager.addDOMListener(widget.hoverArea, 'mouseenter', handleHoverAreaMouseEnter);
|
|
|
|
// 添加清理函数
|
|
widget._eventCleanupFunctions.push(removeContainerMouseLeave);
|
|
widget._eventCleanupFunctions.push(removeContainerMouseEnter);
|
|
widget._eventCleanupFunctions.push(removeHoverAreaMouseEnter);
|
|
|
|
// 添加清理定时器的函数
|
|
widget._eventCleanupFunctions.push(() => {
|
|
if (collapseTimer) {
|
|
clearTimeout(collapseTimer);
|
|
collapseTimer = null;
|
|
}
|
|
if (autoCollapseTimer) {
|
|
clearTimeout(autoCollapseTimer);
|
|
autoCollapseTimer = null;
|
|
}
|
|
if (widget._transitionTimer) {
|
|
clearTimeout(widget._transitionTimer);
|
|
widget._transitionTimer = null;
|
|
}
|
|
});
|
|
|
|
// 创建后自动折叠功能
|
|
const setupAutoCollapse = () => {
|
|
// 如果有活跃按钮,不自动折叠
|
|
if (this._checkAssistantActiveState(widget)) return;
|
|
|
|
// 设置自动折叠定时器,1秒后自动折叠
|
|
autoCollapseTimer = setTimeout(() => {
|
|
// 再次检查是否有活跃按钮或鼠标悬停在容器上
|
|
if (!this._checkAssistantActiveState(widget) && !widget.isMouseOver) {
|
|
collapseAssistant();
|
|
}
|
|
}, 1000);
|
|
};
|
|
|
|
// 添加鼠标悬停状态跟踪
|
|
widget.isMouseOver = false;
|
|
const trackMouseOver = () => {
|
|
widget.isMouseOver = true;
|
|
};
|
|
const trackMouseOut = () => {
|
|
widget.isMouseOver = false;
|
|
};
|
|
|
|
// 为容器和悬停区域添加鼠标悬停状态跟踪
|
|
const removeContainerMouseOverTracking = EventManager.addDOMListener(containerDiv, 'mouseover', trackMouseOver);
|
|
const removeContainerMouseOutTracking = EventManager.addDOMListener(containerDiv, 'mouseout', trackMouseOut);
|
|
const removeHoverAreaMouseOverTracking = EventManager.addDOMListener(widget.hoverArea, 'mouseover', trackMouseOver);
|
|
const removeHoverAreaMouseOutTracking = EventManager.addDOMListener(widget.hoverArea, 'mouseout', trackMouseOut);
|
|
|
|
// 添加清理函数
|
|
widget._eventCleanupFunctions.push(removeContainerMouseOverTracking);
|
|
widget._eventCleanupFunctions.push(removeContainerMouseOutTracking);
|
|
widget._eventCleanupFunctions.push(removeHoverAreaMouseOverTracking);
|
|
widget._eventCleanupFunctions.push(removeHoverAreaMouseOutTracking);
|
|
|
|
// 设置自动折叠(延迟执行,确保DOM已完全渲染)
|
|
setTimeout(setupAutoCollapse, 500);
|
|
};
|
|
|
|
// 设置折叠展开事件
|
|
setupCollapseExpandEvents();
|
|
|
|
// 添加清理函数
|
|
widget.cleanupListeners = () => {
|
|
// 执行所有保存的清理函数
|
|
if (widget._eventCleanupFunctions && widget._eventCleanupFunctions.length) {
|
|
widget._eventCleanupFunctions.forEach(cleanupFn => {
|
|
if (typeof cleanupFn === 'function') {
|
|
cleanupFn();
|
|
}
|
|
});
|
|
widget._eventCleanupFunctions = [];
|
|
}
|
|
};
|
|
}
|
|
|
|
// ---辅助功能---
|
|
/**
|
|
* 更新输入框内容并添加高亮效果
|
|
*/
|
|
updateInputWithHighlight(widget, content) {
|
|
if (!widget?.inputEl) return;
|
|
|
|
try {
|
|
// 更新输入框内容
|
|
widget.inputEl.value = content;
|
|
|
|
// 触发输入事件
|
|
widget.inputEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
widget.inputEl.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
// 添加高亮效果
|
|
widget.inputEl.classList.add('input-highlight');
|
|
// 动画结束后移除类
|
|
setTimeout(() => {
|
|
widget.inputEl.classList.remove('input-highlight');
|
|
}, 200); // 与 CSS 动画时长匹配
|
|
|
|
logger.debug(`输入框更新 | 结果:成功 | 内容长度:${content.length}`);
|
|
} 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节点
|
|
const isNoteNode = widget.nodeInfo && widget.nodeInfo.isNoteNode === true;
|
|
|
|
// 获取历史状态(用于初始化撤销/重做按钮状态)
|
|
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 // 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 {
|
|
// 获取输入值
|
|
const inputValue = widget.inputEl.value;
|
|
if (!inputValue || inputValue.trim() === '') {
|
|
throw new Error('请输入要扩写的内容');
|
|
}
|
|
|
|
// 生成唯一request_id
|
|
const request_id = generateRequestId('glm4_expand');
|
|
|
|
// 通知UI可以准备取消操作了
|
|
notifyCancelReady(request_id);
|
|
|
|
// 显示扩写中提示
|
|
const btnRect = e.currentTarget.getBoundingClientRect();
|
|
UIToolkit.showStatusTip(
|
|
e.currentTarget,
|
|
'loading',
|
|
'扩写中',
|
|
{ x: btnRect.left + btnRect.width / 2, y: btnRect.top }
|
|
);
|
|
|
|
// 调用LLM服务进行扩写
|
|
const result = await APIService.llmExpandPrompt(inputValue, request_id);
|
|
|
|
if (result.success) {
|
|
// 更新输入框内容并添加高亮效果
|
|
this.updateInputWithHighlight(widget, result.data.expanded);
|
|
|
|
// 添加扩写结果到历史记录
|
|
HistoryCacheService.addHistory({
|
|
workflow_id: widget.nodeInfo?.workflow_id || '',
|
|
node_id: widget.nodeId,
|
|
input_id: widget.inputId,
|
|
content: result.data.expanded,
|
|
operation_type: 'expand',
|
|
request_id: request_id,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
// 重置撤销状态
|
|
HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, result.data.expanded);
|
|
|
|
// 更新按钮状态
|
|
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) => {
|
|
// 获取当前激活的扩写提示词ID
|
|
let activePromptId = null;
|
|
let expandPrompts = [];
|
|
|
|
try {
|
|
// 从服务器获取系统提示词配置
|
|
const response = await fetch('/prompt_assistant/api/config/system_prompts');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
// 获取激活的提示词ID
|
|
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];
|
|
expandPrompts.push({
|
|
id: key,
|
|
name: prompt.name || key,
|
|
content: prompt.content,
|
|
isActive: key === activePromptId
|
|
});
|
|
});
|
|
|
|
// 按原始顺序排序
|
|
expandPrompts.sort((a, b) =>
|
|
originalOrder.indexOf(a.id) - originalOrder.indexOf(b.id)
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(`获取扩写提示词失败: ${error.message}`);
|
|
}
|
|
|
|
// 如果没有获取到提示词,显示一个提示
|
|
if (expandPrompts.length === 0) {
|
|
return [
|
|
{
|
|
label: '未找到扩写提示词',
|
|
disabled: true
|
|
}
|
|
];
|
|
}
|
|
|
|
// 创建菜单项
|
|
const menuItems = expandPrompts.map(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('/prompt_assistant/api/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.widget.buttons.expand,
|
|
'success',
|
|
`已切换到: ${prompt.name}`,
|
|
null
|
|
);
|
|
} else {
|
|
throw new Error(`服务器返回错误: ${response.status}`);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`切换扩写提示词失败: ${error.message}`);
|
|
UIToolkit.showStatusTip(
|
|
context.widget.buttons.expand,
|
|
'error',
|
|
`切换失败: ${error.message}`,
|
|
null
|
|
);
|
|
}
|
|
}
|
|
}));
|
|
|
|
menuItems.push({ type: 'separator' });
|
|
menuItems.push({
|
|
label: '规则管理',
|
|
icon: '<span class="pi pi-pen-to-square"></span>',
|
|
onClick: () => {
|
|
rulesConfigManager.showRulesConfigModal();
|
|
}
|
|
});
|
|
|
|
// 在规则管理下方添加 服务选择 子菜单(LLM提供商)
|
|
try {
|
|
const resp = await fetch('/prompt_assistant/api/config/llm');
|
|
if (resp.ok) {
|
|
const cfg = await resp.json();
|
|
const providers = cfg.providers || {};
|
|
const current = cfg.provider || null;
|
|
const providerNameMap = { zhipu: '智谱', siliconflow: '硅基流动', "302ai": '302.AI', ollama: 'Ollama', custom: '自定义' };
|
|
const order = ['zhipu', 'siliconflow', '302ai', 'ollama', 'custom'];
|
|
const children = order
|
|
.filter(key => Object.prototype.hasOwnProperty.call(providers, key))
|
|
.map(key => ({
|
|
label: providerNameMap[key] || key,
|
|
icon: `<span class=\"pi ${current === key ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}\"></span>`,
|
|
onClick: async (context) => {
|
|
try {
|
|
const res = await fetch('/prompt_assistant/api/config/llm', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ current_provider: key })
|
|
});
|
|
if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
|
|
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${providerNameMap[key] || key}`);
|
|
} catch (err) {
|
|
logger.error(`切换LLM提供商失败: ${err.message}`);
|
|
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
|
|
}
|
|
}
|
|
}));
|
|
menuItems.push({
|
|
label: '服务选择',
|
|
icon: '<span class="pi pi-sparkles"></span>',
|
|
children
|
|
});
|
|
}
|
|
} catch (e) {
|
|
logger.error(`加载LLM提供商失败: ${e.message}`);
|
|
}
|
|
|
|
return menuItems;
|
|
}
|
|
},
|
|
{
|
|
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 {
|
|
// 获取输入值
|
|
const inputValue = widget.inputEl.value;
|
|
if (!inputValue || inputValue.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(inputValue);
|
|
}
|
|
if (cacheResult) {
|
|
let resultText = '';
|
|
let tipMessage = '';
|
|
let useCache = true;
|
|
|
|
// 根据缓存匹配类型处理
|
|
if (cacheResult.type === 'source') {
|
|
// 命中原文,返回译文
|
|
resultText = cacheResult.translatedText;
|
|
tipMessage = '译文';
|
|
} else if (cacheResult.type === 'translated') {
|
|
// 命中译文,返回原文
|
|
resultText = cacheResult.sourceText;
|
|
tipMessage = '原文';
|
|
}
|
|
|
|
// 更新输入框内容并添加高亮效果
|
|
this.updateInputWithHighlight(widget, resultText);
|
|
|
|
// 添加翻译结果到历史记录
|
|
HistoryCacheService.addHistory({
|
|
workflow_id: widget.nodeInfo?.workflow_id || '',
|
|
node_id: widget.nodeId,
|
|
input_id: widget.inputId,
|
|
content: resultText,
|
|
operation_type: 'translate',
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
// 重置撤销状态
|
|
HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, resultText);
|
|
|
|
// 更新按钮状态
|
|
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
|
|
|
return {
|
|
success: true,
|
|
useCache: useCache,
|
|
tipType: 'info',
|
|
tipMessage: tipMessage,
|
|
buttonElement: e.currentTarget // 传递按钮元素
|
|
};
|
|
}
|
|
|
|
// 缓存未命中,使用API翻译
|
|
// 获取翻译类型
|
|
const translateType = app.settings?.["PromptAssistant.Settings.TranslateType"] || "baidu";
|
|
|
|
// 生成唯一request_id,根据翻译类型生成对应的前缀
|
|
const request_id = generateRequestId(null, translateType);
|
|
|
|
// 通知UI可以准备取消操作了
|
|
notifyCancelReady(request_id);
|
|
|
|
// 检测语言
|
|
const langResult = PromptFormatter.detectLanguage(inputValue);
|
|
|
|
// 根据翻译类型选择服务
|
|
let result;
|
|
try {
|
|
if (translateType === "baidu") {
|
|
// 使用百度翻译服务
|
|
result = await APIService.baiduTranslate(
|
|
inputValue,
|
|
langResult.from,
|
|
langResult.to,
|
|
request_id
|
|
);
|
|
} else {
|
|
// 使用LLM翻译服务
|
|
result = await APIService.llmTranslate(
|
|
inputValue,
|
|
langResult.from,
|
|
langResult.to,
|
|
request_id
|
|
);
|
|
}
|
|
|
|
if (!result) {
|
|
throw new Error('翻译服务返回空结果');
|
|
}
|
|
} catch (error) {
|
|
logger.error(`翻译失败 | 服务:${translateType} | 错误:${error.message}`);
|
|
throw new Error(`翻译失败: ${error.message}`);
|
|
}
|
|
|
|
if (result.success) {
|
|
// 格式化翻译结果(转换标点符号)
|
|
const formattedText = PromptFormatter.formatTranslatedText(result.data.translated);
|
|
|
|
// 添加翻译结果到历史记录
|
|
HistoryCacheService.addHistory({
|
|
workflow_id: widget.nodeInfo?.workflow_id || '',
|
|
node_id: widget.nodeId,
|
|
input_id: widget.inputId,
|
|
content: formattedText,
|
|
operation_type: 'translate',
|
|
request_id: request_id,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
// 更新输入框内容并添加高亮效果
|
|
this.updateInputWithHighlight(widget, formattedText);
|
|
|
|
// 重置撤销状态
|
|
HistoryCacheService.initUndoState(widget.nodeId, widget.inputId, formattedText);
|
|
|
|
// 更新按钮状态
|
|
UIToolkit.updateUndoRedoButtonState(widget, HistoryCacheService);
|
|
|
|
// 只有开启缓存时才写入缓存
|
|
if (FEATURES.useTranslateCache) {
|
|
TranslateCacheService.addTranslateCache(inputValue, formattedText);
|
|
}
|
|
|
|
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 currentTranslateType = app.ui.settings.getSettingValue("PromptAssistant.Settings.TranslateType", "baidu");
|
|
const useTranslateCache = app.ui.settings.getSettingValue("PromptAssistant.Features.UseTranslateCache", true);
|
|
|
|
// 获取LLM提供商列表
|
|
let llmProviders = {};
|
|
let currentLLMProvider = null;
|
|
try {
|
|
const resp = await fetch('/prompt_assistant/api/config/llm');
|
|
if (resp.ok) {
|
|
const cfg = await resp.json();
|
|
llmProviders = cfg.providers || {};
|
|
currentLLMProvider = cfg.provider || null;
|
|
}
|
|
} catch (e) {
|
|
logger.error(`获取LLM提供商失败: ${e.message}`);
|
|
}
|
|
|
|
const providerNameMap = { zhipu: '智谱', siliconflow: '硅基流动', "302ai": '302.AI', ollama: 'Ollama', custom: '自定义' };
|
|
|
|
const order = ['zhipu', 'siliconflow', '302ai', 'ollama', 'custom'];
|
|
const providerChildren = order
|
|
.filter(key => Object.prototype.hasOwnProperty.call(llmProviders, key))
|
|
.map(key => ({
|
|
label: providerNameMap[key] || key,
|
|
icon: `<span class="pi ${currentLLMProvider === key ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}"></span>`,
|
|
onClick: async (context) => {
|
|
try {
|
|
const res = await fetch('/prompt_assistant/api/config/llm', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ current_provider: key })
|
|
});
|
|
if (!res.ok) throw new Error(`服务器返回错误: ${res.status}`);
|
|
// 确保切换到LLM翻译
|
|
app.ui.settings.setSettingValue("PromptAssistant.Settings.TranslateType", "llm");
|
|
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: ${providerNameMap[key] || key}`);
|
|
} catch (err) {
|
|
logger.error(`切换LLM提供商失败: ${err.message}`);
|
|
UIToolkit.showStatusTip(context.buttonElement, 'error', `切换失败: ${err.message}`);
|
|
}
|
|
}
|
|
}));
|
|
|
|
return [
|
|
{
|
|
label: "使用百度翻译",
|
|
icon: `<span class="pi ${currentTranslateType === 'baidu' ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}"></span>`,
|
|
onClick: (context) => {
|
|
logger.log("右键菜单 | 动作: 切换到百度翻译");
|
|
app.ui.settings.setSettingValue("PromptAssistant.Settings.TranslateType", "baidu");
|
|
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: 百度翻译`);
|
|
}
|
|
},
|
|
{
|
|
label: "使用大语言模型翻译",
|
|
icon: `<span class="pi ${currentTranslateType === 'llm' ? 'pi-check-circle active-status' : 'pi-circle-off inactive-status'}"></span>`,
|
|
onClick: (context) => {
|
|
logger.log("右键菜单 | 动作: 切换到LLM翻译");
|
|
app.ui.settings.setSettingValue("PromptAssistant.Settings.TranslateType", "llm");
|
|
UIToolkit.showStatusTip(context.buttonElement, 'success', `已切换到: 大语言模型翻译`);
|
|
},
|
|
children: providerChildren
|
|
},
|
|
{ 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;
|
|
|
|
// ---使用for...of循环支持异步操作---
|
|
for (const config of buttonConfigs) {
|
|
if (config.type === 'divider') {
|
|
// 创建分割线但暂不添加
|
|
divider = document.createElement('div');
|
|
divider.className = 'prompt-assistant-divider';
|
|
divider.dataset.id = config.id || `divider_${Date.now()}`;
|
|
// 保存引用
|
|
if (config.id) {
|
|
widget.buttons[config.id] = divider;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 检查按钮是否可见
|
|
if (config.visible === false) {
|
|
// 区分节点类型和功能禁用的情况
|
|
const reason = (widget.nodeInfo?.isNoteNode && config.id !== 'translate') ? "Note节点限制" : "功能禁用";
|
|
// logger.debug(`按钮跳过 | 按钮: ${config.id || 'unknown'} | 原因: ${reason}`);
|
|
continue;
|
|
}
|
|
|
|
// ---异步创建按钮---
|
|
const button = this.addButtonWithIcon(widget, config);
|
|
if (!button) continue;
|
|
|
|
// 设置初始状态
|
|
if (config.initialState) {
|
|
Object.entries(config.initialState).forEach(([stateType, value]) => {
|
|
UIToolkit.setButtonState(widget, config.id, stateType, value);
|
|
});
|
|
}
|
|
|
|
// 根据按钮类型分组
|
|
if (['history', 'undo', 'redo'].includes(config.id)) {
|
|
historyButtons.push(button);
|
|
} else {
|
|
otherButtons.push(button);
|
|
}
|
|
}
|
|
|
|
// 添加按钮到DOM,并在需要时添加分割线
|
|
let addedButtonCount = 0;
|
|
|
|
// 添加历史相关按钮
|
|
historyButtons.forEach(button => {
|
|
widget.innerContent.appendChild(button);
|
|
addedButtonCount++;
|
|
});
|
|
|
|
// 如果两种类型的按钮都存在,添加分割线
|
|
if (historyButtons.length > 0 && otherButtons.length > 0 && divider) {
|
|
widget.innerContent.appendChild(divider);
|
|
}
|
|
|
|
// 添加其他按钮
|
|
otherButtons.forEach(button => {
|
|
widget.innerContent.appendChild(button);
|
|
addedButtonCount++;
|
|
});
|
|
|
|
// 判断节点类型并记录日志
|
|
if (widget.nodeInfo?.isNoteNode) {
|
|
logger.debug(`按钮添加 | 结果:完成 | 节点ID: ${widget.nodeId} | Note节点: 仅显示翻译按钮 | 添加数量: ${addedButtonCount}`);
|
|
} else {
|
|
logger.debug(`按钮添加 | 结果:完成 | 节点ID: ${widget.nodeId} | 标准节点 | 添加数量: ${addedButtonCount}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 添加带图标的按钮
|
|
*/
|
|
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 - 输入框元素
|
|
*/
|
|
_adjustPositionForScrollbar(widget, inputEl) {
|
|
if (!widget?.element || !inputEl) return;
|
|
|
|
const hasScrollbar = this._detectScrollbar(inputEl);
|
|
const containerDiv = widget.element;
|
|
|
|
// 仅在滚动条状态发生变化时更新位置与输出日志
|
|
const prevState = containerDiv.dataset.hasScrollbar === 'true';
|
|
if (prevState === hasScrollbar) {
|
|
return; // 状态未变,不做任何操作
|
|
}
|
|
containerDiv.dataset.hasScrollbar = String(hasScrollbar);
|
|
|
|
// 标准定位模式调整
|
|
const rightOffset = hasScrollbar ? '16px' : '4px'; // 有滚动条时向左偏移12px
|
|
containerDiv.style.right = rightOffset;
|
|
logger.debug(`[位置调整] 标准方案 | 滚动条: ${hasScrollbar} → 偏移: ${rightOffset}`);
|
|
}
|
|
|
|
/**
|
|
* 设置UI位置
|
|
* 使用标准定位方式(仅支持 ComfyUI 0.3.27 及以上版本)
|
|
* @param {Function} onComplete - 定位完成回调,接收boolean参数,true表示成功,false表示失败
|
|
*/
|
|
_setupUIPosition(widget, inputEl, containerDiv, canvasContainerRect, onComplete) {
|
|
const _applyStandardPositioning = (containerDiv, domWidgetContainer) => {
|
|
// 标准模式使用默认的绝对定位,添加位置参数
|
|
containerDiv.style.right = '4px';
|
|
containerDiv.style.bottom = '4px';
|
|
|
|
// 直接添加到dom-widget容器,但不修改dom-widget本身
|
|
domWidgetContainer.appendChild(containerDiv);
|
|
|
|
// 触发回流,确保样式更新
|
|
void containerDiv.offsetWidth;
|
|
|
|
// 强制应用容器高度
|
|
containerDiv.style.height = '20px';
|
|
containerDiv.style.minHeight = '20px';
|
|
void containerDiv.offsetWidth; // 触发回流,确保样式应用
|
|
};
|
|
|
|
// 查找dom-widget父容器(ComfyUI 0.3.27及以上版本的标准容器)
|
|
const findDomWidgetContainer = () => {
|
|
let domWidgetContainer = null;
|
|
// 向上查找dom-widget容器
|
|
let parent = inputEl.parentElement;
|
|
while (parent) {
|
|
if (parent.classList && parent.classList.contains('dom-widget')) {
|
|
domWidgetContainer = parent;
|
|
break;
|
|
}
|
|
parent = parent.parentElement;
|
|
}
|
|
return domWidgetContainer;
|
|
};
|
|
|
|
// 初始查找dom-widget容器
|
|
let domWidgetContainer = findDomWidgetContainer();
|
|
|
|
// 清理函数列表
|
|
widget._eventCleanupFunctions = widget._eventCleanupFunctions || [];
|
|
|
|
// 如果初次查找失败,添加延迟重试机制
|
|
// 这是为了处理新创建节点时DOM可能尚未完全渲染的情况
|
|
if (!domWidgetContainer) {
|
|
// 设置重试次数和间隔
|
|
const maxRetries = 3;
|
|
const retryInterval = 500; // 毫秒
|
|
let retryCount = 0;
|
|
|
|
// 创建重试函数
|
|
const retrySetupPosition = () => {
|
|
// 重新查找dom-widget容器
|
|
domWidgetContainer = findDomWidgetContainer();
|
|
|
|
if (domWidgetContainer) {
|
|
// 找到了dom-widget容器,使用标准定位方案
|
|
// 使用标准定位方案
|
|
_applyStandardPositioning(containerDiv, domWidgetContainer);
|
|
|
|
// 通知定位成功
|
|
if (onComplete) onComplete(true);
|
|
} else if (retryCount < maxRetries) {
|
|
// 继续重试
|
|
retryCount++;
|
|
logger.debug(`定位方式 - 重试查找dom-widget容器 (${retryCount}/${maxRetries})`);
|
|
setTimeout(retrySetupPosition, retryInterval);
|
|
} else {
|
|
// 达到最大重试次数,定位失败
|
|
// 通知定位失败
|
|
if (onComplete) onComplete(false);
|
|
}
|
|
};
|
|
|
|
// 开始重试流程
|
|
setTimeout(retrySetupPosition, retryInterval);
|
|
} else {
|
|
// === 标准方案定位 (ComfyUI 0.3.27及以上) ===
|
|
_applyStandardPositioning(containerDiv, domWidgetContainer);
|
|
|
|
// 通知定位成功
|
|
if (onComplete) onComplete(true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清理单个实例的资源
|
|
*/
|
|
_cleanupInstance(instance, instanceKey, skipRemove = false) {
|
|
try {
|
|
// 检查实例是否有效
|
|
if (!instance) {
|
|
logger.debug(`实例清理 | 结果:跳过 | 实例:${instanceKey || 'unknown'} | 原因:实例不存在`);
|
|
return;
|
|
}
|
|
|
|
// 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 = [];
|
|
}
|
|
|
|
// 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 };
|