/** * 节点帮助文档翻译模块 (Node Help Translator) * 独立模块,用于检测 ComfyUI 侧边栏的节点文档翻译功能 */ import { APIService } from "../services/api.js"; import { logger } from "../utils/logger.js"; import { UIToolkit } from "../utils/UIToolkit.js"; import { createSplitButton, createTooltip } from "./uiComponents.js"; import { PromptFormatter } from "../utils/promptFormatter.js"; class NodeHelpTranslator { constructor() { this.observer = null; this.isObserving = false; this.stateCache = new Map(); this.currentHelpPanel = { nodeName: null, element: null, bilingualSwitch: null }; } initialize() { // 检查功能开关 if (typeof window !== 'undefined' && window.FEATURES && !window.FEATURES.nodeHelpTranslator) { logger.log("[NodeHelpTranslator] 功能已禁用,跳过初始化"); return; } if (this.isObserving) return; logger.log("[NodeHelpTranslator] 初始化监听器"); this.observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.addedNodes.length > 0) { this._handleDomChange(mutation.addedNodes); } } }); this.observer.observe(document.body, { childList: true, subtree: true }); this.isObserving = true; } _handleDomChange(addedNodes) { // 检查功能开关 if (typeof window !== 'undefined' && window.FEATURES && !window.FEATURES.nodeHelpTranslator) { return; } const helpContent = document.querySelector('.node-help-content'); if (helpContent) { this._checkAndInject(); } } _checkAndInject() { // 检查功能开关 if (typeof window !== 'undefined' && window.FEATURES && !window.FEATURES.nodeHelpTranslator) { return; } if (this._injectTimer) clearTimeout(this._injectTimer); this._injectTimer = setTimeout(() => { const helpContent = document.querySelector('.node-help-content'); if (!helpContent) return; const container = helpContent.closest('.flex.flex-col'); if (container) { const header = container.querySelector('.border-b'); if (header) { const titleSpan = header.querySelector('span.font-semibold'); const currentNodeName = titleSpan ? titleSpan.textContent.trim() : 'Unknown'; const existingBtnContainer = header.querySelector('.pa-help-translate-btn-container'); if (!existingBtnContainer) { const sampleText = helpContent.innerText.substring(0, 500); const langDetect = PromptFormatter.detectLanguage(sampleText); if (langDetect.from === 'en') { this._injectTranslateButton(header, currentNodeName, helpContent); } else { logger.log(`[NodeHelpTranslator] 内容为中文或混合语言(${langDetect.from}→${langDetect.to}),不显示翻译按钮`); } } else { const btn = existingBtnContainer.querySelector('button'); if (btn && btn.dataset.nodeName !== currentNodeName) { logger.log(`[NodeHelpTranslator] 检测到节点切换: ${btn.dataset.nodeName} -> ${currentNodeName},重置按钮状态`); this._resetButtonState(btn, currentNodeName); } } } } }, 200); } _injectTranslateButton(header, nodeName, helpContent) { if (!header) return; if (header.querySelector('.pa-help-translate-btn-container')) return; const btnContainer = document.createElement('div'); btnContainer.className = 'pa-help-translate-btn-container'; // 使用 SplitButton const splitBtn = createSplitButton({ label: "翻译文档", icon: "pi pi-language", className: "p-splitbutton-compact p-splitbutton-primary", onClick: (e) => { const btn = splitBtn.container.querySelector('.p-button:first-child'); this._handleTranslateClick(e, btn); }, items: this._getMenuItems(nodeName, 'bilingual') }); const mainBtn = splitBtn.container.querySelector('.p-button:first-child'); mainBtn.classList.add('pa-translate-btn'); mainBtn.dataset.nodeName = nodeName; mainBtn._paSplitBtn = splitBtn; this.currentHelpPanel.splitBtn = splitBtn; this._setButtonContent(mainBtn, 'translate'); btnContainer.appendChild(splitBtn.container); header.appendChild(btnContainer); logger.log(`[NodeHelpTranslator] 已注入翻译按钮(SplitBtn)到节点: ${nodeName}`); if (helpContent) { this._restoreFromCache(mainBtn, helpContent, nodeName); } } _resetButtonState(btn, newNodeName) { btn.dataset.nodeName = newNodeName; this._setButtonContent(btn, 'translate'); btn.disabled = false; if (btn._paSplitBtn) { btn._paSplitBtn.updateMenu(this._getMenuItems(newNodeName, 'bilingual')); } const container = btn.closest('.flex.flex-col'); const helpContent = container ? container.querySelector('.node-help-content') : null; if (helpContent) { this._restoreFromCache(btn, helpContent, newNodeName); } } _setButtonContent(btn, type) { let label = '翻译'; let iconClass = 'pi pi-language pa-translate-btn-icon'; let spinning = false; let tooltip = ''; switch (type) { case 'translate': label = '翻译'; iconClass = 'pi pi-language pa-translate-btn-icon'; tooltip = '使用提示词小助手翻译文档'; break; case 'translating': label = '翻译中...'; iconClass = 'pi pi-spinner pa-translate-btn-icon'; spinning = true; tooltip = '正在翻译文档...'; break; case 'translated': label = '已翻译'; iconClass = 'pi pi-check pa-translate-btn-icon'; tooltip = '使用提示词小助手重新翻译'; break; } if (btn._paSplitBtn) { btn._paSplitBtn.updateMainButton(label, iconClass, spinning); } else { btn.innerHTML = ` ${label} `; } // 更新自定义 Tooltip if (btn._paTooltip) { btn._paTooltip.destroy(); btn._paTooltip = null; } if (tooltip) { btn._paTooltip = createTooltip({ target: btn, content: tooltip, position: 'top' }); } } async _handleTranslateClick(e, btn) { e.stopPropagation(); const nodeName = btn.dataset.nodeName; const header = btn.closest('.border-b'); if (!header) return; const container = header.parentElement; const contentEl = container.querySelector('.node-help-content'); if (!contentEl) { UIToolkit.showStatusTip(btn, 'warn', '未找到帮助内容'); return; } // 允许重新翻译:无论当前状态如何,点击按钮都触发翻译 // 如果想要防抖或避免意外点击,可以检查 btn.disabled (已经在 _performTranslation 中处理) await this._performTranslation(btn, contentEl, nodeName); } async _performTranslation(btn, contentEl, nodeName) { this._setButtonContent(btn, 'translating'); btn.disabled = true; try { // 重置之前的翻译状态(如果有) // 如果已经翻译过,先还原DOM,以便重新提取文本 const existingTranslatable = contentEl.querySelectorAll('.pa-translatable'); if (existingTranslatable.length > 0) { existingTranslatable.forEach(el => { if (el.dataset.paOriginal) { el.innerHTML = el.dataset.paOriginal; } el.classList.remove('pa-translatable'); delete el.dataset.paTranslation; // dataset.paOriginal 保留或删除皆可,保留着也没事,extractTextBlocks 不会利用它,而是读 innerText // 但是为了干净,还是清理一下 delete el.dataset.paOriginal; }); } contentEl.classList.remove('pa-mode-bilingual', 'pa-mode-translation-only', 'pa-mode-original-only'); const textBlocks = this._extractTextBlocks(contentEl); if (textBlocks.length === 0) { UIToolkit.showStatusTip(btn, 'warn', '没有找到可翻译的内容'); this._setButtonContent(btn, 'translate'); // 恢复为待翻译状态 return; } // ... remainder is unchanged logic, but extractTextBlocks comes next const textsToTranslate = textBlocks.map(block => block.text); let translations = []; let isBaidu = false; // 1. 获取翻译配置,判断是否使用百度翻译 try { const configResp = await fetch(APIService.getApiUrl('/config/translate')); if (configResp.ok) { const config = await configResp.json(); if (config.provider === 'baidu') { isBaidu = true; } } } catch (e) { logger.warn(`[NodeHelpTranslator] 获取翻译配置失败: ${e.message},将默认使用LLM`); } // 2. 根据服务类型执行翻译 if (isBaidu) { logger.log('[NodeHelpTranslator] 使用百度翻译API进行翻译'); // 百度批量翻译是串行请求,返回结果数组 const results = await APIService.batchBaiduTranslate(textsToTranslate, 'en', 'zh'); // 提取翻译结果,BaiduTranslateService 返回格式需与 LLM 保持一致 (data.translated) // 如果某个请求失败,result.success 为 false,这里暂时填 null 跳过 translations = results.map(r => { if (r && r.success && r.data && r.data.translated) { return r.data.translated; } return null; }); // 检查是否所有翻译都失败了 const successCount = translations.filter(t => t !== null).length; if (successCount === 0 && textsToTranslate.length > 0) { throw new Error('所有文本翻译失败 (Baidu)'); } } else { logger.log('[NodeHelpTranslator] 使用LLM API进行批量翻译'); const result = await APIService.llmBatchTranslate(textsToTranslate, 'en', 'zh'); if (result.success && result.data && result.data.translations) { translations = result.data.translations; } else { throw new Error(result.error || '翻译接口返回错误'); } } // 3. 渲染结果 const cleanedTranslations = translations.map(t => t ? t.replace(/^\|\s*/, '') : t); this._renderTranslations(textBlocks, cleanedTranslations, nodeName); this.stateCache.set(nodeName, { translated: true, viewMode: 'bilingual' }); if (this.currentHelpPanel.splitBtn) { this.currentHelpPanel.splitBtn.updateMenu(this._getMenuItems(nodeName, 'bilingual')); } this._setButtonContent(btn, 'translated'); } catch (error) { logger.error(`[NodeHelpTranslator] 翻译失败: ${error.message}`); UIToolkit.showStatusTip(btn, 'error', '翻译失败: ' + error.message); this._setButtonContent(btn, 'translate'); btn.disabled = false; } finally { btn.disabled = false; } } /** * 提取文本块 */ _extractTextBlocks(rootEl) { const blocks = []; // 优化1:不提取 th (表头),因为无需翻译 const candidates = rootEl.querySelectorAll('p, li, h1, h2, h3, h4, h5, h6, td'); for (const el of candidates) { const text = el.innerText.trim(); if (!text) continue; // 过滤纯数字和标点 if (/^[0-9\s\p{P}]+$/u.test(text)) continue; // 优化2:过滤看似“类型”定义的纯大写单词 (如 IMAGE, FLOAT, BOOLEAN, COMBO等) // 允许特定符号如下划线,但不包含空格(通常类型是一个单词) // 限制长度<30,防止误伤全大写的长句子 // 注意:参数名通常是 snake_case (小写),所以不会被过滤 if (/^[A-Z0-9_]+$/.test(text) && text.length < 30) continue; if (el.closest('pre') || el.closest('code')) continue; if (el.classList.contains('pa-translatable')) continue; if (el.closest('.pa-translatable')) continue; const hasBlockChildren = Array.from(el.children).some(child => ['P', 'DIV', 'LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TABLE', 'UL', 'OL'].includes(child.tagName) ); if (hasBlockChildren) continue; blocks.push({ element: el, text: text }); } return blocks; } _renderTranslations(blocks, translations, nodeName) { // 保存用于缓存的数据 const cacheData = []; blocks.forEach((block, index) => { const translation = translations[index]; if (!translation) return; const el = block.element; el.classList.add('pa-translatable'); if (!el.dataset.paOriginal) { el.dataset.paOriginal = el.innerHTML; } el.dataset.paTranslation = translation; this._updateBlockContent(el, 'bilingual'); // 收集缓存数据(存储原始HTML的哈希或简单选择器作为标识可能不可靠,因为DOM结构变化) // 这里我们使用一种简单策略:按顺序存储。 // 只要帮助文档内容没变,顺序提取的文本块也应该没变。 cacheData.push({ index: index, // 保存索引,以便恢复时匹配 translation: translation }); }); // 保存到缓存 if (nodeName) { this._saveToCache(nodeName, { translations: cacheData, timestamp: Date.now(), bilingual: true }); } } _updateBlockContent(el, mode) { const original = el.dataset.paOriginal || ''; const translation = el.dataset.paTranslation || ''; // Wrap original content to allow toggling visibility const wrappedOriginal = `${original}`; const separator = ``; const styledTranslation = `${translation.replace(/\n/g, '
')}
`; el.innerHTML = `${wrappedOriginal}${separator}${styledTranslation}`; } // --- 缓存管理 --- _getCacheKey() { return 'pa_node_help_translations'; } _loadFromCache(nodeName) { try { const raw = sessionStorage.getItem(this._getCacheKey()); if (!raw) return null; const cache = JSON.parse(raw); return cache[nodeName] || null; } catch (e) { console.error('[NodeHelpTranslator] 读取缓存失败', e); return null; } } _saveToCache(nodeName, data) { try { const key = this._getCacheKey(); const raw = sessionStorage.getItem(key); let cache = raw ? JSON.parse(raw) : {}; cache[nodeName] = data; // 简单的清理策略:如果太大了,清空旧的(虽然sessionStorage限制是按域名,但也要防止无限增长) // 这里暂不实现复杂LRU,假设用户单次会话查看的节点有限 sessionStorage.setItem(key, JSON.stringify(cache)); } catch (e) { console.error('[NodeHelpTranslator] 写入缓存失败', e); } } _restoreFromCache(btn, contentEl, nodeName) { const cached = this._loadFromCache(nodeName); if (!cached) return false; // 检查是否过期(例如超过24小时?其实sessionStorage已经限制了会话生命周期,这里可以不检查时间) // 但检查是否匹配当前内容结构很重要。 // 我们尝试按索引恢复。 const textBlocks = this._extractTextBlocks(contentEl); if (textBlocks.length === 0) return false; // 简单的完整性检查:如果缓存的索引超出了当前文本块数量,可能内容变了 const maxIndex = Math.max(...cached.translations.map(t => t.index)); if (maxIndex >= textBlocks.length) { logger.warn(`[NodeHelpTranslator] 缓存与当前内容不匹配(索引溢出),跳过恢复: ${nodeName}`); return false; } // 恢复翻译 // 构建全量的 translations 数组 // cached.translations 是稀疏的,只包含有翻译的块 const appliedTranslations = new Array(textBlocks.length).fill(null); let matchCount = 0; cached.translations.forEach(item => { if (item.index < textBlocks.length) { // 可选:对比一下原文内容是否大致匹配?(防止内容微调导致错位) // 这里为了性能暂不对比原文,严格依赖提取顺序 appliedTranslations[item.index] = item.translation; matchCount++; } }); if (matchCount === 0) return false; // 渲染 this._renderTranslations(textBlocks, appliedTranslations, null); // passing null as nodeName to avoid recursive save // 恢复状态 const viewMode = cached.viewMode || (cached.bilingual !== false ? 'bilingual' : 'translation-only'); this.stateCache.set(nodeName, { translated: true, viewMode: viewMode }); // 更新按钮状态 this._setButtonContent(btn, 'translated'); // 更新 SplitButton 菜单 if (btn._paSplitBtn) { btn._paSplitBtn.updateMenu(this._getMenuItems(nodeName, viewMode)); } // 应用显示模式 this._applyViewMode(contentEl, viewMode); logger.log(`[NodeHelpTranslator] 已从缓存恢复翻译: ${nodeName}`); return true; } cleanup() { // 清理定时器 if (this._injectTimer) { clearTimeout(this._injectTimer); this._injectTimer = null; } // 停止并清理 MutationObserver if (this.observer) { this.observer.disconnect(); this.observer = null; } this.isObserving = false; // 移除所有已注入的翻译按钮 const translationButtons = document.querySelectorAll('.pa-help-translate-btn-container'); translationButtons.forEach(btn => { // 清理 tooltip const mainBtn = btn.querySelector('.pa-translate-btn'); if (mainBtn && mainBtn._paTooltip) { mainBtn._paTooltip.destroy(); mainBtn._paTooltip = null; } // 移除按钮容器 btn.remove(); }); // 清理状态缓存 this.stateCache.clear(); // 重置当前帮助面板引用 this.currentHelpPanel = { nodeName: null, element: null, bilingualSwitch: null }; logger.log("[NodeHelpTranslator] 已清理所有资源"); } /** * 应用视图模式 * @param {HTMLElement} contentEl 内容元素 * @param {string} mode 'bilingual' | 'translation-only' | 'original-only' */ _applyViewMode(contentEl, mode) { if (!contentEl) return; contentEl.classList.remove('pa-mode-bilingual', 'pa-mode-translation-only', 'pa-mode-original-only'); switch (mode) { case 'bilingual': contentEl.classList.add('pa-mode-bilingual'); break; case 'translation-only': contentEl.classList.add('pa-mode-translation-only'); break; case 'original-only': contentEl.classList.add('pa-mode-original-only'); break; } } /** * 获取下拉菜单配置 * @param {string} nodeName * @param {string} currentMode * @returns {Array} */ _getMenuItems(nodeName, currentMode) { return [ { label: '中英对照', checked: currentMode === 'bilingual', command: () => this._handleModeChange(nodeName, 'bilingual') }, { label: '显示译文', checked: currentMode === 'translation-only', command: () => this._handleModeChange(nodeName, 'translation-only') }, { label: '显示原文', checked: currentMode === 'original-only', command: () => this._handleModeChange(nodeName, 'original-only') }, { separator: true }, { label: '重新翻译', icon: 'pi pi-refresh', command: () => { const btn = document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`); if (btn) btn.click(); } } ]; } async _handleModeChange(nodeName, mode) { const state = this.stateCache.get(nodeName); if (!state) return; state.viewMode = mode; this.stateCache.set(nodeName, state); const btn = document.querySelector(`.pa-translate-btn[data-node-name="${nodeName}"]`); if (!btn) return; // Find header then panel then content const header = btn.closest('.border-b') || btn.closest('.pa-help-translate-btn-container').parentElement; if (!header) return; // If header is inside a container which has sibling content const container = header.parentElement; const contentEl = container.querySelector('.node-help-content'); if (contentEl) { this._applyViewMode(contentEl, mode); } const cachedData = this._loadFromCache(nodeName); if (cachedData) { this._saveToCache(nodeName, { ...cachedData, viewMode: mode }); } if (this.currentHelpPanel.splitBtn) { this.currentHelpPanel.splitBtn.updateMenu(this._getMenuItems(nodeName, mode)); } } } export const nodeHelpTranslator = new NodeHelpTranslator();