您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
ParaTranz文本替换和AI翻译功能拓展。
// ==UserScript== // @name ParaTranz-AI // @namespace http://tampermonkey.net/ // @version 1.4.4 // @description ParaTranz文本替换和AI翻译功能拓展。 // @author HCPTangHY // @license WTFPL // @match https://paratranz.cn/* // @icon https://paratranz.cn/favicon.png // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/diff.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/bundles/js/diff2html-ui.min.js // @resource css https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css // @grant GM_getResourceURL // @grant GM_getResourceText // @grant GM_addStyle // ==/UserScript== const PARATRANZ_AI_TOAST_STYLES = ` /* Toast Notifications */ #toast-container-paratranz-ai { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 10000; display: flex; flex-direction: column-reverse; align-items: center; pointer-events: none; /* Allow clicks to pass through the container */ } .toast-message { padding: 10px 20px; margin-top: 10px; border-radius: 5px; color: white; box-shadow: 0 2px 10px rgba(0,0,0,0.2); opacity: 0; transform: translateY(20px); transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out; min-width: 250px; max-width: 80vw; text-align: center; pointer-events: all; /* Individual toasts should be interactive if needed */ } .toast-message.show { opacity: 1; transform: translateY(0); } .toast-message.toast-success { background-color: #28a745; } .toast-message.toast-error { background-color: #dc3545; } .toast-message.toast-warning { background-color: #ffc107; color: black; } .toast-message.toast-info { background-color: #17a2b8; } `; GM_addStyle(GM_getResourceText("css") + PARATRANZ_AI_TOAST_STYLES); // fork from HeliumOctahelide https://greasyfork.org/zh-CN/scripts/503063-paratranz-tools (function() { 'use strict'; // Helper function for Toast Notifications function showToast(message, type = 'info', duration = 3000) { let toastContainer = document.getElementById('toast-container-paratranz-ai'); if (!toastContainer) { toastContainer = document.createElement('div'); toastContainer.id = 'toast-container-paratranz-ai'; document.body.appendChild(toastContainer); } const toast = document.createElement('div'); toast.className = `toast-message toast-${type}`; toast.textContent = message; toastContainer.appendChild(toast); // Animate in requestAnimationFrame(() => { toast.classList.add('show'); }); // Auto-dismiss setTimeout(() => { toast.classList.remove('show'); toast.addEventListener('transitionend', () => { if (toast.parentElement) { // Check if still attached toast.remove(); } if (toastContainer && !toastContainer.hasChildNodes()) { // Check if toastContainer is still in the DOM before removing if (toastContainer.parentElement) { toastContainer.remove(); } } }, { once: true }); }, duration); } // 基类定义 class BaseComponent { constructor(selector) { this.selector = selector; this.init(); } init() { this.checkExistence(); } checkExistence() { const element = document.querySelector(this.selector); if (!element) { this.insert(); } setTimeout(() => this.checkExistence(), 1000); } insert() { // 留空,子类实现具体插入逻辑 } } // 按钮类定义,继承自BaseComponent class Button extends BaseComponent { constructor(selector, toolbarSelector, htmlContent, callback) { super(selector); this.toolbarSelector = toolbarSelector; this.htmlContent = htmlContent; this.callback = callback; } insert() { const toolbar = document.querySelector(this.toolbarSelector); if (!toolbar) { console.log(`Toolbar not found: ${this.toolbarSelector}`); return; } if (toolbar && !document.querySelector(this.selector)) { const button = document.createElement('button'); button.className = this.selector.split('.').join(' '); button.innerHTML = this.htmlContent; button.type = 'button'; button.addEventListener('click', this.callback); toolbar.insertAdjacentElement('afterbegin', button); console.log(`Button inserted: ${this.selector}`); } } } // 手风琴类定义,继承自BaseComponent class Accordion extends BaseComponent { constructor(selector, parentSelector) { super(selector); this.parentSelector = parentSelector; } insert() { const parentElement = document.querySelector(this.parentSelector); if (!parentElement) { console.log(`Parent element not found: ${this.parentSelector}`); return; } if (parentElement && !document.querySelector(this.selector)) { const accordionHTML = ` <div class="accordion" id="accordionExample"></div> <hr> `; parentElement.insertAdjacentHTML('afterbegin', accordionHTML); } } addCard(card) { card.insert(); } } // 卡片类定义,继承自BaseComponent class Card extends BaseComponent { constructor(selector, parentSelector, headingId, title, contentHTML) { super(selector); this.parentSelector = parentSelector; this.headingId = headingId; this.title = title; this.contentHTML = contentHTML; } insert() { const parentElement = document.querySelector(this.parentSelector); if (!parentElement) { console.log(`Parent element not found: ${this.parentSelector}`); return; } if (parentElement && !document.querySelector(this.selector)) { const cardHTML = ` <div class="card m-0"> <div class="card-header p-0" id="${this.headingId}"> <h2 class="mb-0"> <button class="btn btn-link" type="button" aria-expanded="false" aria-controls="${this.selector.substring(1)}"> ${this.title} </button> </h2> </div> <div id="${this.selector.substring(1)}" class="collapse" aria-labelledby="${this.headingId}" data-parent="#accordionExample" style="max-height: 70vh; overflow-y: auto;"> <div class="card-body"> ${this.contentHTML} </div> </div> </div> `; parentElement.insertAdjacentHTML('beforeend', cardHTML); const toggleButton = document.querySelector(`#${this.headingId} button`); const collapseDiv = document.querySelector(this.selector); toggleButton.addEventListener('click', function() { if (collapseDiv.style.maxHeight === '0px' || !collapseDiv.style.maxHeight) { collapseDiv.style.display = 'block'; requestAnimationFrame(() => { collapseDiv.style.maxHeight = collapseDiv.scrollHeight + 'px'; }); toggleButton.setAttribute('aria-expanded', 'true'); } else { collapseDiv.style.maxHeight = '0px'; toggleButton.setAttribute('aria-expanded', 'false'); collapseDiv.addEventListener('transitionend', () => { if (collapseDiv.style.maxHeight === '0px') { collapseDiv.style.display = 'none'; } }, { once: true }); } }); collapseDiv.style.maxHeight = '0px'; collapseDiv.style.overflow = 'hidden'; collapseDiv.style.transition = 'max-height 0.3s ease'; } } } // 定义具体的文本替换管理卡片 class StringReplaceCard extends Card { constructor(parentSelector) { const headingId = 'headingOne'; const contentHTML = ` <div id="manageReplacePage"> <div id="replaceListContainer"></div> <div class="replace-item mb-3 p-2" style="border: 1px solid #ccc; border-radius: 8px;"> <input type="text" placeholder="查找文本" id="newFindText" class="form-control mb-2"/> <input type="text" placeholder="替换为" id="newReplacementText" class="form-control mb-2"/> <button class="btn btn-secondary" id="addReplaceRuleButton"> <i class="far fa-plus-circle"></i> 添加替换规则 </button> </div> <div class="mt-3"> <button class="btn btn-primary" id="exportReplaceRulesButton">导出替换规则</button> <input type="file" id="importReplaceRuleInput" class="d-none"/> <button class="btn btn-primary" id="importReplaceRuleButton">导入替换规则</button> </div> </div> `; super('#collapseOne', parentSelector, headingId, '文本替换', contentHTML); } insert() { super.insert(); if (!document.querySelector('#collapseOne')) { return; } document.getElementById('addReplaceRuleButton').addEventListener('click', this.addReplaceRule); document.getElementById('exportReplaceRulesButton').addEventListener('click', this.exportReplaceRules); document.getElementById('importReplaceRuleButton').addEventListener('click', () => { document.getElementById('importReplaceRuleInput').click(); }); document.getElementById('importReplaceRuleInput').addEventListener('change', this.importReplaceRules); this.loadReplaceList(); } addReplaceRule = () => { const findText = document.getElementById('newFindText').value; const replacementText = document.getElementById('newReplacementText').value; if (findText) { const replaceList = JSON.parse(localStorage.getItem('replaceList')) || []; replaceList.push({ findText, replacementText, disabled: false }); localStorage.setItem('replaceList', JSON.stringify(replaceList)); this.loadReplaceList(); document.getElementById('newFindText').value = ''; document.getElementById('newReplacementText').value = ''; } }; updateRuleText(index, type, value) { const replaceList = JSON.parse(localStorage.getItem('replaceList')) || []; if (replaceList[index]) { if (type === 'findText') { replaceList[index].findText = value; } else if (type === 'replacementText') { replaceList[index].replacementText = value; } localStorage.setItem('replaceList', JSON.stringify(replaceList)); } } loadReplaceList() { const replaceList = JSON.parse(localStorage.getItem('replaceList')) || []; const replaceListDiv = document.getElementById('replaceListContainer'); replaceListDiv.innerHTML = ''; // Add scrollbar when rules are too many replaceListDiv.style.maxHeight = '40vh'; // Adjust as needed replaceListDiv.style.overflowY = 'auto'; replaceList.forEach((rule, index) => { const ruleDiv = document.createElement('div'); ruleDiv.className = 'replace-item mb-3 p-2'; ruleDiv.style.border = '1px solid #ccc'; ruleDiv.style.borderRadius = '8px'; ruleDiv.style.transition = 'transform 0.3s'; ruleDiv.style.backgroundColor = rule.disabled ? '#f2dede' : '#fff'; const inputsDiv = document.createElement('div'); inputsDiv.className = 'mb-2'; const findInput = document.createElement('input'); findInput.type = 'text'; findInput.className = 'form-control mb-1'; findInput.value = rule.findText; findInput.placeholder = '查找文本'; findInput.dataset.index = index; findInput.addEventListener('change', (event) => this.updateRuleText(index, 'findText', event.target.value)); inputsDiv.appendChild(findInput); const replInput = document.createElement('input'); replInput.type = 'text'; replInput.className = 'form-control'; replInput.value = rule.replacementText; replInput.placeholder = '替换为'; replInput.dataset.index = index; replInput.addEventListener('change', (event) => this.updateRuleText(index, 'replacementText', event.target.value)); inputsDiv.appendChild(replInput); ruleDiv.appendChild(inputsDiv); const buttonsDiv = document.createElement('div'); buttonsDiv.className = 'd-flex justify-content-between'; const leftButtonGroup = document.createElement('div'); leftButtonGroup.className = 'btn-group'; leftButtonGroup.setAttribute('role', 'group'); const moveUpButton = this.createButton('上移', 'fas fa-arrow-up', () => this.moveReplaceRule(index, -1)); const moveDownButton = this.createButton('下移', 'fas fa-arrow-down', () => this.moveReplaceRule(index, 1)); const toggleButton = this.createButton('禁用/启用', rule.disabled ? 'fas fa-toggle-off' : 'fas fa-toggle-on', () => this.toggleReplaceRule(index)); const applyButton = this.createButton('应用此规则', 'fas fa-play', () => this.applySingleReplaceRule(index)); leftButtonGroup.append(moveUpButton, moveDownButton, toggleButton, applyButton); const rightButtonGroup = document.createElement('div'); rightButtonGroup.className = 'btn-group'; rightButtonGroup.setAttribute('role', 'group'); const deleteButton = this.createButton('删除', 'far fa-trash-alt', () => this.deleteReplaceRule(index), 'btn-danger'); rightButtonGroup.appendChild(deleteButton); buttonsDiv.append(leftButtonGroup, rightButtonGroup); ruleDiv.appendChild(buttonsDiv); replaceListDiv.appendChild(ruleDiv); }); replaceListDiv.style.display = 'none'; replaceListDiv.offsetHeight; replaceListDiv.style.display = ''; } createButton(title, iconClass, onClick, btnClass = 'btn-secondary') { const button = document.createElement('button'); button.className = `btn ${btnClass}`; button.title = title; button.innerHTML = `<i class="${iconClass}"></i>`; button.addEventListener('click', onClick); return button; } deleteReplaceRule(index) { const replaceList = JSON.parse(localStorage.getItem('replaceList')) || []; replaceList.splice(index, 1); localStorage.setItem('replaceList', JSON.stringify(replaceList)); this.loadReplaceList(); } toggleReplaceRule(index) { const replaceList = JSON.parse(localStorage.getItem('replaceList')) || []; replaceList[index].disabled = !replaceList[index].disabled; localStorage.setItem('replaceList', JSON.stringify(replaceList)); this.loadReplaceList(); } applySingleReplaceRule(index) { const replaceList = JSON.parse(localStorage.getItem('replaceList')) || []; const rule = replaceList[index]; if (rule.disabled || !rule.findText) return; const textareas = document.querySelectorAll('textarea.translation.form-control'); textareas.forEach(textarea => { let text = textarea.value; text = text.replaceAll(rule.findText, rule.replacementText); this.simulateInputChange(textarea, text); }); } moveReplaceRule(index, direction) { const replaceList = JSON.parse(localStorage.getItem('replaceList')) || []; const newIndex = index + direction; if (newIndex >= 0 && newIndex < replaceList.length) { const [movedItem] = replaceList.splice(index, 1); replaceList.splice(newIndex, 0, movedItem); localStorage.setItem('replaceList', JSON.stringify(replaceList)); this.loadReplaceListWithAnimation(index, newIndex); } } loadReplaceListWithAnimation(oldIndex, newIndex) { const replaceListDiv = document.getElementById('replaceListContainer'); const items = replaceListDiv.querySelectorAll('.replace-item'); if (items[oldIndex] && items[newIndex]) { items[oldIndex].style.transform = `translateY(${(newIndex - oldIndex) * 100}%)`; items[newIndex].style.transform = `translateY(${(oldIndex - newIndex) * 100}%)`; } setTimeout(() => { this.loadReplaceList(); }, 300); } simulateInputChange(element, newValue) { const inputEvent = new Event('input', { bubbles: true }); const originalValue = element.value; element.value = newValue; const tracker = element._valueTracker; if (tracker) { tracker.setValue(originalValue); } element.dispatchEvent(inputEvent); } exportReplaceRules() { const replaceList = JSON.parse(localStorage.getItem('replaceList')) || []; const json = JSON.stringify(replaceList, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'replaceList.json'; a.click(); URL.revokeObjectURL(url); } importReplaceRules(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = e => { try { const content = e.target.result; const importedList = JSON.parse(content); if (Array.isArray(importedList) && importedList.every(item => typeof item.findText === 'string' && typeof item.replacementText === 'string')) { localStorage.setItem('replaceList', JSON.stringify(importedList)); this.loadReplaceList(); showToast('替换规则导入成功!', 'success'); } else { showToast('导入的文件格式不正确。', 'error'); } } catch (error) { console.error('Error importing rules:', error); showToast('导入失败,文件可能已损坏或格式不正确。', 'error'); } }; reader.readAsText(file); event.target.value = null; } } // 定义具体的机器翻译卡片 class MachineTranslationCard extends Card { constructor(parentSelector) { const headingId = 'headingTwo'; const contentHTML = ` <button class="btn btn-primary" id="openTranslationConfigButton">配置翻译</button> <div class="mt-3"> <div class="d-flex"> <textarea id="originalText" class="form-control" style="width: 100%; height: 25vh;"></textarea> <div class="d-flex flex-column ml-2"> <button class="btn btn-secondary mb-2" id="copyOriginalButton"> <i class="fas fa-copy"></i> </button> <button class="btn btn-secondary" id="translateButton"> <i class="fas fa-globe"></i> </button> </div> </div> <div class="d-flex mt-2"> <textarea id="translatedText" class="form-control" style="width: 100%; height: 25vh;"></textarea> <div class="d-flex flex-column ml-2"> <button class="btn btn-secondary mb-2" id="pasteTranslationButton"> <i class="fas fa-arrow-alt-left"></i> </button> <button class="btn btn-secondary" id="copyTranslationButton"> <i class="fas fa-copy"></i> </button> </div> </div> </div> <!-- Translation Configuration Modal --> <div class="modal" id="translationConfigModal" tabindex="-1" role="dialog" style="display: none;"> <div class="modal-dialog modal-lg" role="document"> <!-- Added modal-lg --> <div class="modal-content"> <div class="modal-header py-2"> <h5 class="modal-title">翻译配置</h5> <button type="button" class="close" id="closeTranslationConfigModal" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> <div class="modal-body p-3" style="max-height: 80vh; overflow-y: auto;"> <!-- Increased max-height, added p-3 --> <form id="translationConfigForm"> <div class="form-row"> <div class="form-group col-md-7"> <label for="apiConfigSelect">API 配置</label> <select class="custom-select" id="apiConfigSelect"> <option value="" selected>选择或新建配置...</option> </select> </div> <div class="form-group col-md-5 d-flex align-items-end"> <button type="button" class="btn btn-success mr-2 w-100" id="saveApiConfigButton" title="保存或更新当前填写的配置"><i class="fas fa-save"></i> 保存</button> <button type="button" class="btn btn-info mr-2 w-100" id="newApiConfigButton" title="清空表单以新建配置"><i class="fas fa-plus-circle"></i> 新建</button> <button type="button" class="btn btn-danger w-100" id="deleteApiConfigButton" title="删除下拉框中选中的配置"><i class="fas fa-trash-alt"></i> 删除</button> </div> </div> <hr> <p><strong>当前配置详情:</strong></p> <div class="form-row"> <div class="form-group col-md-6"> <label for="apiConfigName">配置名称</label> <input type="text" class="form-control" id="apiConfigName" placeholder="为此配置命名 (例如 My OpenAI)"> </div> <div class="form-group col-md-6"> <label for="apiKey">API Key</label> <input type="text" class="form-control" id="apiKey" placeholder="Enter API key"> </div> </div> <div class="form-group"> <label for="baseUrl">Base URL</label> <div class="input-group"> <input type="text" class="form-control" id="baseUrl" placeholder="Enter base URL"> <div class="input-group-append"> <button class="btn btn-outline-secondary" type="button" title="OpenAI API" id="openaiButton"> <img src="https://paratranz.cn/media/f2014e0647283fcff54e3a8f4edaa488.png!webp160" style="width: 16px; height: 16px;"> </button> <button class="btn btn-outline-secondary" type="button" title="DeepSeek API" id="deepseekButton"> <img src="https://paratranz.cn/media/0bfd294f99b9141e3432c0ffbf3d8e78.png!webp160" style="width: 16px; height: 16px;"> </button> </div> </div> <small id="fullUrlPreview" class="form-text text-muted mt-1" style="word-break: break-all;"></small> </div> <div class="form-row"> <div class="form-group col-md-8"> <label for="model">Model</label> <div class="input-group"> <input type="text" class="form-control" id="model" placeholder="Enter model (e.g., gpt-4o-mini)" list="modelDatalist"> <datalist id="modelDatalist"></datalist> <div class="input-group-append"> <button class="btn btn-outline-secondary" type="button" id="fetchModelsButton" title="Fetch Models from API"> <i class="fas fa-sync-alt"></i> </button> </div> </div> </div> <div class="form-group col-md-4"> <label for="temperature">Temperature</label> <input type="number" step="0.1" class="form-control" id="temperature" placeholder="e.g., 0.7"> </div> </div> <div class="form-group"> <label for="prompt">Prompt</label> <textarea class="form-control" id="prompt" rows="3" placeholder="Enter prompt or use default prompt. 可用变量: {{original}}, {{context}}, {{terms}}"></textarea> </div> <div class="form-group"> <label for="promptLibrarySelect">Prompt 库</label> <div class="input-group"> <select class="custom-select" id="promptLibrarySelect"> <option value="" selected>从库中选择或管理...</option> </select> <div class="input-group-append"> <button class="btn btn-outline-secondary" type="button" id="saveToPromptLibraryButton" title="保存当前Prompt到库"><i class="fas fa-save"></i></button> <button class="btn btn-outline-danger" type="button" id="deleteFromPromptLibraryButton" title="从库中删除选定Prompt"><i class="fas fa-trash-alt"></i></button> </div> </div> </div> <div class="form-group"> <label>自动化选项</label> <div class="d-flex"> <div class="custom-control custom-switch mr-3"> <input type="checkbox" class="custom-control-input" id="autoTranslateToggle"> <label class="custom-control-label" for="autoTranslateToggle">自动翻译</label> </div> <div class="custom-control custom-switch"> <input type="checkbox" class="custom-control-input" id="autoPasteToggle"> <label class="custom-control-label" for="autoPasteToggle">自动粘贴</label> </div> </div> <small class="form-text text-muted">自动翻译:进入新条目时自动翻译 / 自动粘贴:翻译完成后自动填充到翻译框</small> </div> </form> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" id="closeTranslationConfigModalButton">关闭</button> </div> </div> </div> </div> `; super('#collapseTwo', parentSelector, headingId, '机器翻译', contentHTML); } insert() { super.insert(); if (!document.querySelector('#collapseTwo')) { return; } const translationConfigModal = document.getElementById('translationConfigModal'); document.getElementById('openTranslationConfigButton').addEventListener('click', function() { translationConfigModal.style.display = 'block'; }); function closeModal() { translationConfigModal.style.display = 'none'; } document.getElementById('closeTranslationConfigModal').addEventListener('click', closeModal); document.getElementById('closeTranslationConfigModalButton').addEventListener('click', closeModal); const apiConfigSelect = document.getElementById('apiConfigSelect'); const saveApiConfigButton = document.getElementById('saveApiConfigButton'); const newApiConfigButton = document.getElementById('newApiConfigButton'); const deleteApiConfigButton = document.getElementById('deleteApiConfigButton'); const apiConfigNameInput = document.getElementById('apiConfigName'); const baseUrlInput = document.getElementById('baseUrl'); const apiKeyInput = document.getElementById('apiKey'); const modelSelect = document.getElementById('model'); // This is now an input text field const fetchModelsButton = document.getElementById('fetchModelsButton'); const promptInput = document.getElementById('prompt'); const temperatureInput = document.getElementById('temperature'); const autoTranslateToggle = document.getElementById('autoTranslateToggle'); const autoPasteToggle = document.getElementById('autoPasteToggle'); const promptLibrarySelect = document.getElementById('promptLibrarySelect'); const saveToPromptLibraryButton = document.getElementById('saveToPromptLibraryButton'); const deleteFromPromptLibraryButton = document.getElementById('deleteFromPromptLibraryButton'); // API Config related functions are now defined in IIFE scope function updateActiveConfigField(fieldName, value) { const activeConfigName = getCurrentApiConfigName(); if (activeConfigName) { let configs = getApiConfigurations(); const activeConfigIndex = configs.findIndex(c => c.name === activeConfigName); if (activeConfigIndex > -1) { configs[activeConfigIndex][fieldName] = value; saveApiConfigurations(configs); // console.log(`Field '${fieldName}' for active config '${activeConfigName}' updated to '${value}' and saved.`); } } } function updateFullUrlPreview(baseUrl) { const fullUrlPreview = document.getElementById('fullUrlPreview'); if (baseUrl) { const fullUrl = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}chat/completions`; fullUrlPreview.textContent = `完整URL: ${fullUrl}`; } else { fullUrlPreview.textContent = ''; } } function populateApiConfigSelect() { const configs = getApiConfigurations(); const currentConfigName = getCurrentApiConfigName(); apiConfigSelect.innerHTML = '<option value="">选择或新建配置...</option>'; // Changed placeholder configs.forEach(config => { const option = document.createElement('option'); option.value = config.name; option.textContent = config.name; if (config.name === currentConfigName) { option.selected = true; } apiConfigSelect.appendChild(option); }); } function clearConfigForm() { apiConfigNameInput.value = ''; baseUrlInput.value = ''; apiKeyInput.value = ''; // Optionally reset model, prompt, temp, toggles to defaults or leave them // modelSelect.value = 'gpt-4o-mini'; // promptInput.value = ''; // temperatureInput.value = ''; // autoTranslateToggle.checked = false; // autoPasteToggle.checked = false; updateFullUrlPreview(''); apiConfigSelect.value = ""; // Reset dropdown to placeholder } function loadConfigToUI(configName) { const configs = getApiConfigurations(); const config = configs.find(c => c.name === configName); if (config) { apiConfigNameInput.value = config.name; baseUrlInput.value = config.baseUrl; apiKeyInput.value = config.apiKey; modelSelect.value = config.model || localStorage.getItem('model') || 'gpt-4o-mini'; promptInput.value = config.prompt || localStorage.getItem('prompt') || ''; temperatureInput.value = config.temperature || localStorage.getItem('temperature') || ''; autoTranslateToggle.checked = config.autoTranslateEnabled !== undefined ? config.autoTranslateEnabled : (localStorage.getItem('autoTranslateEnabled') === 'true'); autoPasteToggle.checked = config.autoPasteEnabled !== undefined ? config.autoPasteEnabled : (localStorage.getItem('autoPasteEnabled') === 'true'); setCurrentApiConfigName(config.name); apiConfigSelect.value = config.name; // Ensure dropdown reflects loaded config } else { clearConfigForm(); // Clear form if no specific config is loaded (e.g., "Select or create new") } updateFullUrlPreview(baseUrlInput.value); } // Initial load populateApiConfigSelect(); const activeConfigName = getCurrentApiConfigName(); if (activeConfigName) { loadConfigToUI(activeConfigName); } else { // Try to migrate old settings if no new config is active const oldBaseUrl = localStorage.getItem('baseUrl'); // Check for old individual settings const oldApiKey = localStorage.getItem('apiKey'); if (oldBaseUrl && oldApiKey && !getApiConfigurations().length) { // Migrate only if no new configs exist const defaultConfigName = "默认迁移配置"; const newConfig = { name: defaultConfigName, baseUrl: oldBaseUrl, apiKey: oldApiKey, model: localStorage.getItem('model') || 'gpt-4o-mini', prompt: localStorage.getItem('prompt') || '', temperature: localStorage.getItem('temperature') || '', autoTranslateEnabled: localStorage.getItem('autoTranslateEnabled') === 'true', autoPasteEnabled: localStorage.getItem('autoPasteEnabled') === 'true' }; let configs = getApiConfigurations(); configs.push(newConfig); saveApiConfigurations(configs); setCurrentApiConfigName(defaultConfigName); populateApiConfigSelect(); loadConfigToUI(defaultConfigName); // Optionally remove old keys after successful migration // localStorage.removeItem('baseUrl'); localStorage.removeItem('apiKey'); } else { // If no active config and no old settings to migrate, or if configs already exist, load general settings. modelSelect.value = localStorage.getItem('model') || 'gpt-4o-mini'; promptInput.value = localStorage.getItem('prompt') || ''; temperatureInput.value = localStorage.getItem('temperature') || ''; autoTranslateToggle.checked = localStorage.getItem('autoTranslateEnabled') === 'true'; autoPasteToggle.checked = localStorage.getItem('autoPasteEnabled') === 'true'; clearConfigForm(); // Start with a clean slate for API specific parts if no config selected } } apiConfigSelect.addEventListener('change', function() { if (this.value) { loadConfigToUI(this.value); } else { clearConfigForm(); // User selected "Select or create new...", so we clear the form for a new entry. // Do not clear currentApiConfigName here, as they might just be viewing. } }); newApiConfigButton.addEventListener('click', function() { clearConfigForm(); apiConfigNameInput.focus(); }); saveApiConfigButton.addEventListener('click', function() { const name = apiConfigNameInput.value.trim(); const baseUrl = baseUrlInput.value.trim(); const apiKey = apiKeyInput.value.trim(); if (!name || !baseUrl || !apiKey) { showToast('配置名称、Base URL 和 API Key 不能为空。', 'error'); return; } let configs = getApiConfigurations(); const existingConfigIndex = configs.findIndex(c => c.name === name); const currentConfigData = { name, baseUrl, apiKey, model: modelSelect.value, prompt: promptInput.value, temperature: temperatureInput.value, autoTranslateEnabled: autoTranslateToggle.checked, autoPasteEnabled: autoPasteToggle.checked }; if (existingConfigIndex > -1) { configs[existingConfigIndex] = currentConfigData; // Update existing } else { configs.push(currentConfigData); // Add new } saveApiConfigurations(configs); setCurrentApiConfigName(name); // Set this as the active config populateApiConfigSelect(); // Refresh dropdown apiConfigSelect.value = name; // Ensure the saved/updated config is selected showToast(`API 配置 "${name}" 已保存!`, 'success'); }); deleteApiConfigButton.addEventListener('click', function() { const selectedNameToDelete = apiConfigSelect.value; // The config selected in dropdown if (!selectedNameToDelete) { showToast('请先从下拉列表中选择一个要删除的配置。', 'error'); return; } if (!confirm(`确定要删除配置 "${selectedNameToDelete}" 吗?`)) { return; } let configs = getApiConfigurations(); configs = configs.filter(c => c.name !== selectedNameToDelete); saveApiConfigurations(configs); // If the deleted config was the currently active one, clear the form and active status if (getCurrentApiConfigName() === selectedNameToDelete) { setCurrentApiConfigName(''); clearConfigForm(); } populateApiConfigSelect(); // Refresh dropdown showToast(`API 配置 "${selectedNameToDelete}" 已删除!`, 'success'); // If there are other configs, load the first one or leave blank if (getApiConfigurations().length > 0) { const firstConfigName = getApiConfigurations()[0].name; loadConfigToUI(firstConfigName); apiConfigSelect.value = firstConfigName; } else { clearConfigForm(); // No configs left, clear form } }); // Event listeners for general (non-API-config specific) fields // Event listeners for general (non-API-config specific) fields // These save to general localStorage and also update the active API config if one is selected. baseUrlInput.addEventListener('input', () => { updateFullUrlPreview(baseUrlInput.value); // Base URL and API Key are core to a config, usually not changed outside explicit save. }); // apiKeyInput does not have a live update to avoid frequent writes of sensitive data. document.getElementById('openaiButton').addEventListener('click', () => { baseUrlInput.value = 'https://api.openai.com/v1'; updateFullUrlPreview(baseUrlInput.value); }); document.getElementById('deepseekButton').addEventListener('click', () => { baseUrlInput.value = 'https://api.deepseek.com'; updateFullUrlPreview(baseUrlInput.value); }); fetchModelsButton.addEventListener('click', async () => { await this.fetchModelsAndUpdateDatalist(); }); modelSelect.addEventListener('input', () => { // modelSelect is the input field localStorage.setItem('model', modelSelect.value); updateActiveConfigField('model', modelSelect.value); }); promptInput.addEventListener('input', () => { localStorage.setItem('prompt', promptInput.value); updateActiveConfigField('prompt', promptInput.value); }); temperatureInput.addEventListener('input', () => { const tempValue = temperatureInput.value; localStorage.setItem('temperature', tempValue); updateActiveConfigField('temperature', tempValue); }); autoTranslateToggle.addEventListener('change', () => { localStorage.setItem('autoTranslateEnabled', autoTranslateToggle.checked); updateActiveConfigField('autoTranslateEnabled', autoTranslateToggle.checked); }); autoPasteToggle.addEventListener('change', () => { localStorage.setItem('autoPasteEnabled', autoPasteToggle.checked); updateActiveConfigField('autoPasteEnabled', autoPasteToggle.checked); }); const PROMPT_LIBRARY_KEY = 'promptLibrary'; function getPromptLibrary() { return JSON.parse(localStorage.getItem(PROMPT_LIBRARY_KEY)) || []; } function savePromptLibrary(library) { localStorage.setItem(PROMPT_LIBRARY_KEY, JSON.stringify(library)); } function populatePromptLibrarySelect() { const library = getPromptLibrary(); promptLibrarySelect.innerHTML = '<option value="" selected>从库中选择或管理...</option>'; library.forEach((promptText) => { const option = document.createElement('option'); option.value = promptText; option.textContent = promptText.substring(0, 50) + (promptText.length > 50 ? '...' : ''); option.dataset.fulltext = promptText; promptLibrarySelect.appendChild(option); }); } promptLibrarySelect.addEventListener('change', function() { if (this.value) { promptInput.value = this.value; localStorage.setItem('prompt', this.value); // Keep for fallback if no config selected updateActiveConfigField('prompt', this.value); } }); saveToPromptLibraryButton.addEventListener('click', function() { const currentPrompt = promptInput.value.trim(); if (currentPrompt) { let library = getPromptLibrary(); if (!library.includes(currentPrompt)) { library.push(currentPrompt); savePromptLibrary(library); populatePromptLibrarySelect(); promptLibrarySelect.value = currentPrompt; showToast('Prompt 已保存到库中。', 'success'); } else { showToast('此 Prompt 已存在于库中。', 'warning'); } } else { showToast('Prompt 内容不能为空。', 'error'); } }); deleteFromPromptLibraryButton.addEventListener('click', function() { const selectedPromptValue = promptLibrarySelect.value; if (selectedPromptValue) { let library = getPromptLibrary(); const indexToRemove = library.indexOf(selectedPromptValue); if (indexToRemove > -1) { library.splice(indexToRemove, 1); savePromptLibrary(library); populatePromptLibrarySelect(); if (promptInput.value === selectedPromptValue) { promptInput.value = ''; localStorage.setItem('prompt', ''); } showToast('选定的 Prompt 已从库中删除。', 'success'); } } else { showToast('请先从库中选择一个 Prompt 进行删除。', 'error'); } }); populatePromptLibrarySelect(); // Sync promptLibrarySelect with the initial promptInput value const initialPromptValue = promptInput.value; if (initialPromptValue) { const library = getPromptLibrary(); if (library.includes(initialPromptValue)) { promptLibrarySelect.value = initialPromptValue; } else { promptLibrarySelect.value = ""; // If not in library, keep placeholder } } else { promptLibrarySelect.value = ""; // Default to placeholder if no initial prompt } // Removed duplicated listeners for temperature and autoTranslateToggle here, // as they are already defined above with updateActiveConfigField logic. this.setupTranslation(); } async fetchModelsAndUpdateDatalist() { const modelDatalist = document.getElementById('modelDatalist'); const fetchModelsButton = document.getElementById('fetchModelsButton'); const originalButtonHtml = fetchModelsButton.innerHTML; fetchModelsButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; fetchModelsButton.disabled = true; let API_SECRET_KEY = ''; let BASE_URL = ''; const currentConfigName = getCurrentApiConfigName(); let activeConfig = null; if (currentConfigName) { const configs = getApiConfigurations(); activeConfig = configs.find(c => c.name === currentConfigName); } if (activeConfig) { BASE_URL = activeConfig.baseUrl; API_SECRET_KEY = activeConfig.apiKey; } else { // Fallback to general localStorage if no active config (less ideal) BASE_URL = localStorage.getItem('baseUrl'); API_SECRET_KEY = localStorage.getItem('apiKey'); } if (!BASE_URL || !API_SECRET_KEY) { showToast('请先配置并选择一个有效的 API 配置 (包含 Base URL 和 API Key)。', 'error', 5000); fetchModelsButton.innerHTML = originalButtonHtml; fetchModelsButton.disabled = false; return; } // Construct the models API URL (OpenAI standard is /models) const modelsUrl = `${BASE_URL}${BASE_URL.endsWith('/') ? '' : '/'}models`; try { const response = await fetch(modelsUrl, { method: 'GET', headers: { 'Authorization': `Bearer ${API_SECRET_KEY}` } }); if (!response.ok) { const errorData = await response.text(); console.error('Error fetching models:', response.status, errorData); showToast(`获取模型列表失败: ${response.status} - ${errorData.substring(0,100)}`, 'error', 5000); return; } const data = await response.json(); if (data && data.data && Array.isArray(data.data)) { modelDatalist.innerHTML = ''; // Clear existing options data.data.forEach(model => { if (model.id) { const option = document.createElement('option'); option.value = model.id; modelDatalist.appendChild(option); } }); showToast('模型列表已更新。', 'success'); } else { console.warn('Unexpected models API response structure:', data); showToast('获取模型列表成功,但响应数据格式不符合预期。', 'warning', 4000); } } catch (error) { console.error('Failed to fetch models:', error); showToast(`获取模型列表时发生网络错误: ${error.message}`, 'error', 5000); } finally { fetchModelsButton.innerHTML = originalButtonHtml; fetchModelsButton.disabled = false; } } setupTranslation() { function removeThoughtProcessContent(text) { if (typeof text !== 'string') return text; // 移除XML风格的思考标签 let cleanedText = text.replace(/<thought>[\s\S]*?<\/thought>/gi, ''); cleanedText = cleanedText.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');cleanedText = cleanedText.replace(/<think>[\s\S]*?<\/think>/gi, ''); cleanedText = cleanedText.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, ''); // 移除Markdown风格的思考标签 cleanedText = cleanedText.replace(/\[THOUGHT\][\s\S]*?\[\/THOUGHT\]/gi, ''); cleanedText = cleanedText.replace(/\[REASONING\][\s\S]*?\[\/REASONING\]/gi, ''); // 移除以特定关键词开头的思考过程 cleanedText = cleanedText.replace(/^(思考过程:|思考:|Thought process:|Thought:|Thinking:|Reasoning:)[\s\S]*?(\n|$)/gim, ''); // 移除常见的工具交互XML标签 cleanedText = cleanedText.replace(/<tool_code>[\s\S]*?<\/tool_code>/gi, ''); cleanedText = cleanedText.replace(/<tool_code_executing>[\s\S]*?<\/tool_code_executing>/gi, ''); cleanedText = cleanedText.replace(/<tool_code_completed>[\s\S]*?<\/tool_code_completed>/gi, ''); cleanedText = cleanedText.replace(/<tool_code_error>[\s\S]*?<\/tool_code_error>/gi, ''); cleanedText = cleanedText.replace(/<tool_code_output>[\s\S]*?<\/tool_code_output>/gi, ''); cleanedText = cleanedText.replace(/<tool_code_execution_succeeded>[\s\S]*?<\/tool_code_execution_succeeded>/gi, ''); cleanedText = cleanedText.replace(/<tool_code_execution_failed>[\s\S]*?<\/tool_code_execution_failed>/gi, ''); // 移除 SEARCH/REPLACE 块标记 cleanedText = cleanedText.replace(/<<<<<<< SEARCH[\s\S]*?>>>>>>> REPLACE/gi, ''); // 清理多余的空行,并将多个连续空行合并为一个 cleanedText = cleanedText.replace(/\n\s*\n/g, '\n'); // 移除首尾空白字符 (包括换行符) cleanedText = cleanedText.trim(); return cleanedText; } const translationCache = {}; const translationsInProgress = {}; async function getCurrentStringId() { const pathParts = window.location.pathname.split('/'); let stringId = null; const stringsIndex = pathParts.indexOf('strings'); if (stringsIndex !== -1 && pathParts.length > stringsIndex + 1) { const idFromPath = pathParts[stringsIndex + 1]; if (!isNaN(parseInt(idFromPath, 10))) { stringId = idFromPath; } } if (!stringId) { const copyLinkButton = document.querySelector('.string-editor a.float-right.no-select[href*="/strings?id="]'); if (copyLinkButton) { const href = copyLinkButton.getAttribute('href'); const urlParams = new URLSearchParams(href.split('?')[1]); stringId = urlParams.get('id'); } else { const settingsLink = document.querySelector('.string-editor .tab.context-tab a[href*="/settings/strings?id="]'); if (settingsLink) { const href = settingsLink.getAttribute('href'); const urlParams = new URLSearchParams(href.split('?')[1]); stringId = urlParams.get('id'); } } } return stringId && !isNaN(parseInt(stringId, 10)) ? stringId : null; } function updateTranslationUI(text, modelName, stringIdForUI) { document.getElementById('translatedText').value = text; if (localStorage.getItem('autoPasteEnabled') === 'true') { const targetTextarea = document.querySelector('textarea.translation.form-control'); // 修复:仅当翻译框为空时才自动粘贴 if (targetTextarea && targetTextarea.value.trim() === '') { simulateInputChange(targetTextarea, text); } } let translationMemoryDiv = document.querySelector('.translation-memory'); let mtListContainer; if (!translationMemoryDiv) { const tabs = document.querySelector('.sidebar-right .tabs'); if (!tabs) { console.error('找不到.sidebar-right .tabs元素'); return; } translationMemoryDiv = document.createElement('div'); translationMemoryDiv.className = 'translation-memory'; translationMemoryDiv.style.display = 'block'; const header = document.createElement('header'); header.className = 'mb-3'; const headerContent = document.createElement('div'); headerContent.className = 'row medium align-items-center'; headerContent.innerHTML = ` <div class="col-auto"> <button title="Ctrl + Shift + F" type="button" class="btn btn-secondary btn-sm"> <i class="far fa-search"></i> 搜索历史翻译 </button> </div> <div class="col text-right"> <span class="text-muted">共 0 条建议</span> <button type="button" class="btn btn-secondary btn-sm"><i class="far fa-cog fa-fw"></i></button> </div>`; header.appendChild(headerContent); translationMemoryDiv.appendChild(header); mtListContainer = document.createElement('div'); mtListContainer.className = 'list mt-list'; translationMemoryDiv.appendChild(mtListContainer); tabs.insertBefore(translationMemoryDiv, tabs.firstChild); } else { mtListContainer = translationMemoryDiv.querySelector('.list.mt-list'); if (!mtListContainer) { mtListContainer = document.createElement('div'); mtListContainer.className = 'list mt-list'; const header = translationMemoryDiv.querySelector('header'); if (header) header.insertAdjacentElement('afterend', mtListContainer); else translationMemoryDiv.appendChild(mtListContainer); } } const existingAiReferences = mtListContainer.querySelectorAll('.mt-reference.paratranz-ai-reference'); existingAiReferences.forEach(ref => ref.remove()); if (mtListContainer) { const newReferenceDiv = document.createElement('div'); newReferenceDiv.className = 'mt-reference paratranz-ai-reference'; newReferenceDiv.dataset.stringId = stringIdForUI; const header = document.createElement('header'); header.className = 'medium mb-2 text-muted'; const icon = document.createElement('i'); icon.className = 'far fa-language'; header.appendChild(icon); header.appendChild(document.createTextNode(' 机器翻译参考')); newReferenceDiv.appendChild(header); const bodyRow = document.createElement('div'); bodyRow.className = 'row align-items-center'; const colAuto = document.createElement('div'); colAuto.className = 'col-auto pr-0'; const copyButton = document.createElement('button'); copyButton.title = '复制当前文本至翻译框'; copyButton.type = 'button'; copyButton.className = 'btn btn-link'; const copyIcon = document.createElement('i'); copyIcon.className = 'far fa-clone'; copyButton.appendChild(copyIcon); copyButton.addEventListener('click', function() { simulateInputChange(document.querySelector('textarea.translation.form-control'), text); }); colAuto.appendChild(copyButton); bodyRow.appendChild(colAuto); const colText = document.createElement('div'); colText.className = 'col'; const translationSpan = document.createElement('span'); translationSpan.className = 'translation notranslate'; translationSpan.textContent = text; colText.appendChild(translationSpan); bodyRow.appendChild(colText); newReferenceDiv.appendChild(bodyRow); const footer = document.createElement('footer'); footer.className = 'medium mt-2 text-muted'; const leftText = document.createElement('span'); leftText.textContent = 'Paratranz-AI'; const rightText = document.createElement('div'); rightText.className = 'float-right'; rightText.textContent = modelName || 'N/A'; footer.appendChild(leftText); footer.appendChild(rightText); newReferenceDiv.appendChild(footer); mtListContainer.prepend(newReferenceDiv); } } async function processTranslationRequest(stringIdToProcess, textToTranslate) { const translateButtonElement = document.getElementById('translateButton'); if (!stringIdToProcess) { console.warn('processTranslationRequest called with no stringId.'); return; } if (translationsInProgress[stringIdToProcess]) { console.log(`Translation for ${stringIdToProcess} is already in progress. Ignoring new request.`); return; } translationsInProgress[stringIdToProcess] = true; if (translateButtonElement) translateButtonElement.disabled = true; document.getElementById('translatedText').value = '翻译中...'; let translatedTextOutput = ''; try { console.log(`Processing translation for stringId ${stringIdToProcess}:`, textToTranslate); const model = localStorage.getItem('model') || 'gpt-4o-mini'; const promptStr = localStorage.getItem('prompt') || `You are a translator, you will translate all the message I send to you.\n\nSource Language: en\nTarget Language: zh-cn\n\nOutput result and thought with zh-cn, and keep the result pure text\nwithout any markdown syntax and any thought or references.\n\nInstructions:\n - Accuracy: Ensure the translation accurately conveys the original meaning.\n - Context: Adapt to cultural nuances and specific context to avoid misinterpretation.\n - Tone: Match the tone (formal, informal, technical) of the source text.\n - Grammar: Use correct grammar and sentence structure in the target language.\n - Readability: Ensure the translation is clear and easy to understand.\n - Keep Tags: Maintain the original tags intact, do not translate tags themselves!\n - Keep or remove the spaces around the tags based on the language manners (in CJK, usually the spaces will be removed).\n\nTags are matching the following regular expressions (one per line):\n/{\w+}/\n/%[ds]?\d/\n/\\s#\d{1,2}/\n/<[^>]+?>/\n/%{\d}[a-z]/\n/@[a-zA-Z.]+?@/`; const temperature = parseFloat(localStorage.getItem('temperature')) || 0; translatedTextOutput = await translateText(textToTranslate, model, promptStr, temperature); const replaceList = JSON.parse(localStorage.getItem('replaceList')) || []; replaceList.forEach(rule => { if (!rule.disabled && rule.findText) { translatedTextOutput = translatedTextOutput.replaceAll(rule.findText, rule.replacementText); } }); // 新增:去除思维链内容 translatedTextOutput = removeThoughtProcessContent(translatedTextOutput); // 检查翻译是否成功,如果失败则不保存到缓存 const isTranslationError = translatedTextOutput.startsWith("API Base URL 或 Key 未配置。") || translatedTextOutput.startsWith("API 翻译失败:") || translatedTextOutput === "翻译失败: API响应格式无效" || translatedTextOutput === "翻译请求超时。" || translatedTextOutput.startsWith("翻译请求失败:"); if (!isTranslationError) { translationCache[stringIdToProcess] = translatedTextOutput; } const currentPageId = await getCurrentStringId(); if (currentPageId === stringIdToProcess) { updateTranslationUI(translatedTextOutput, model, stringIdToProcess); } else { console.log(`Translated stringId ${stringIdToProcess}, but page is now ${currentPageId}. Reference UI not updated for ${stringIdToProcess}.`); document.getElementById('translatedText').value = translatedTextOutput; } } catch (error) { console.error(`Error during translation processing for stringId ${stringIdToProcess}:`, error); const translatedTextArea = document.getElementById('translatedText'); if (translatedTextArea) { translatedTextArea.value = `翻译出错 (ID: ${stringIdToProcess}): ${error.message}`; } } finally { delete translationsInProgress[stringIdToProcess]; if (translateButtonElement) translateButtonElement.disabled = false; console.log(`Translation processing for stringId ${stringIdToProcess} finished, flags reset.`); } } async function updateOriginalTextAndTranslateIfNeeded() { const currentStringId = await getCurrentStringId(); if (!currentStringId) { return; } const originalDiv = document.querySelector('.original.well'); if (originalDiv) { const originalText = originalDiv.innerText; document.getElementById('originalText').value = originalText; const existingAiReference = document.querySelector('.mt-reference.paratranz-ai-reference'); if (translationCache[currentStringId]) { console.log(`Using cached translation for stringId: ${currentStringId}`); const model = localStorage.getItem('model') || 'gpt-4o-mini'; if (existingAiReference && existingAiReference.dataset.stringId !== currentStringId) { existingAiReference.remove(); } updateTranslationUI(translationCache[currentStringId], model, currentStringId); return; } else { if (existingAiReference) { existingAiReference.remove(); } } if (localStorage.getItem('autoTranslateEnabled') === 'true' && originalText.trim() !== '' && !translationsInProgress[currentStringId]) { console.log(`Auto-translating for stringId: ${currentStringId}`); await processTranslationRequest(currentStringId, originalText); } else if (translationsInProgress[currentStringId]) { console.log(`Translation already in progress for stringId: ${currentStringId} (checked in updateOriginalText)`); } } } let debounceTimer = null; const observer = new MutationObserver(async () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { console.log('Observer triggered, updating original text and checking translation.'); await updateOriginalTextAndTranslateIfNeeded(); }, 200); }); const config = { childList: true, subtree: true, characterData: true }; const originalDivTarget = document.querySelector('.original.well'); if (originalDivTarget) { observer.observe(originalDivTarget, config); updateOriginalTextAndTranslateIfNeeded(); } else { console.warn("Original text container (.original.well) not found at observer setup."); } document.getElementById('copyOriginalButton').addEventListener('click', async () => { await updateOriginalTextAndTranslateIfNeeded(); }); document.getElementById('translateButton').addEventListener('click', async function() { const currentStringId = await getCurrentStringId(); const originalText = document.getElementById('originalText').value; if (!currentStringId) { console.error('Cannot translate: No valid stringId found for manual trigger.'); return; } await processTranslationRequest(currentStringId, originalText); }); document.getElementById('copyTranslationButton').addEventListener('click', function() { const translatedText = document.getElementById('translatedText').value; navigator.clipboard.writeText(translatedText).then(() => { console.log('Translated text copied to clipboard'); }).catch(err => { console.error('Failed to copy text: ', err); }); }); document.getElementById('pasteTranslationButton').addEventListener('click', function() { const translatedText = document.getElementById('translatedText').value; simulateInputChange(document.querySelector('textarea.translation.form-control'), translatedText); }); } } // 辅助函数:获取项目ID和字符串ID async function getProjectIdAndStringId() { const pathParts = window.location.pathname.split('/'); let projectId = null; let stringId = null; // 尝试从当前URL路径获取项目ID let projectPathIndex = pathParts.indexOf('projects'); if (projectPathIndex !== -1 && pathParts.length > projectPathIndex + 1) { projectId = pathParts[projectPathIndex + 1]; } // 尝试从当前URL路径获取字符串ID const stringsPathIndex = pathParts.indexOf('strings'); if (stringsPathIndex !== -1 && pathParts.length > stringsPathIndex + 1) { const idFromPath = pathParts[stringsPathIndex + 1]; if (idFromPath && !isNaN(parseInt(idFromPath, 10))) { stringId = idFromPath; } } // 如果未在路径中找到,或为了确认/覆盖,则回退到使用页面元素 const copyLinkButton = document.querySelector('.string-editor a.float-right.no-select[href*="/strings?id="]'); if (copyLinkButton) { const href = copyLinkButton.getAttribute('href'); const url = new URL(href, window.location.origin); // 确保是完整URL以便解析 const urlParams = new URLSearchParams(url.search); const idFromHref = urlParams.get('id'); if (idFromHref && !isNaN(parseInt(idFromHref, 10))) { if (!stringId) stringId = idFromHref; // 如果路径中没有,则优先使用href中的ID const hrefPathParts = url.pathname.split('/'); const projectIdxHref = hrefPathParts.indexOf('projects'); if (projectIdxHref !== -1 && hrefPathParts.length > projectIdxHref + 1) { const pidFromHref = hrefPathParts[projectIdxHref + 1]; if (pidFromHref) { if (!projectId || projectId !== pidFromHref) projectId = pidFromHref; // 如果项目ID不同或未找到,则更新 } } } } if (!stringId) { // 如果仍然没有字符串ID,尝试设置链接 const settingsLink = document.querySelector('.string-editor .tab.context-tab a[href*="/settings/strings?id="]'); if (settingsLink) { const href = settingsLink.getAttribute('href'); const url = new URL(href, window.location.origin); const urlParams = new URLSearchParams(url.search); const idFromHref = urlParams.get('id'); if (idFromHref && !isNaN(parseInt(idFromHref, 10))) { stringId = idFromHref; const hrefPathParts = url.pathname.split('/'); const projectIdxHref = hrefPathParts.indexOf('projects'); if (projectIdxHref !== -1 && hrefPathParts.length > projectIdxHref + 1) { const pidFromHref = hrefPathParts[projectIdxHref + 1]; if (pidFromHref && (!projectId || projectId !== pidFromHref)) { projectId = pidFromHref; } } } } } // 确保projectId和stringId是字符串类型 if (projectId && typeof projectId !== 'string') { projectId = String(projectId); } if (stringId && typeof stringId !== 'string') { stringId = String(stringId); } return { projectId, stringId: stringId && !isNaN(parseInt(stringId, 10)) ? stringId : null }; } // 获取术语表数据 (异步) async function getTermsData() { const terms = []; const { projectId, stringId } = await getProjectIdAndStringId(); if (!projectId) { console.warn('无法从 URL 或页面元素中解析项目 ID,跳过术语获取。'); return terms; } if (!stringId) { console.warn('无法从 URL 或页面元素中解析有效的字符串 ID,跳过术语获取。'); return terms; } const apiUrl = `https://paratranz.cn/api/projects/${projectId}/strings/${stringId}/terms`; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 const response = await fetch(apiUrl, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { console.error(`获取术语 API 失败: ${response.status} ${response.statusText}`); return terms; } const apiResult = await response.json(); apiResult.forEach(item => { if (item.term && item.translation) { terms.push({ source: item.term, target: item.translation, note: item.note || '' }); } }); // console.log(`通过 API 获取到 ${terms.length} 条术语。`); } catch (error) { if (error.name === 'AbortError') { console.error('获取术语 API 超时。'); } else { console.error('调用术语 API 时发生错误:', error); } } return terms; } async function buildTermsSystemMessageWithRetry() { let terms = await getTermsData(); if (!terms.length) { // console.log('第一次通过 API 获取术语表失败或为空,等待100ms后重试...'); await new Promise(resolve => setTimeout(resolve, 100)); terms = await getTermsData(); if (!terms.length) { // console.log('第二次通过 API 获取术语表仍然失败或为空。'); return null; } // console.log(`第二次尝试通过 API 获取到 ${terms.length} 条术语。`); } else { // console.log(`第一次尝试通过 API 获取到 ${terms.length} 条术语。`); } const termsContext = terms.map(term => { let termString = `${term.source} → ${term.target}`; if (term.note) { termString += ` (备注(辅助思考不要出现在译文中):${term.note})`; } return termString; }).join('\n'); return { role: "user", content: `翻译时请参考以下术语表:\n${termsContext}` }; } // 新增:获取翻译建议上下文 async function getTranslationSuggestionsContext() { const { projectId, stringId } = await getProjectIdAndStringId(); const suggestionsContext = []; if (!projectId || !stringId) { // console.warn('无法获取翻译建议:项目 ID 或字符串 ID 未找到。'); return suggestionsContext; } const apiUrl = `https://paratranz.cn/api/projects/${projectId}/strings/${stringId}/suggestions`; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 const response = await fetch(apiUrl, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { console.error(`获取翻译建议 API 失败: ${response.status} ${response.statusText}`); return suggestionsContext; } const apiResult = await response.json(); if (Array.isArray(apiResult)) { apiResult.forEach(suggestion => { // 确保 original 和 translation 存在且不为空字符串 if (suggestion.original && suggestion.translation && typeof suggestion.matching === 'number' && suggestion.matching >= 0.7) { suggestionsContext.push({ role: "user", content: suggestion.original }); suggestionsContext.push({ role: "assistant", content: suggestion.translation }); } }); } // console.log(`获取到 ${suggestionsContext.length / 2} 条符合条件的翻译建议。`); } catch (error) { if (error.name === 'AbortError') { console.error('获取翻译建议 API 超时。'); } else { console.error('调用翻译建议 API 时发生错误:', error); } } return suggestionsContext; } class PromptTagProcessor { constructor() { this.tagProcessors = new Map(); this.setupDefaultTags(); } setupDefaultTags() { this.registerTag('original', (text) => text); this.registerTag('context', async () => { const contextDiv = document.querySelector('.context .well'); if (!contextDiv) return ''; return contextDiv.innerText.trim(); }); this.registerTag('terms', async () => { const terms = await getTermsData(); if (!terms.length) return ''; return terms.map(term => { let termString = `${term.source} → ${term.target}`; if (term.note) termString += ` (${term.note})`; return termString; }).join('\n'); }); } registerTag(tagName, processor) { this.tagProcessors.set(tagName, processor); } async processPrompt(prompt, originalText) { let processedPrompt = prompt; for (const [tagName, processor] of this.tagProcessors) { const tagPattern = new RegExp(`{{${tagName}}}`, 'g'); if (tagPattern.test(processedPrompt)) { let replacement; try { replacement = (tagName === 'original') ? originalText : await processor(); processedPrompt = processedPrompt.replace(tagPattern, replacement || ''); // console.log(`替换标签 {{${tagName}}} 成功`); } catch (error) { console.error(`处理标签 {{${tagName}}} 时出错:`, error); } } } // console.log('处理后的prompt:', processedPrompt); return processedPrompt; } } // Define API config utility functions in IIFE scope const API_CONFIGURATIONS_KEY = 'apiConfigurations'; const CURRENT_API_CONFIG_NAME_KEY = 'currentApiConfigName'; function getApiConfigurations() { return JSON.parse(localStorage.getItem(API_CONFIGURATIONS_KEY)) || []; } function saveApiConfigurations(configs) { localStorage.setItem(API_CONFIGURATIONS_KEY, JSON.stringify(configs)); } function getCurrentApiConfigName() { return localStorage.getItem(CURRENT_API_CONFIG_NAME_KEY); } function setCurrentApiConfigName(name) { localStorage.setItem(CURRENT_API_CONFIG_NAME_KEY, name); } async function translateText(query, model, prompt, temperature) { let API_SECRET_KEY = ''; let BASE_URL = ''; const currentConfigName = getCurrentApiConfigName(); let activeConfig = null; if (currentConfigName) { const configs = getApiConfigurations(); activeConfig = configs.find(c => c.name === currentConfigName); } if (activeConfig) { BASE_URL = activeConfig.baseUrl; API_SECRET_KEY = activeConfig.apiKey; model = activeConfig.model || localStorage.getItem('model') || 'gpt-4o-mini'; // Fallback to general localStorage then default prompt = activeConfig.prompt || localStorage.getItem('prompt') || ''; temperature = activeConfig.temperature !== undefined && activeConfig.temperature !== '' ? parseFloat(activeConfig.temperature) : (localStorage.getItem('temperature') !== null ? parseFloat(localStorage.getItem('temperature')) : 0); } else { // If no active config, try to use general localStorage settings as a last resort for key/URL // This case should ideally be handled by prompting user to select/create a config console.warn("No active API configuration selected. Translation might fail or use stale settings."); BASE_URL = localStorage.getItem('baseUrl_fallback_for_translate') || ''; // Example of a dedicated fallback key API_SECRET_KEY = localStorage.getItem('apiKey_fallback_for_translate') || ''; // For other params, use general localStorage or defaults model = localStorage.getItem('model') || 'gpt-4o-mini'; prompt = localStorage.getItem('prompt') || ''; temperature = localStorage.getItem('temperature') !== null ? parseFloat(localStorage.getItem('temperature')) : 0; } if (!BASE_URL || !API_SECRET_KEY) { console.error("API Base URL or Key is missing. Please configure an API setting."); return "API Base URL 或 Key 未配置。请在翻译配置中设置。"; } if (!prompt) { // Default prompt if still empty after all fallbacks prompt = "You are a professional translator focusing on translating Magic: The Gathering cards from English to Chinese. You are given a card's original text in English. Translate it into Chinese."; } const tagProcessor = new PromptTagProcessor(); const processedPrompt = await tagProcessor.processPrompt(prompt, query); const messages = [{ role: "system", content: processedPrompt }]; // console.log('准备获取术语表信息...'); const termsMessage = await buildTermsSystemMessageWithRetry(); // 间接使用 getProjectIdAndStringId if (termsMessage && termsMessage.content) { // console.log('成功获取术语表信息,添加到请求中。'); messages.push(termsMessage); } else { // console.log('未获取到术语表信息或术语表为空,翻译请求将不包含术语表。'); } // 新增:获取并添加翻译建议上下文 // console.log('准备获取翻译建议上下文...'); const suggestionContextMessages = await getTranslationSuggestionsContext(); // 直接使用 getProjectIdAndStringId if (suggestionContextMessages && suggestionContextMessages.length > 0) { // console.log(`成功获取 ${suggestionContextMessages.length / 2} 条翻译建议,添加到请求中。`); messages.push(...suggestionContextMessages); } else { // console.log('未获取到符合条件的翻译建议,或获取失败。'); } messages.push({ role: "user", content: "text below\n```\n" + query + "\n```\nreturn without `" }); const requestBody = { model, temperature, messages }; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 250000); // 25秒超时 const response = await fetch(`${BASE_URL}${BASE_URL.endsWith('/') ? '' : '/'}chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_SECRET_KEY}` }, body: JSON.stringify(requestBody), signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { let errorData; try { errorData = await response.json(); } catch (e) { /* ignore */ } console.error('API Error:', errorData || response.statusText); return `API 翻译失败: ${response.status} - ${errorData?.error?.message || errorData?.message || response.statusText}`; } const data = await response.json(); if (data.choices && data.choices[0]?.message?.content) { return data.choices[0].message.content; } else { console.error('Invalid API response structure:', data); return '翻译失败: API响应格式无效'; } } catch (error) { if (error.name === 'AbortError') { console.error('API translation request timed out.'); return '翻译请求超时。'; } console.error('Translation Fetch/Network Error:', error); return `翻译请求失败: ${error.message || error.toString()}`; } } function simulateInputChange(element, newValue) { if (element.value.trim() !== '') { // return; // Allowing overwrite now based on typical user expectation for paste } const inputEvent = new Event('input', { bubbles: true }); const originalValue = element.value; element.value = newValue; const tracker = element._valueTracker; if (tracker) tracker.setValue(originalValue); element.dispatchEvent(inputEvent); } const accordion = new Accordion('#accordionExample', '.sidebar-right'); const stringReplaceCard = new StringReplaceCard('#accordionExample'); const machineTranslationCard = new MachineTranslationCard('#accordionExample'); accordion.addCard(stringReplaceCard); accordion.addCard(machineTranslationCard); // Diff对比模态框类 class DiffModal { constructor() { this.modalId = 'diffModal'; this.diffLib = null; this.initModal(); this.initDiffLibraries(); } initDiffLibraries() { if (typeof Diff !== 'undefined') { this.diffLib = Diff; console.log('jsdiff library initialized successfully'); } else { console.error('jsdiff library is not available'); } } initModal() { if (document.getElementById(this.modalId)) return; const modalHTML = ` <div class="modal" id="${this.modalId}" tabindex="-1" role="dialog" style="display: none;"> <div class="modal-dialog modal-xl" role="document"> <div class="modal-content"> <div class="modal-header py-2"> <h5 class="modal-title">文本对比</h5> <button type="button" class="close" id="closeDiffModal" aria-label="Close"> <span aria-hidden="true">×</span> </button> </div> <div class="modal-body p-0" style="height: 70vh;"> <div class="diff-container d-flex h-100"> <div class="diff-original w-50 border-right" style="overflow-y: auto;"> <div class="diff-header bg-light p-2">原文</div> <div class="diff-content" id="originalDiffContent"></div> </div> <div class="diff-translation w-50" style="overflow-y: auto;"> <div class="diff-header bg-light p-2 d-flex justify-content-between align-items-center"> <span>当前翻译</span> <button class="btn btn-sm btn-primary" id="editTranslationButton">编辑</button> </div> <div class="diff-content" id="translationDiffContent" style="display: block;"></div> <textarea class="form-control" id="translationEditor" style="display: none; height: 100%; width: 100%; border: none; resize: none; font-family: monospace;" placeholder="在此编辑翻译内容..."></textarea> </div> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" id="closeDiffModalButton">关闭</button> <button type="button" class="btn btn-primary" id="saveTranslationButton" style="display: none;">保存</button> </div> </div> </div> </div> `; document.body.insertAdjacentHTML('beforeend', modalHTML); const style = document.createElement('style'); style.textContent = ` .diff-line { display: flex; padding: 2px 5px; font-family: monospace; line-height: 1.4; } .diff-line-number { min-width: 35px; color: #999; text-align: right; padding-right: 10px; user-select: none; font-size: 0.9em; } .diff-line-content { flex: 1; white-space: pre-wrap; word-break: break-word; padding-left: 5px; } .diff-line.diff-added { background-color: #e6ffed; /* Light green for whole line add */ } .diff-line.diff-removed { background-color: #ffeef0; /* Light red for whole line remove */ } .diff-line.diff-common { background-color: #ffffff; } .diff-line.diff-placeholder, .diff-line.diff-modified-old, /* Placeholder for original side of a modification */ .diff-line.diff-added-extra { /* Placeholder for translation side of a modification where original has fewer lines */ background-color: #f0f0f0; /* Grey for placeholders */ } .copy-action-button { /* Unified class for action buttons */ cursor: pointer; margin-left: 8px; padding: 0 4px; font-size: 0.9em; line-height: 1; border: 1px solid #ccc; border-radius: 3px; background-color: #f0f0f0; } .copy-action-button:hover { background-color: #e0e0e0; } .diff-header { font-weight: bold; position: sticky; top: 0; z-index: 1; background-color: #f8f9fa; /* Ensure header bg covers scrolling content */ } /* Intra-line diff styles */ .diff-intraline-added { background-color: #acf2bd; /* More prominent green for intra-line additions */ /* text-decoration: underline; */ } .diff-intraline-removed { background-color: #fdb8c0; /* More prominent red for intra-line deletions */ text-decoration: line-through; } `; document.head.appendChild(style); document.getElementById('closeDiffModal').addEventListener('click', this.closeModal.bind(this)); document.getElementById('closeDiffModalButton').addEventListener('click', this.closeModal.bind(this)); document.getElementById('editTranslationButton').addEventListener('click', this.toggleEditMode.bind(this)); document.getElementById('saveTranslationButton').addEventListener('click', this.saveTranslation.bind(this)); } toggleEditMode() { const translationContent = document.getElementById('translationDiffContent'); const translationEditor = document.getElementById('translationEditor'); const editButton = document.getElementById('editTranslationButton'); const saveButton = document.getElementById('saveTranslationButton'); if (translationContent.style.display === 'block') { translationContent.style.display = 'none'; translationEditor.style.display = 'block'; editButton.textContent = '取消编辑'; saveButton.style.display = 'inline-block'; translationEditor.value = document.querySelector('textarea.translation.form-control')?.value || ''; translationEditor.focus(); } else { translationContent.style.display = 'block'; translationEditor.style.display = 'none'; editButton.textContent = '编辑'; saveButton.style.display = 'none'; } } saveTranslation() { const translationEditor = document.getElementById('translationEditor'); const textarea = document.querySelector('textarea.translation.form-control'); if (textarea) { textarea.value = translationEditor.value; simulateInputChange(textarea, textarea.value); // Ensure change is registered by React/Vue if applicable this.toggleEditMode(); // Switch back to diff view this.generateDiff(); // Regenerate diff with new translation } } show() { const modal = document.getElementById(this.modalId); modal.style.display = 'block'; this.generateDiff(); } closeModal() { document.getElementById(this.modalId).style.display = 'none'; } // Helper to split lines, handling trailing newline consistently and removing CR splitIntoLines(text) { if (text === null || text === undefined) return []; if (text === '') return ['']; // An empty text is one empty line for diffing purposes let lines = text.split('\n'); // If the text ends with a newline, split will produce an empty string at the end. // jsdiff's diffLines handles this by considering the newline as part of the last line's value or as a separate token. // For our rendering, we want to represent each line distinctly. // If text is "a\nb\n", split gives ["a", "b", ""]. We want ["a", "b"]. // If text is "a\nb", split gives ["a", "b"]. We want ["a", "b"]. // If text is "\n", split gives ["", ""]. We want [""] for one empty line. if (text.endsWith('\n') && lines.length > 0) { lines.pop(); // Remove the empty string caused by a trailing newline } return lines.map(l => l.replace(/\r$/, '')); // Remove CR if present for consistency } generateDiff() { const originalText = document.querySelector('.original.well')?.innerText || ''; const translationText = document.querySelector('textarea.translation.form-control')?.value || ''; const originalContent = document.getElementById('originalDiffContent'); const translationContent = document.getElementById('translationDiffContent'); originalContent.innerHTML = ''; translationContent.innerHTML = ''; if (!this.diffLib) { console.error('Diff library (jsdiff) not loaded.'); originalContent.innerHTML = '<p>差异库未加载</p>'; return; } const lineDiffResult = this.diffLib.diffLines(originalText, translationText, { newlineIsToken: false, ignoreWhitespace: false }); let origDisplayLineNum = 1; let transDisplayLineNum = 1; let currentTranslationLineIndexForAction = 0; for (let i = 0; i < lineDiffResult.length; i++) { const part = lineDiffResult[i]; const nextPart = (i + 1 < lineDiffResult.length) ? lineDiffResult[i + 1] : null; let linesInPart = this.splitIntoLines(part.value); if (part.removed) { if (nextPart && nextPart.added) { // This is a modification block let linesInNextPart = this.splitIntoLines(nextPart.value); const maxLines = Math.max(linesInPart.length, linesInNextPart.length); for (let j = 0; j < maxLines; j++) { const removedLine = j < linesInPart.length ? linesInPart[j] : null; const addedLine = j < linesInNextPart.length ? linesInNextPart[j] : null; if (removedLine !== null) { this.appendLine(originalContent, origDisplayLineNum++, removedLine, 'diff-removed', removedLine, currentTranslationLineIndexForAction, true, 'original', addedLine, 'replace'); // Action: replace for modified lines } else { this.appendLine(originalContent, '-', '', 'diff-placeholder diff-added-extra', null, null, false, 'original', null); } if (addedLine !== null) { this.appendLine(translationContent, transDisplayLineNum++, addedLine, 'diff-added', addedLine, currentTranslationLineIndexForAction, true, 'translation', removedLine); } else { this.appendLine(translationContent, '-', '', 'diff-placeholder diff-modified-old', null, null, false, 'translation', null); } currentTranslationLineIndexForAction++; } i++; // Skip nextPart as it's processed } else { // Pure removal linesInPart.forEach(lineText => { this.appendLine(originalContent, origDisplayLineNum++, lineText, 'diff-removed', lineText, currentTranslationLineIndexForAction, true, 'original', '', 'insert'); // Action: insert for removed lines this.appendLine(translationContent, '-', '', 'diff-placeholder diff-removed', null, null, false, 'translation', null); // currentTranslationLineIndexForAction does not advance for placeholders on translation side if original is removed }); } } else if (part.added) { // Pure addition (modification handled above) linesInPart.forEach(lineText => { this.appendLine(originalContent, '-', '', 'diff-placeholder diff-added', null, null, false, 'original', null, 'insert'); // Or 'replace' if that makes more sense for placeholder context this.appendLine(translationContent, transDisplayLineNum++, lineText, 'diff-added', lineText, currentTranslationLineIndexForAction, true, 'translation', ''); currentTranslationLineIndexForAction++; }); } else { // Common part linesInPart.forEach(lineText => { this.appendLine(originalContent, origDisplayLineNum++, lineText, 'diff-common', lineText, currentTranslationLineIndexForAction, true, 'original', lineText, 'replace'); // Action: replace for common lines this.appendLine(translationContent, transDisplayLineNum++, lineText, 'diff-common', lineText, currentTranslationLineIndexForAction, true, 'translation', lineText, 'replace'); currentTranslationLineIndexForAction++; }); } } } appendLine(container, lineNumber, text, diffClass, lineTextForAction = null, translationLineIndexForAction = null, showActionButton = false, side = 'original', otherTextForIntralineDiff = null, actionType = 'replace') { // Added actionType, default to 'replace' const lineDiv = document.createElement('div'); lineDiv.className = `diff-line ${diffClass || ''}`; const numberSpan = document.createElement('span'); numberSpan.className = 'diff-line-number'; numberSpan.textContent = lineNumber; lineDiv.appendChild(numberSpan); const contentSpan = document.createElement('span'); contentSpan.className = 'diff-line-content'; if (text === null || (text === '' && diffClass.includes('placeholder'))) { contentSpan.innerHTML = ' '; } else if (this.diffLib && otherTextForIntralineDiff !== null && (diffClass.includes('diff-removed') || diffClass.includes('diff-added') || diffClass.includes('diff-common'))) { let oldContentForWordDiff, newContentForWordDiff; if (diffClass.includes('diff-removed')) { // Displaying on original side, text is old oldContentForWordDiff = text; newContentForWordDiff = otherTextForIntralineDiff || ''; } else if (diffClass.includes('diff-added')) { // Displaying on translation side, text is new oldContentForWordDiff = otherTextForIntralineDiff || ''; newContentForWordDiff = text; } else { // Common line oldContentForWordDiff = text; newContentForWordDiff = text; // or otherTextForIntralineDiff, they are the same } const wordDiff = this.diffLib.diffWordsWithSpace(oldContentForWordDiff, newContentForWordDiff); wordDiff.forEach(part => { const span = document.createElement('span'); if (part.added) { // Style as added if we are on the side that displays the "new" content of the pair if (diffClass.includes('diff-added') || (diffClass.includes('diff-removed') && side === 'original')) { span.className = 'diff-intraline-added'; } } else if (part.removed) { // Style as removed if we are on the side that displays the "old" content of the pair if (diffClass.includes('diff-removed') || (diffClass.includes('diff-added') && side === 'translation')) { span.className = 'diff-intraline-removed'; } } span.textContent = part.value; contentSpan.appendChild(span); }); } else { contentSpan.textContent = text; } lineDiv.appendChild(contentSpan); if (showActionButton && lineTextForAction !== null && translationLineIndexForAction !== null && !diffClass.includes('placeholder')) { const actionButton = document.createElement('button'); actionButton.className = `btn btn-link p-0 ml-2 copy-action-button`; let buttonTitle = ''; let buttonIconClass = ''; if (side === 'original') { buttonIconClass = 'fas fa-arrow-right'; if (actionType === 'replace') { buttonTitle = '使用此原文行覆盖译文对应行'; } else { // actionType === 'insert' buttonTitle = '将此原文行插入到译文对应位置'; } } // Add logic for buttons on translation side if needed later if (buttonIconClass && !diffClass.includes('diff-common')) { // <--- 修改点在这里 actionButton.innerHTML = `<i class="${buttonIconClass}"></i>`; actionButton.title = buttonTitle; actionButton.addEventListener('click', () => { const textarea = document.querySelector('textarea.translation.form-control'); if (!textarea) return; let lines = textarea.value.split('\n'); const targetIndex = Math.max(0, translationLineIndexForAction); while (lines.length <= targetIndex) { lines.push(''); } if (actionType === 'replace') { // 确保目标索引在数组范围内,如果超出则扩展数组 while (lines.length <= targetIndex) { lines.push(''); } lines[targetIndex] = lineTextForAction; } else { // actionType === 'insert' const effectiveTargetIndex = Math.min(lines.length, targetIndex); lines.splice(effectiveTargetIndex, 0, lineTextForAction); } textarea.value = lines.join('\n'); simulateInputChange(textarea, textarea.value); requestAnimationFrame(() => this.generateDiff()); }); lineDiv.appendChild(actionButton); } } container.appendChild(lineDiv); } } // 添加对比按钮 const diffButton = new Button( '.btn.btn-secondary.show-diff-button', '.toolbar .right .btn-group', '<i class="fas fa-file-alt"></i> 对比文本', function() { new DiffModal().show(); } ); const runAllReplacementsButton = new Button( '.btn.btn-secondary.apply-all-rules-button', '.toolbar .right .btn-group', '<i class="fas fa-cogs"></i> 应用全部替换', function() { const replaceList = JSON.parse(localStorage.getItem('replaceList')) || []; const textareas = document.querySelectorAll('textarea.translation.form-control'); textareas.forEach(textarea => { let text = textarea.value; replaceList.forEach(rule => { if (!rule.disabled && rule.findText) { text = text.replaceAll(rule.findText, rule.replacementText); } }); simulateInputChange(textarea, text); }); } ); // AI 对话框类 class AIChatDialog { constructor() { this.fabId = 'ai-chat-fab'; this.dialogId = 'ai-chat-dialog'; this.messagesContainerId = 'ai-chat-messages'; this.inputAreaId = 'ai-chat-input'; this.sendButtonId = 'ai-chat-send'; this.closeButtonId = 'ai-chat-close'; this.clearHistoryButtonId = 'ai-chat-clear-history'; // New ID for clear button this.isDragging = false; this.dragStartX = 0; this.dragStartY = 0; this.dialogX = 0; this.dialogY = 0; this.sendContextToggleId = 'ai-chat-send-context-toggle'; this.localStorageKeySendContext = 'aiChatSendContextEnabled'; this.aiChatModelInputId = 'aiChatModelInput'; this.aiChatModelDatalistId = 'aiChatModelDatalist'; this.fetchAiChatModelsButtonId = 'fetchAiChatModelsButton'; this.localStorageKeyAiChatModel = 'aiChatModelName'; // New key for AI chat model this.init(); } init() { this.addStyles(); this.insertFab(); // Dialog is inserted only when FAB is clicked for the first time } addStyles() { const css = ` #${this.fabId} { position: fixed; bottom: 20px; right: 20px; width: 50px; height: 50px; background-color: #007bff; color: white; border-radius: 50%; display: flex; justify-content: center; align-items: center; font-size: 24px; cursor: pointer; box-shadow: 0 2px 10px rgba(0,0,0,0.2); z-index: 9998; /* Below dialog */ transition: background-color 0.3s ease; } #${this.fabId}:hover { background-color: #0056b3; } #${this.dialogId} { position: fixed; bottom: 80px; /* Position above FAB */ right: 20px; width: 380px; /* Increased width */ height: 450px; background-color: white; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); display: none; /* Hidden by default */ flex-direction: column; z-index: 9999; overflow: hidden; /* Prevent content spill */ } #${this.dialogId} .ai-chat-header { padding: 10px 15px; background-color: #f8f9fa; border-bottom: 1px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; cursor: move; /* Make header draggable */ } #${this.dialogId} .ai-chat-header h5 { margin: 0; font-size: 1rem; flex-grow: 1; /* Allow title to take space */ } #${this.dialogId} .ai-chat-header .header-buttons { display: flex; align-items: center; } #${this.dialogId} .ai-chat-header .btn-icon { /* Style for icon buttons */ background: none; border: none; font-size: 1.2rem; /* Adjust icon size */ opacity: 0.6; cursor: pointer; padding: 5px; margin-left: 8px; } #${this.dialogId} .ai-chat-header .btn-icon:hover { opacity: 1; } #${this.messagesContainerId} { flex-grow: 1; overflow-y: auto; padding: 15px; background-color: #f0f0f0; /* Light grey background for messages */ } #${this.messagesContainerId} .message { margin-bottom: 10px; padding: 8px 12px; border-radius: 15px; max-width: 80%; word-wrap: break-word; } #${this.messagesContainerId} .message.user { background-color: #007bff; color: white; margin-left: auto; border-bottom-right-radius: 5px; } #${this.messagesContainerId} .message.ai { background-color: #e9ecef; color: #333; margin-right: auto; border-bottom-left-radius: 5px; } #${this.messagesContainerId} .message.error { background-color: #f8d7da; color: #721c24; margin-right: auto; border-bottom-left-radius: 5px; font-style: italic; } #${this.dialogId} .ai-chat-input-area { display: flex; align-items: flex-start; /* Align items to the start for multi-line textarea */ padding: 10px; border-top: 1px solid #dee2e6; background-color: #f8f9fa; } #${this.inputAreaId} { flex-grow: 1; margin-right: 8px; /* Reduced margin */ resize: none; /* Prevent manual resize */ min-height: 40px; /* Ensure it's at least one line */ max-height: 120px; /* Limit max height for textarea */ overflow-y: auto; /* Allow scroll if content exceeds max-height */ line-height: 1.5; /* Adjust line height for better readability */ } #${this.sendButtonId} { height: 40px; /* Keep button height consistent */ min-width: 65px; /* Ensure button has enough space for "发送" */ padding-left: 12px; padding-right: 12px; align-self: flex-end; /* Align button to bottom if textarea grows */ } .ai-chat-options { padding: 5px 10px; background-color: #f8f9fa; border-bottom: 1px solid #dee2e6; font-size: 0.85rem; } .ai-chat-options .custom-control-label { font-weight: normal; } `; GM_addStyle(css); } insertFab() { if (document.getElementById(this.fabId)) return; const fab = document.createElement('div'); fab.id = this.fabId; fab.innerHTML = '<i class="fas fa-robot"></i>'; // Example icon fab.title = 'AI 助手'; fab.addEventListener('click', () => this.toggleDialog()); document.body.appendChild(fab); } insertDialog() { if (document.getElementById(this.dialogId)) return; const dialog = document.createElement('div'); dialog.id = this.dialogId; dialog.innerHTML = ` <div class="ai-chat-header"> <h5>AI 助手</h5> <div class="header-buttons"> <button type="button" class="btn-icon" id="${this.clearHistoryButtonId}" title="清空聊天记录"> <i class="fas fa-trash-alt"></i> </button> <button type="button" class="btn-icon close" id="${this.closeButtonId}" aria-label="Close" title="关闭对话框"> <span aria-hidden="true">×</span> </button> </div> </div> <div id="${this.messagesContainerId}"> <div class="message ai">你好!有什么可以帮你的吗?</div> </div> <div class="ai-chat-options"> <div class="custom-control custom-switch custom-control-sm"> <input type="checkbox" class="custom-control-input" id="${this.sendContextToggleId}"> <label class="custom-control-label" for="${this.sendContextToggleId}">发送页面上下文给AI</label> </div> </div> <div class="ai-chat-options" style="border-top: 1px solid #dee2e6; padding-top: 8px; margin-top: 5px;"> <!-- Model selection for AI Chat --> <div class="form-group mb-1"> <label for="${this.aiChatModelInputId}" style="font-size: 0.85rem; margin-bottom: .2rem;">AI 模型:</label> <div class="input-group input-group-sm"> <input type="text" class="form-control form-control-sm" id="${this.aiChatModelInputId}" placeholder="默认 (gpt-4o-mini)" list="${this.aiChatModelDatalistId}"> <datalist id="${this.aiChatModelDatalistId}"></datalist> <div class="input-group-append"> <button class="btn btn-outline-secondary btn-sm" type="button" id="${this.fetchAiChatModelsButtonId}" title="获取模型列表"> <i class="fas fa-sync-alt"></i> </button> </div> </div> </div> </div> <div class="ai-chat-input-area"> <textarea id="${this.inputAreaId}" class="form-control" placeholder="输入消息..."></textarea> <button id="${this.sendButtonId}" class="btn btn-primary">发送</button> </div> `; document.body.appendChild(dialog); // Add event listeners document.getElementById(this.closeButtonId).addEventListener('click', () => this.toggleDialog(false)); document.getElementById(this.clearHistoryButtonId).addEventListener('click', () => this.clearChatHistory()); document.getElementById(this.sendButtonId).addEventListener('click', () => this.sendMessage()); const sendContextToggle = document.getElementById(this.sendContextToggleId); const aiChatModelInput = document.getElementById(this.aiChatModelInputId); const fetchAiChatModelsButton = document.getElementById(this.fetchAiChatModelsButtonId); // Load saved preference for sending context const savedSendContextPreference = localStorage.getItem(this.localStorageKeySendContext); if (savedSendContextPreference === 'true') { sendContextToggle.checked = true; } else if (savedSendContextPreference === 'false') { sendContextToggle.checked = false; } else { sendContextToggle.checked = true; // Default to true if not set localStorage.setItem(this.localStorageKeySendContext, 'true'); } sendContextToggle.addEventListener('change', (e) => { localStorage.setItem(this.localStorageKeySendContext, e.target.checked); }); // AI Chat Model preferences let initialAiChatModel = localStorage.getItem(this.localStorageKeyAiChatModel); if (!initialAiChatModel) { // If no specific AI chat model is saved, try to use the model from the current translation config const currentTranslationConfigName = getCurrentApiConfigName(); if (currentTranslationConfigName) { const configs = getApiConfigurations(); const activeTranslationConfig = configs.find(c => c.name === currentTranslationConfigName); if (activeTranslationConfig && activeTranslationConfig.model) { initialAiChatModel = activeTranslationConfig.model; // Save this inherited model as the current AI chat model localStorage.setItem(this.localStorageKeyAiChatModel, initialAiChatModel); } } } aiChatModelInput.value = initialAiChatModel || ''; // Fallback to empty if no model found aiChatModelInput.addEventListener('input', () => { localStorage.setItem(this.localStorageKeyAiChatModel, aiChatModelInput.value); }); fetchAiChatModelsButton.addEventListener('click', async () => { await this.fetchModelsAndUpdateDatalistForChat(); }); document.getElementById(this.inputAreaId).addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); // Prevent newline this.sendMessage(); } }); // Auto-resize textarea const textarea = document.getElementById(this.inputAreaId); textarea.addEventListener('input', () => { // Auto-resize textarea based on content, up to max-height textarea.style.height = 'auto'; // Reset height to shrink if text is deleted let scrollHeight = textarea.scrollHeight; const maxHeight = parseInt(window.getComputedStyle(textarea).maxHeight, 10); if (maxHeight && scrollHeight > maxHeight) { textarea.style.height = maxHeight + 'px'; textarea.style.overflowY = 'auto'; } else { textarea.style.height = scrollHeight + 'px'; textarea.style.overflowY = 'hidden'; } }); // Make dialog draggable const header = dialog.querySelector('.ai-chat-header'); header.addEventListener('mousedown', (e) => { this.isDragging = true; this.dragStartX = e.clientX - dialog.offsetLeft; this.dragStartY = e.clientY - dialog.offsetTop; header.style.cursor = 'grabbing'; // Change cursor while dragging // Prevent text selection during drag document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (!this.isDragging) return; const newX = e.clientX - this.dragStartX; const newY = e.clientY - this.dragStartY; // Keep dialog within viewport boundaries (optional) const maxX = window.innerWidth - dialog.offsetWidth; const maxY = window.innerHeight - dialog.offsetHeight; dialog.style.left = Math.max(0, Math.min(newX, maxX)) + 'px'; dialog.style.top = Math.max(0, Math.min(newY, maxY)) + 'px'; // Update position relative to bottom/right if needed, but left/top is simpler for dragging dialog.style.bottom = 'auto'; dialog.style.right = 'auto'; }); document.addEventListener('mouseup', () => { if (this.isDragging) { this.isDragging = false; header.style.cursor = 'move'; document.body.style.userSelect = ''; // Restore text selection } }); } toggleDialog(forceShow = null) { if (!document.getElementById(this.dialogId)) { this.insertDialog(); // Create dialog on first open } const dialog = document.getElementById(this.dialogId); const shouldShow = forceShow !== null ? forceShow : dialog.style.display === 'none'; if (shouldShow) { dialog.style.display = 'flex'; // Focus input when opened setTimeout(() => document.getElementById(this.inputAreaId)?.focus(), 0); } else { dialog.style.display = 'none'; } } displayMessage(text, sender = 'ai', isError = false) { const messagesContainer = document.getElementById(this.messagesContainerId); if (!messagesContainer) return; const messageDiv = document.createElement('div'); messageDiv.classList.add('message', sender); if (isError) { messageDiv.classList.add('error'); } if (sender === 'ai' && !isError) { messageDiv.innerHTML = text.replace(/\n/g, '<br>'); // Initial text or full text if not streaming } else { messageDiv.textContent = text; } messagesContainer.appendChild(messageDiv); messagesContainer.scrollTop = messagesContainer.scrollHeight; return messageDiv; // Return the created message element for potential stream updates } updateAIMessage(messageElement, chunk) { if (!messageElement) return; // Append new chunk, converting newlines. // For proper Markdown streaming, this would need to be more sophisticated, // potentially re-rendering the whole Markdown on each chunk or using a lib that supports streaming. messageElement.innerHTML += chunk.replace(/\n/g, '<br>'); const messagesContainer = document.getElementById(this.messagesContainerId); if (messagesContainer) { messagesContainer.scrollTop = messagesContainer.scrollHeight; } } clearChatHistory() { const messagesContainer = document.getElementById(this.messagesContainerId); if (messagesContainer) { messagesContainer.innerHTML = ''; // Clear all messages this.displayMessage('你好!有什么可以帮你的吗?', 'ai'); // Display initial greeting } } async sendMessage() { const inputArea = document.getElementById(this.inputAreaId); const sendButton = document.getElementById(this.sendButtonId); const messageText = inputArea.value.trim(); if (!messageText) return; this.displayMessage(messageText, 'user'); inputArea.value = ''; // Reset textarea height after sending inputArea.style.height = 'auto'; inputArea.style.height = (inputArea.scrollHeight < 40 ? 40 : inputArea.scrollHeight) + 'px'; if (parseInt(inputArea.style.height) > parseInt(window.getComputedStyle(inputArea).maxHeight)) { inputArea.style.height = window.getComputedStyle(inputArea).maxHeight; inputArea.style.overflowY = 'auto'; } else { inputArea.style.overflowY = 'hidden'; } inputArea.disabled = true; sendButton.disabled = true; // Display "Thinking..." and get the message element let aiMessageElement = this.displayMessage('思考中...', 'ai'); const messagesContainerElement = document.getElementById(this.messagesContainerId); try { // Call chatWithAI, now potentially streaming await this.chatWithAI(messageText, (chunk) => { if (aiMessageElement && aiMessageElement.textContent === '思考中...') { // Replace "Thinking..." with the first chunk aiMessageElement.innerHTML = chunk.replace(/\n/g, '<br>'); } else if (aiMessageElement) { // Append subsequent chunks this.updateAIMessage(aiMessageElement, chunk); } }); // If the "Thinking..." message is still there (e.g. stream was empty or very fast non-streamed error) // This case should ideally be handled by the streaming logic itself replacing "Thinking..." // For non-streaming success, chatWithAI would have to call the onChunk callback once. // If chatWithAI throws an error before any chunk, the catch block handles it. } catch (error) { if (aiMessageElement && messagesContainerElement) { // Ensure element exists // If "Thinking..." is still shown, replace it with error. Otherwise, display a new error message. if (aiMessageElement.textContent === '思考中...') { aiMessageElement.classList.add('error'); aiMessageElement.innerHTML = `抱歉,与 AI 通信时出错: ${error.message}`.replace(/\n/g, '<br>'); } else { this.displayMessage(`抱歉,与 AI 通信时出错: ${error.message}`, 'ai', true); } } else { // Fallback if aiMessageElement somehow isn't there this.displayMessage(`抱歉,与 AI 通信时出错: ${error.message}`, 'ai', true); } console.error('AI Chat Error:', error); this.displayMessage(`抱歉,与 AI 通信时出错: ${error.message}`, 'ai', true); } finally { inputArea.disabled = false; sendButton.disabled = false; inputArea.focus(); } } // Modified chat function to support streaming async fetchModelsAndUpdateDatalistForChat() { const modelDatalist = document.getElementById(this.aiChatModelDatalistId); const fetchButton = document.getElementById(this.fetchAiChatModelsButtonId); const originalButtonHtml = fetchButton.innerHTML; fetchButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; fetchButton.disabled = true; let API_SECRET_KEY = ''; let BASE_URL = ''; const currentConfigName = getCurrentApiConfigName(); let activeConfig = null; if (currentConfigName) { const configs = getApiConfigurations(); activeConfig = configs.find(c => c.name === currentConfigName); } if (activeConfig) { BASE_URL = activeConfig.baseUrl; API_SECRET_KEY = activeConfig.apiKey; } else { showToast('请先在“机器翻译”配置中选择一个有效的 API 配置。', 'error', 5000); fetchButton.innerHTML = originalButtonHtml; fetchButton.disabled = false; return; } if (!BASE_URL || !API_SECRET_KEY) { showToast('当前选中的 API 配置缺少 Base URL 或 API Key。', 'error', 5000); fetchButton.innerHTML = originalButtonHtml; fetchButton.disabled = false; return; } const modelsUrl = `${BASE_URL}${BASE_URL.endsWith('/') ? '' : '/'}models`; try { const response = await fetch(modelsUrl, { method: 'GET', headers: { 'Authorization': `Bearer ${API_SECRET_KEY}` } }); if (!response.ok) { const errorData = await response.text(); showToast(`为AI助手获取模型列表失败: ${response.status} - ${errorData.substring(0,100)}`, 'error', 5000); return; } const data = await response.json(); if (data && data.data && Array.isArray(data.data)) { modelDatalist.innerHTML = ''; // Clear existing options data.data.forEach(model => { if (model.id) { const option = document.createElement('option'); option.value = model.id; modelDatalist.appendChild(option); } }); showToast('AI助手模型列表已更新。', 'success'); } else { showToast('AI助手模型列表响应数据格式不符合预期。', 'warning', 4000); } } catch (error) { showToast(`为AI助手获取模型列表时发生网络错误: ${error.message}`, 'error', 5000); } finally { fetchButton.innerHTML = originalButtonHtml; fetchButton.disabled = false; } } async chatWithAI(userMessage, onChunkReceived) { let API_SECRET_KEY = ''; let BASE_URL = ''; const currentConfigName = getCurrentApiConfigName(); // This is the translation config let activeTranslationConfig = null; if (currentConfigName) { const configs = getApiConfigurations(); activeTranslationConfig = configs.find(c => c.name === currentConfigName); } // Get AI Chat specific model. // Priority: 1. localStorageKeyAiChatModel, 2. activeTranslationConfig.model, 3. 'gpt-4o-mini' let model = localStorage.getItem(this.localStorageKeyAiChatModel); if (!model && activeTranslationConfig && activeTranslationConfig.model) { model = activeTranslationConfig.model; } if (!model) { model = 'gpt-4o-mini'; // Ultimate fallback } let temperature = 0.7; // Default temperature for chat let systemPrompt = `你是一个在 Paratranz 翻译平台工作的 AI 助手。请根据用户的问题,结合当前条目的原文、上下文、术语等信息(如果提供),提供翻译建议、解释或回答相关问题。请保持回答简洁明了。`; if (activeTranslationConfig) { BASE_URL = activeTranslationConfig.baseUrl; API_SECRET_KEY = activeTranslationConfig.apiKey; temperature = (activeTranslationConfig.temperature !== undefined && activeTranslationConfig.temperature !== '') ? parseFloat(activeTranslationConfig.temperature) : temperature; } else { console.warn("AI Chat: No active API configuration selected for API credentials. Chat might fail."); // Attempt to use fallback keys if absolutely necessary, but ideally user should configure BASE_URL = localStorage.getItem('baseUrl_fallback_for_translate') || ''; API_SECRET_KEY = localStorage.getItem('apiKey_fallback_for_translate') || ''; } if (!BASE_URL || !API_SECRET_KEY) { throw new Error("API Base URL 或 Key 未配置。请在“机器翻译”配置中设置。"); } // --- Context Gathering (Optional but Recommended) --- let contextInfo = ""; const shouldSendContext = localStorage.getItem(this.localStorageKeySendContext) === 'true'; if (shouldSendContext) { try { const originalDiv = document.querySelector('.original.well'); if (originalDiv) contextInfo += `当前原文 (Original Text):\n${originalDiv.innerText.trim()}\n\n`; const currentTranslationTextarea = document.querySelector('textarea.translation.form-control'); if (currentTranslationTextarea && currentTranslationTextarea.value.trim()) { contextInfo += `当前翻译 (Current Translation):\n${currentTranslationTextarea.value.trim()}\n\n`; } const contextNoteDiv = document.querySelector('.context .well'); if (contextNoteDiv) contextInfo += `上下文注释 (Context Note):\n${contextNoteDiv.innerText.trim()}\n\n`; const terms = await getTermsData(); // Reuse existing function if (terms.length > 0) { contextInfo += `相关术语 (Terms):\n${terms.map(t => `${t.source} -> ${t.target}${t.note ? ` (${t.note})` : ''}`).join('\n')}\n\n`; } } catch (e) { console.warn("AI Chat: Error gathering context:", e); } } // --- End Context Gathering --- const messages = [ { role: "system", content: systemPrompt } ]; if (contextInfo) { messages.push({ role: "user", content: `请参考以下上下文信息:\n${contextInfo}我的问题是:\n${userMessage}` }); } else { messages.push({ role: "user", content: userMessage }); } const requestBody = { model, temperature, messages, stream: true }; // Enable streaming const response = await fetch(`${BASE_URL}${BASE_URL.endsWith('/') ? '' : '/'}chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_SECRET_KEY}` }, body: JSON.stringify(requestBody), }); if (!response.ok) { let errorData; try { errorData = await response.json(); } catch (e) { /* ignore parsing error for non-json errors */ } console.error('AI Chat API Error:', errorData || response.statusText); throw new Error(`API 请求失败: ${response.status} - ${errorData?.error?.message || errorData?.message || response.statusText}`); } if (!response.body) { throw new Error('ReadableStream not available in response.'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let eolIndex; while ((eolIndex = buffer.indexOf('\n')) >= 0) { const line = buffer.substring(0, eolIndex).trim(); buffer = buffer.substring(eolIndex + 1); if (line.startsWith('data: ')) { const jsonData = line.substring(6); if (jsonData === '[DONE]') { console.log("Stream finished."); return; // Stream ended } try { const parsed = JSON.parse(jsonData); if (parsed.choices && parsed.choices[0]?.delta?.content) { onChunkReceived(parsed.choices[0].delta.content); } } catch (e) { console.error('Error parsing stream JSON:', e, jsonData); } } } } // Process any remaining buffer content if necessary (though for SSE, lines usually end with \n) if (buffer.trim().startsWith('data: ')) { const jsonData = buffer.trim().substring(6); if (jsonData !== '[DONE]') { try { const parsed = JSON.parse(jsonData); if (parsed.choices && parsed.choices[0]?.delta?.content) { onChunkReceived(parsed.choices[0].delta.content); } } catch (e) { console.error('Error parsing final buffer JSON:', e, jsonData); } } } } catch (error) { console.error('Error reading stream:', error); throw new Error(`读取流时出错: ${error.message}`); } finally { reader.releaseLock(); } } } // --- Initialization --- const aiChatDialog = new AIChatDialog(); // Initialize AI Chat Dialog })();