您需要先安装一个扩展,例如 篡改猴、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/diff@5.1.0/dist/diff.min.js
- // @require https://cdn.jsdelivr.net/npm/diff2html@3.4.51/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
- })();