// ==UserScript==
// @name ParaTranz-AI
// @namespace http://tampermonkey.net/
// @version 1.4.2
// @description ParaTranz文本替换和AI翻译功能拓展。
// @author HCPTangHY
// @license WTFPL
// @match https://paratranz.cn/*
// @icon https://paratranz.cn/favicon.png
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/diff.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/bundles/js/diff2html-ui.min.js
// @resource css https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css
// @grant GM_getResourceURL
// @grant GM_getResourceText
// @grant GM_addStyle
// ==/UserScript==
const PARATRANZ_AI_TOAST_STYLES = `
/* Toast Notifications */
#toast-container-paratranz-ai {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
display: flex;
flex-direction: column-reverse;
align-items: center;
pointer-events: none; /* Allow clicks to pass through the container */
}
.toast-message {
padding: 10px 20px;
margin-top: 10px;
border-radius: 5px;
color: white;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
min-width: 250px;
max-width: 80vw;
text-align: center;
pointer-events: all; /* Individual toasts should be interactive if needed */
}
.toast-message.show {
opacity: 1;
transform: translateY(0);
}
.toast-message.toast-success { background-color: #28a745; }
.toast-message.toast-error { background-color: #dc3545; }
.toast-message.toast-warning { background-color: #ffc107; color: black; }
.toast-message.toast-info { background-color: #17a2b8; }
`;
GM_addStyle(GM_getResourceText("css") + PARATRANZ_AI_TOAST_STYLES);
// fork from HeliumOctahelide https://greasyfork.org/zh-CN/scripts/503063-paratranz-tools
(function() {
'use strict';
// Helper function for Toast Notifications
function showToast(message, type = 'info', duration = 3000) {
let toastContainer = document.getElementById('toast-container-paratranz-ai');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container-paratranz-ai';
document.body.appendChild(toastContainer);
}
const toast = document.createElement('div');
toast.className = `toast-message toast-${type}`;
toast.textContent = message;
toastContainer.appendChild(toast);
// Animate in
requestAnimationFrame(() => {
toast.classList.add('show');
});
// Auto-dismiss
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => {
if (toast.parentElement) { // Check if still attached
toast.remove();
}
if (toastContainer && !toastContainer.hasChildNodes()) {
// Check if toastContainer is still in the DOM before removing
if (toastContainer.parentElement) {
toastContainer.remove();
}
}
}, { once: true });
}, duration);
}
// 基类定义
class BaseComponent {
constructor(selector) {
this.selector = selector;
this.init();
}
init() {
this.checkExistence();
}
checkExistence() {
const element = document.querySelector(this.selector);
if (!element) {
this.insert();
}
setTimeout(() => this.checkExistence(), 1000);
}
insert() {
// 留空,子类实现具体插入逻辑
}
}
// 按钮类定义,继承自BaseComponent
class Button extends BaseComponent {
constructor(selector, toolbarSelector, htmlContent, callback) {
super(selector);
this.toolbarSelector = toolbarSelector;
this.htmlContent = htmlContent;
this.callback = callback;
}
insert() {
const toolbar = document.querySelector(this.toolbarSelector);
if (!toolbar) {
console.log(`Toolbar not found: ${this.toolbarSelector}`);
return;
}
if (toolbar && !document.querySelector(this.selector)) {
const button = document.createElement('button');
button.className = this.selector.split('.').join(' ');
button.innerHTML = this.htmlContent;
button.type = 'button';
button.addEventListener('click', this.callback);
toolbar.insertAdjacentElement('afterbegin', button);
console.log(`Button inserted: ${this.selector}`);
}
}
}
// 手风琴类定义,继承自BaseComponent
class Accordion extends BaseComponent {
constructor(selector, parentSelector) {
super(selector);
this.parentSelector = parentSelector;
}
insert() {
const parentElement = document.querySelector(this.parentSelector);
if (!parentElement) {
console.log(`Parent element not found: ${this.parentSelector}`);
return;
}
if (parentElement && !document.querySelector(this.selector)) {
const accordionHTML = `
<div class="accordion" id="accordionExample"></div>
<hr>
`;
parentElement.insertAdjacentHTML('afterbegin', accordionHTML);
}
}
addCard(card) {
card.insert();
}
}
// 卡片类定义,继承自BaseComponent
class Card extends BaseComponent {
constructor(selector, parentSelector, headingId, title, contentHTML) {
super(selector);
this.parentSelector = parentSelector;
this.headingId = headingId;
this.title = title;
this.contentHTML = contentHTML;
}
insert() {
const parentElement = document.querySelector(this.parentSelector);
if (!parentElement) {
console.log(`Parent element not found: ${this.parentSelector}`);
return;
}
if (parentElement && !document.querySelector(this.selector)) {
const cardHTML = `
<div class="card m-0">
<div class="card-header p-0" id="${this.headingId}">
<h2 class="mb-0">
<button class="btn btn-link" type="button" aria-expanded="false" aria-controls="${this.selector.substring(1)}">
${this.title}
</button>
</h2>
</div>
<div id="${this.selector.substring(1)}" class="collapse" aria-labelledby="${this.headingId}" data-parent="#accordionExample" style="max-height: 70vh; overflow-y: auto;">
<div class="card-body">
${this.contentHTML}
</div>
</div>
</div>
`;
parentElement.insertAdjacentHTML('beforeend', cardHTML);
const toggleButton = document.querySelector(`#${this.headingId} button`);
const collapseDiv = document.querySelector(this.selector);
toggleButton.addEventListener('click', function() {
if (collapseDiv.style.maxHeight === '0px' || !collapseDiv.style.maxHeight) {
collapseDiv.style.display = 'block';
requestAnimationFrame(() => {
collapseDiv.style.maxHeight = collapseDiv.scrollHeight + 'px';
});
toggleButton.setAttribute('aria-expanded', 'true');
} else {
collapseDiv.style.maxHeight = '0px';
toggleButton.setAttribute('aria-expanded', 'false');
collapseDiv.addEventListener('transitionend', () => {
if (collapseDiv.style.maxHeight === '0px') {
collapseDiv.style.display = 'none';
}
}, { once: true });
}
});
collapseDiv.style.maxHeight = '0px';
collapseDiv.style.overflow = 'hidden';
collapseDiv.style.transition = 'max-height 0.3s ease';
}
}
}
// 定义具体的文本替换管理卡片
class StringReplaceCard extends Card {
constructor(parentSelector) {
const headingId = 'headingOne';
const contentHTML = `
<div id="manageReplacePage">
<div id="replaceListContainer"></div>
<div class="replace-item mb-3 p-2" style="border: 1px solid #ccc; border-radius: 8px;">
<input type="text" placeholder="查找文本" id="newFindText" class="form-control mb-2"/>
<input type="text" placeholder="替换为" id="newReplacementText" class="form-control mb-2"/>
<button class="btn btn-secondary" id="addReplaceRuleButton">
<i class="far fa-plus-circle"></i> 添加替换规则
</button>
</div>
<div class="mt-3">
<button class="btn btn-primary" id="exportReplaceRulesButton">导出替换规则</button>
<input type="file" id="importReplaceRuleInput" class="d-none"/>
<button class="btn btn-primary" id="importReplaceRuleButton">导入替换规则</button>
</div>
</div>
`;
super('#collapseOne', parentSelector, headingId, '文本替换', contentHTML);
}
insert() {
super.insert();
if (!document.querySelector('#collapseOne')) {
return;
}
document.getElementById('addReplaceRuleButton').addEventListener('click', this.addReplaceRule);
document.getElementById('exportReplaceRulesButton').addEventListener('click', this.exportReplaceRules);
document.getElementById('importReplaceRuleButton').addEventListener('click', () => {
document.getElementById('importReplaceRuleInput').click();
});
document.getElementById('importReplaceRuleInput').addEventListener('change', this.importReplaceRules);
this.loadReplaceList();
}
addReplaceRule = () => {
const findText = document.getElementById('newFindText').value;
const replacementText = document.getElementById('newReplacementText').value;
if (findText) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
replaceList.push({ findText, replacementText, disabled: false });
localStorage.setItem('replaceList', JSON.stringify(replaceList));
this.loadReplaceList();
document.getElementById('newFindText').value = '';
document.getElementById('newReplacementText').value = '';
}
};
updateRuleText(index, type, value) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
if (replaceList[index]) {
if (type === 'findText') {
replaceList[index].findText = value;
} else if (type === 'replacementText') {
replaceList[index].replacementText = value;
}
localStorage.setItem('replaceList', JSON.stringify(replaceList));
}
}
loadReplaceList() {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
const replaceListDiv = document.getElementById('replaceListContainer');
replaceListDiv.innerHTML = '';
// Add scrollbar when rules are too many
replaceListDiv.style.maxHeight = '40vh'; // Adjust as needed
replaceListDiv.style.overflowY = 'auto';
replaceList.forEach((rule, index) => {
const ruleDiv = document.createElement('div');
ruleDiv.className = 'replace-item mb-3 p-2';
ruleDiv.style.border = '1px solid #ccc';
ruleDiv.style.borderRadius = '8px';
ruleDiv.style.transition = 'transform 0.3s';
ruleDiv.style.backgroundColor = rule.disabled ? '#f2dede' : '#fff';
const inputsDiv = document.createElement('div');
inputsDiv.className = 'mb-2';
const findInput = document.createElement('input');
findInput.type = 'text';
findInput.className = 'form-control mb-1';
findInput.value = rule.findText;
findInput.placeholder = '查找文本';
findInput.dataset.index = index;
findInput.addEventListener('change', (event) => this.updateRuleText(index, 'findText', event.target.value));
inputsDiv.appendChild(findInput);
const replInput = document.createElement('input');
replInput.type = 'text';
replInput.className = 'form-control';
replInput.value = rule.replacementText;
replInput.placeholder = '替换为';
replInput.dataset.index = index;
replInput.addEventListener('change', (event) => this.updateRuleText(index, 'replacementText', event.target.value));
inputsDiv.appendChild(replInput);
ruleDiv.appendChild(inputsDiv);
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'd-flex justify-content-between';
const leftButtonGroup = document.createElement('div');
leftButtonGroup.className = 'btn-group';
leftButtonGroup.setAttribute('role', 'group');
const moveUpButton = this.createButton('上移', 'fas fa-arrow-up', () => this.moveReplaceRule(index, -1));
const moveDownButton = this.createButton('下移', 'fas fa-arrow-down', () => this.moveReplaceRule(index, 1));
const toggleButton = this.createButton('禁用/启用', rule.disabled ? 'fas fa-toggle-off' : 'fas fa-toggle-on', () => this.toggleReplaceRule(index));
const applyButton = this.createButton('应用此规则', 'fas fa-play', () => this.applySingleReplaceRule(index));
leftButtonGroup.append(moveUpButton, moveDownButton, toggleButton, applyButton);
const rightButtonGroup = document.createElement('div');
rightButtonGroup.className = 'btn-group';
rightButtonGroup.setAttribute('role', 'group');
const deleteButton = this.createButton('删除', 'far fa-trash-alt', () => this.deleteReplaceRule(index), 'btn-danger');
rightButtonGroup.appendChild(deleteButton);
buttonsDiv.append(leftButtonGroup, rightButtonGroup);
ruleDiv.appendChild(buttonsDiv);
replaceListDiv.appendChild(ruleDiv);
});
replaceListDiv.style.display = 'none';
replaceListDiv.offsetHeight;
replaceListDiv.style.display = '';
}
createButton(title, iconClass, onClick, btnClass = 'btn-secondary') {
const button = document.createElement('button');
button.className = `btn ${btnClass}`;
button.title = title;
button.innerHTML = `<i class="${iconClass}"></i>`;
button.addEventListener('click', onClick);
return button;
}
deleteReplaceRule(index) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
replaceList.splice(index, 1);
localStorage.setItem('replaceList', JSON.stringify(replaceList));
this.loadReplaceList();
}
toggleReplaceRule(index) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
replaceList[index].disabled = !replaceList[index].disabled;
localStorage.setItem('replaceList', JSON.stringify(replaceList));
this.loadReplaceList();
}
applySingleReplaceRule(index) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
const rule = replaceList[index];
if (rule.disabled || !rule.findText) return;
const textareas = document.querySelectorAll('textarea.translation.form-control');
textareas.forEach(textarea => {
let text = textarea.value;
text = text.replaceAll(rule.findText, rule.replacementText);
this.simulateInputChange(textarea, text);
});
}
moveReplaceRule(index, direction) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
const newIndex = index + direction;
if (newIndex >= 0 && newIndex < replaceList.length) {
const [movedItem] = replaceList.splice(index, 1);
replaceList.splice(newIndex, 0, movedItem);
localStorage.setItem('replaceList', JSON.stringify(replaceList));
this.loadReplaceListWithAnimation(index, newIndex);
}
}
loadReplaceListWithAnimation(oldIndex, newIndex) {
const replaceListDiv = document.getElementById('replaceListContainer');
const items = replaceListDiv.querySelectorAll('.replace-item');
if (items[oldIndex] && items[newIndex]) {
items[oldIndex].style.transform = `translateY(${(newIndex - oldIndex) * 100}%)`;
items[newIndex].style.transform = `translateY(${(oldIndex - newIndex) * 100}%)`;
}
setTimeout(() => {
this.loadReplaceList();
}, 300);
}
simulateInputChange(element, newValue) {
const inputEvent = new Event('input', { bubbles: true });
const originalValue = element.value;
element.value = newValue;
const tracker = element._valueTracker;
if (tracker) {
tracker.setValue(originalValue);
}
element.dispatchEvent(inputEvent);
}
exportReplaceRules() {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
const json = JSON.stringify(replaceList, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'replaceList.json';
a.click();
URL.revokeObjectURL(url);
}
importReplaceRules(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
try {
const content = e.target.result;
const importedList = JSON.parse(content);
if (Array.isArray(importedList) && importedList.every(item => typeof item.findText === 'string' && typeof item.replacementText === 'string')) {
localStorage.setItem('replaceList', JSON.stringify(importedList));
this.loadReplaceList();
showToast('替换规则导入成功!', 'success');
} else {
showToast('导入的文件格式不正确。', 'error');
}
} catch (error) {
console.error('Error importing rules:', error);
showToast('导入失败,文件可能已损坏或格式不正确。', 'error');
}
};
reader.readAsText(file);
event.target.value = null;
}
}
// 定义具体的机器翻译卡片
class MachineTranslationCard extends Card {
constructor(parentSelector) {
const headingId = 'headingTwo';
const contentHTML = `
<button class="btn btn-primary" id="openTranslationConfigButton">配置翻译</button>
<div class="mt-3">
<div class="d-flex">
<textarea id="originalText" class="form-control" style="width: 100%; height: 25vh;"></textarea>
<div class="d-flex flex-column ml-2">
<button class="btn btn-secondary mb-2" id="copyOriginalButton">
<i class="fas fa-copy"></i>
</button>
<button class="btn btn-secondary" id="translateButton">
<i class="fas fa-globe"></i>
</button>
</div>
</div>
<div class="d-flex mt-2">
<textarea id="translatedText" class="form-control" style="width: 100%; height: 25vh;"></textarea>
<div class="d-flex flex-column ml-2">
<button class="btn btn-secondary mb-2" id="pasteTranslationButton">
<i class="fas fa-arrow-alt-left"></i>
</button>
<button class="btn btn-secondary" id="copyTranslationButton">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
<!-- Translation Configuration Modal -->
<div class="modal" id="translationConfigModal" tabindex="-1" role="dialog" style="display: none;">
<div class="modal-dialog modal-lg" role="document"> <!-- Added modal-lg -->
<div class="modal-content">
<div class="modal-header py-2">
<h5 class="modal-title">翻译配置</h5>
<button type="button" class="close" id="closeTranslationConfigModal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body p-3" style="max-height: 80vh; overflow-y: auto;"> <!-- Increased max-height, added p-3 -->
<form id="translationConfigForm">
<div class="form-row">
<div class="form-group col-md-7">
<label for="apiConfigSelect">API 配置</label>
<select class="custom-select" id="apiConfigSelect">
<option value="" selected>选择或新建配置...</option>
</select>
</div>
<div class="form-group col-md-5 d-flex align-items-end">
<button type="button" class="btn btn-success mr-2 w-100" id="saveApiConfigButton" title="保存或更新当前填写的配置"><i class="fas fa-save"></i> 保存</button>
<button type="button" class="btn btn-info mr-2 w-100" id="newApiConfigButton" title="清空表单以新建配置"><i class="fas fa-plus-circle"></i> 新建</button>
<button type="button" class="btn btn-danger w-100" id="deleteApiConfigButton" title="删除下拉框中选中的配置"><i class="fas fa-trash-alt"></i> 删除</button>
</div>
</div>
<hr>
<p><strong>当前配置详情:</strong></p>
<div class="form-row">
<div class="form-group col-md-6">
<label for="apiConfigName">配置名称</label>
<input type="text" class="form-control" id="apiConfigName" placeholder="为此配置命名 (例如 My OpenAI)">
</div>
<div class="form-group col-md-6">
<label for="apiKey">API Key</label>
<input type="text" class="form-control" id="apiKey" placeholder="Enter API key">
</div>
</div>
<div class="form-group">
<label for="baseUrl">Base URL</label>
<div class="input-group">
<input type="text" class="form-control" id="baseUrl" placeholder="Enter base URL">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" title="OpenAI API" id="openaiButton">
<img src="https://paratranz.cn/media/f2014e0647283fcff54e3a8f4edaa488.png!webp160" style="width: 16px; height: 16px;">
</button>
<button class="btn btn-outline-secondary" type="button" title="DeepSeek API" id="deepseekButton">
<img src="https://paratranz.cn/media/0bfd294f99b9141e3432c0ffbf3d8e78.png!webp160" style="width: 16px; height: 16px;">
</button>
</div>
</div>
<small id="fullUrlPreview" class="form-text text-muted mt-1" style="word-break: break-all;"></small>
</div>
<div class="form-row">
<div class="form-group col-md-8">
<label for="model">Model</label>
<div class="input-group">
<input type="text" class="form-control" id="model" placeholder="Enter model (e.g., gpt-4o-mini)" list="modelDatalist">
<datalist id="modelDatalist"></datalist>
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="fetchModelsButton" title="Fetch Models from API">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
</div>
<div class="form-group col-md-4">
<label for="temperature">Temperature</label>
<input type="number" step="0.1" class="form-control" id="temperature" placeholder="e.g., 0.7">
</div>
</div>
<div class="form-group">
<label for="prompt">Prompt</label>
<textarea class="form-control" id="prompt" rows="3" placeholder="Enter prompt or use default prompt. 可用变量: {{original}}, {{context}}, {{terms}}"></textarea>
</div>
<div class="form-group">
<label for="promptLibrarySelect">Prompt 库</label>
<div class="input-group">
<select class="custom-select" id="promptLibrarySelect">
<option value="" selected>从库中选择或管理...</option>
</select>
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="saveToPromptLibraryButton" title="保存当前Prompt到库"><i class="fas fa-save"></i></button>
<button class="btn btn-outline-danger" type="button" id="deleteFromPromptLibraryButton" title="从库中删除选定Prompt"><i class="fas fa-trash-alt"></i></button>
</div>
</div>
</div>
<div class="form-group">
<label>自动化选项</label>
<div class="d-flex">
<div class="custom-control custom-switch mr-3">
<input type="checkbox" class="custom-control-input" id="autoTranslateToggle">
<label class="custom-control-label" for="autoTranslateToggle">自动翻译</label>
</div>
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="autoPasteToggle">
<label class="custom-control-label" for="autoPasteToggle">自动粘贴</label>
</div>
</div>
<small class="form-text text-muted">自动翻译:进入新条目时自动翻译 / 自动粘贴:翻译完成后自动填充到翻译框</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="closeTranslationConfigModalButton">关闭</button>
</div>
</div>
</div>
</div>
`;
super('#collapseTwo', parentSelector, headingId, '机器翻译', contentHTML);
}
insert() {
super.insert();
if (!document.querySelector('#collapseTwo')) {
return;
}
const translationConfigModal = document.getElementById('translationConfigModal');
document.getElementById('openTranslationConfigButton').addEventListener('click', function() {
translationConfigModal.style.display = 'block';
});
function closeModal() {
translationConfigModal.style.display = 'none';
}
document.getElementById('closeTranslationConfigModal').addEventListener('click', closeModal);
document.getElementById('closeTranslationConfigModalButton').addEventListener('click', closeModal);
const apiConfigSelect = document.getElementById('apiConfigSelect');
const saveApiConfigButton = document.getElementById('saveApiConfigButton');
const newApiConfigButton = document.getElementById('newApiConfigButton');
const deleteApiConfigButton = document.getElementById('deleteApiConfigButton');
const apiConfigNameInput = document.getElementById('apiConfigName');
const baseUrlInput = document.getElementById('baseUrl');
const apiKeyInput = document.getElementById('apiKey');
const modelSelect = document.getElementById('model'); // This is now an input text field
const fetchModelsButton = document.getElementById('fetchModelsButton');
const promptInput = document.getElementById('prompt');
const temperatureInput = document.getElementById('temperature');
const autoTranslateToggle = document.getElementById('autoTranslateToggle');
const autoPasteToggle = document.getElementById('autoPasteToggle');
const promptLibrarySelect = document.getElementById('promptLibrarySelect');
const saveToPromptLibraryButton = document.getElementById('saveToPromptLibraryButton');
const deleteFromPromptLibraryButton = document.getElementById('deleteFromPromptLibraryButton');
// API Config related functions are now defined in IIFE scope
function updateActiveConfigField(fieldName, value) {
const activeConfigName = getCurrentApiConfigName();
if (activeConfigName) {
let configs = getApiConfigurations();
const activeConfigIndex = configs.findIndex(c => c.name === activeConfigName);
if (activeConfigIndex > -1) {
configs[activeConfigIndex][fieldName] = value;
saveApiConfigurations(configs);
// console.log(`Field '${fieldName}' for active config '${activeConfigName}' updated to '${value}' and saved.`);
}
}
}
function updateFullUrlPreview(baseUrl) {
const fullUrlPreview = document.getElementById('fullUrlPreview');
if (baseUrl) {
const fullUrl = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}chat/completions`;
fullUrlPreview.textContent = `完整URL: ${fullUrl}`;
} else {
fullUrlPreview.textContent = '';
}
}
function populateApiConfigSelect() {
const configs = getApiConfigurations();
const currentConfigName = getCurrentApiConfigName();
apiConfigSelect.innerHTML = '<option value="">选择或新建配置...</option>'; // Changed placeholder
configs.forEach(config => {
const option = document.createElement('option');
option.value = config.name;
option.textContent = config.name;
if (config.name === currentConfigName) {
option.selected = true;
}
apiConfigSelect.appendChild(option);
});
}
function clearConfigForm() {
apiConfigNameInput.value = '';
baseUrlInput.value = '';
apiKeyInput.value = '';
// Optionally reset model, prompt, temp, toggles to defaults or leave them
// modelSelect.value = 'gpt-4o-mini';
// promptInput.value = '';
// temperatureInput.value = '';
// autoTranslateToggle.checked = false;
// autoPasteToggle.checked = false;
updateFullUrlPreview('');
apiConfigSelect.value = ""; // Reset dropdown to placeholder
}
function loadConfigToUI(configName) {
const configs = getApiConfigurations();
const config = configs.find(c => c.name === configName);
if (config) {
apiConfigNameInput.value = config.name;
baseUrlInput.value = config.baseUrl;
apiKeyInput.value = config.apiKey;
modelSelect.value = config.model || localStorage.getItem('model') || 'gpt-4o-mini';
promptInput.value = config.prompt || localStorage.getItem('prompt') || '';
temperatureInput.value = config.temperature || localStorage.getItem('temperature') || '';
autoTranslateToggle.checked = config.autoTranslateEnabled !== undefined ? config.autoTranslateEnabled : (localStorage.getItem('autoTranslateEnabled') === 'true');
autoPasteToggle.checked = config.autoPasteEnabled !== undefined ? config.autoPasteEnabled : (localStorage.getItem('autoPasteEnabled') === 'true');
setCurrentApiConfigName(config.name);
apiConfigSelect.value = config.name; // Ensure dropdown reflects loaded config
} else {
clearConfigForm(); // Clear form if no specific config is loaded (e.g., "Select or create new")
}
updateFullUrlPreview(baseUrlInput.value);
}
// Initial load
populateApiConfigSelect();
const activeConfigName = getCurrentApiConfigName();
if (activeConfigName) {
loadConfigToUI(activeConfigName);
} else {
// Try to migrate old settings if no new config is active
const oldBaseUrl = localStorage.getItem('baseUrl'); // Check for old individual settings
const oldApiKey = localStorage.getItem('apiKey');
if (oldBaseUrl && oldApiKey && !getApiConfigurations().length) { // Migrate only if no new configs exist
const defaultConfigName = "默认迁移配置";
const newConfig = {
name: defaultConfigName,
baseUrl: oldBaseUrl,
apiKey: oldApiKey,
model: localStorage.getItem('model') || 'gpt-4o-mini',
prompt: localStorage.getItem('prompt') || '',
temperature: localStorage.getItem('temperature') || '',
autoTranslateEnabled: localStorage.getItem('autoTranslateEnabled') === 'true',
autoPasteEnabled: localStorage.getItem('autoPasteEnabled') === 'true'
};
let configs = getApiConfigurations();
configs.push(newConfig);
saveApiConfigurations(configs);
setCurrentApiConfigName(defaultConfigName);
populateApiConfigSelect();
loadConfigToUI(defaultConfigName);
// Optionally remove old keys after successful migration
// localStorage.removeItem('baseUrl'); localStorage.removeItem('apiKey');
} else {
// If no active config and no old settings to migrate, or if configs already exist, load general settings.
modelSelect.value = localStorage.getItem('model') || 'gpt-4o-mini';
promptInput.value = localStorage.getItem('prompt') || '';
temperatureInput.value = localStorage.getItem('temperature') || '';
autoTranslateToggle.checked = localStorage.getItem('autoTranslateEnabled') === 'true';
autoPasteToggle.checked = localStorage.getItem('autoPasteEnabled') === 'true';
clearConfigForm(); // Start with a clean slate for API specific parts if no config selected
}
}
apiConfigSelect.addEventListener('change', function() {
if (this.value) {
loadConfigToUI(this.value);
} else {
clearConfigForm();
// User selected "Select or create new...", so we clear the form for a new entry.
// Do not clear currentApiConfigName here, as they might just be viewing.
}
});
newApiConfigButton.addEventListener('click', function() {
clearConfigForm();
apiConfigNameInput.focus();
});
saveApiConfigButton.addEventListener('click', function() {
const name = apiConfigNameInput.value.trim();
const baseUrl = baseUrlInput.value.trim();
const apiKey = apiKeyInput.value.trim();
if (!name || !baseUrl || !apiKey) {
showToast('配置名称、Base URL 和 API Key 不能为空。', 'error');
return;
}
let configs = getApiConfigurations();
const existingConfigIndex = configs.findIndex(c => c.name === name);
const currentConfigData = {
name,
baseUrl,
apiKey,
model: modelSelect.value,
prompt: promptInput.value,
temperature: temperatureInput.value,
autoTranslateEnabled: autoTranslateToggle.checked,
autoPasteEnabled: autoPasteToggle.checked
};
if (existingConfigIndex > -1) {
configs[existingConfigIndex] = currentConfigData; // Update existing
} else {
configs.push(currentConfigData); // Add new
}
saveApiConfigurations(configs);
setCurrentApiConfigName(name); // Set this as the active config
populateApiConfigSelect(); // Refresh dropdown
apiConfigSelect.value = name; // Ensure the saved/updated config is selected
showToast(`API 配置 "${name}" 已保存!`, 'success');
});
deleteApiConfigButton.addEventListener('click', function() {
const selectedNameToDelete = apiConfigSelect.value; // The config selected in dropdown
if (!selectedNameToDelete) {
showToast('请先从下拉列表中选择一个要删除的配置。', 'error');
return;
}
if (!confirm(`确定要删除配置 "${selectedNameToDelete}" 吗?`)) {
return;
}
let configs = getApiConfigurations();
configs = configs.filter(c => c.name !== selectedNameToDelete);
saveApiConfigurations(configs);
// If the deleted config was the currently active one, clear the form and active status
if (getCurrentApiConfigName() === selectedNameToDelete) {
setCurrentApiConfigName('');
clearConfigForm();
}
populateApiConfigSelect(); // Refresh dropdown
showToast(`API 配置 "${selectedNameToDelete}" 已删除!`, 'success');
// If there are other configs, load the first one or leave blank
if (getApiConfigurations().length > 0) {
const firstConfigName = getApiConfigurations()[0].name;
loadConfigToUI(firstConfigName);
apiConfigSelect.value = firstConfigName;
} else {
clearConfigForm(); // No configs left, clear form
}
});
// Event listeners for general (non-API-config specific) fields
// Event listeners for general (non-API-config specific) fields
// These save to general localStorage and also update the active API config if one is selected.
baseUrlInput.addEventListener('input', () => {
updateFullUrlPreview(baseUrlInput.value);
// Base URL and API Key are core to a config, usually not changed outside explicit save.
});
// apiKeyInput does not have a live update to avoid frequent writes of sensitive data.
document.getElementById('openaiButton').addEventListener('click', () => {
baseUrlInput.value = 'https://api.openai.com/v1';
updateFullUrlPreview(baseUrlInput.value);
});
document.getElementById('deepseekButton').addEventListener('click', () => {
baseUrlInput.value = 'https://api.deepseek.com';
updateFullUrlPreview(baseUrlInput.value);
});
fetchModelsButton.addEventListener('click', async () => {
await this.fetchModelsAndUpdateDatalist();
});
modelSelect.addEventListener('input', () => { // modelSelect is the input field
localStorage.setItem('model', modelSelect.value);
updateActiveConfigField('model', modelSelect.value);
});
promptInput.addEventListener('input', () => {
localStorage.setItem('prompt', promptInput.value);
updateActiveConfigField('prompt', promptInput.value);
});
temperatureInput.addEventListener('input', () => {
const tempValue = temperatureInput.value;
localStorage.setItem('temperature', tempValue);
updateActiveConfigField('temperature', tempValue);
});
autoTranslateToggle.addEventListener('change', () => {
localStorage.setItem('autoTranslateEnabled', autoTranslateToggle.checked);
updateActiveConfigField('autoTranslateEnabled', autoTranslateToggle.checked);
});
autoPasteToggle.addEventListener('change', () => {
localStorage.setItem('autoPasteEnabled', autoPasteToggle.checked);
updateActiveConfigField('autoPasteEnabled', autoPasteToggle.checked);
});
const PROMPT_LIBRARY_KEY = 'promptLibrary';
function getPromptLibrary() {
return JSON.parse(localStorage.getItem(PROMPT_LIBRARY_KEY)) || [];
}
function savePromptLibrary(library) {
localStorage.setItem(PROMPT_LIBRARY_KEY, JSON.stringify(library));
}
function populatePromptLibrarySelect() {
const library = getPromptLibrary();
promptLibrarySelect.innerHTML = '<option value="" selected>从库中选择或管理...</option>';
library.forEach((promptText) => {
const option = document.createElement('option');
option.value = promptText;
option.textContent = promptText.substring(0, 50) + (promptText.length > 50 ? '...' : '');
option.dataset.fulltext = promptText;
promptLibrarySelect.appendChild(option);
});
}
promptLibrarySelect.addEventListener('change', function() {
if (this.value) {
promptInput.value = this.value;
localStorage.setItem('prompt', this.value); // Keep for fallback if no config selected
updateActiveConfigField('prompt', this.value);
}
});
saveToPromptLibraryButton.addEventListener('click', function() {
const currentPrompt = promptInput.value.trim();
if (currentPrompt) {
let library = getPromptLibrary();
if (!library.includes(currentPrompt)) {
library.push(currentPrompt);
savePromptLibrary(library);
populatePromptLibrarySelect();
promptLibrarySelect.value = currentPrompt;
showToast('Prompt 已保存到库中。', 'success');
} else {
showToast('此 Prompt 已存在于库中。', 'warning');
}
} else {
showToast('Prompt 内容不能为空。', 'error');
}
});
deleteFromPromptLibraryButton.addEventListener('click', function() {
const selectedPromptValue = promptLibrarySelect.value;
if (selectedPromptValue) {
let library = getPromptLibrary();
const indexToRemove = library.indexOf(selectedPromptValue);
if (indexToRemove > -1) {
library.splice(indexToRemove, 1);
savePromptLibrary(library);
populatePromptLibrarySelect();
if (promptInput.value === selectedPromptValue) {
promptInput.value = '';
localStorage.setItem('prompt', '');
}
showToast('选定的 Prompt 已从库中删除。', 'success');
}
} else {
showToast('请先从库中选择一个 Prompt 进行删除。', 'error');
}
});
populatePromptLibrarySelect();
// Sync promptLibrarySelect with the initial promptInput value
const initialPromptValue = promptInput.value;
if (initialPromptValue) {
const library = getPromptLibrary();
if (library.includes(initialPromptValue)) {
promptLibrarySelect.value = initialPromptValue;
} else {
promptLibrarySelect.value = ""; // If not in library, keep placeholder
}
} else {
promptLibrarySelect.value = ""; // Default to placeholder if no initial prompt
}
// Removed duplicated listeners for temperature and autoTranslateToggle here,
// as they are already defined above with updateActiveConfigField logic.
this.setupTranslation();
}
async fetchModelsAndUpdateDatalist() {
const modelDatalist = document.getElementById('modelDatalist');
const fetchModelsButton = document.getElementById('fetchModelsButton');
const originalButtonHtml = fetchModelsButton.innerHTML;
fetchModelsButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
fetchModelsButton.disabled = true;
let API_SECRET_KEY = '';
let BASE_URL = '';
const currentConfigName = getCurrentApiConfigName();
let activeConfig = null;
if (currentConfigName) {
const configs = getApiConfigurations();
activeConfig = configs.find(c => c.name === currentConfigName);
}
if (activeConfig) {
BASE_URL = activeConfig.baseUrl;
API_SECRET_KEY = activeConfig.apiKey;
} else {
// Fallback to general localStorage if no active config (less ideal)
BASE_URL = localStorage.getItem('baseUrl');
API_SECRET_KEY = localStorage.getItem('apiKey');
}
if (!BASE_URL || !API_SECRET_KEY) {
showToast('请先配置并选择一个有效的 API 配置 (包含 Base URL 和 API Key)。', 'error', 5000);
fetchModelsButton.innerHTML = originalButtonHtml;
fetchModelsButton.disabled = false;
return;
}
// Construct the models API URL (OpenAI standard is /models)
const modelsUrl = `${BASE_URL}${BASE_URL.endsWith('/') ? '' : '/'}models`;
try {
const response = await fetch(modelsUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${API_SECRET_KEY}`
}
});
if (!response.ok) {
const errorData = await response.text();
console.error('Error fetching models:', response.status, errorData);
showToast(`获取模型列表失败: ${response.status} - ${errorData.substring(0,100)}`, 'error', 5000);
return;
}
const data = await response.json();
if (data && data.data && Array.isArray(data.data)) {
modelDatalist.innerHTML = ''; // Clear existing options
data.data.forEach(model => {
if (model.id) {
const option = document.createElement('option');
option.value = model.id;
modelDatalist.appendChild(option);
}
});
showToast('模型列表已更新。', 'success');
} else {
console.warn('Unexpected models API response structure:', data);
showToast('获取模型列表成功,但响应数据格式不符合预期。', 'warning', 4000);
}
} catch (error) {
console.error('Failed to fetch models:', error);
showToast(`获取模型列表时发生网络错误: ${error.message}`, 'error', 5000);
} finally {
fetchModelsButton.innerHTML = originalButtonHtml;
fetchModelsButton.disabled = false;
}
}
setupTranslation() {
function removeThoughtProcessContent(text) {
if (typeof text !== 'string') return text;
// 移除XML风格的思考标签
let cleanedText = text.replace(/<thought>[\s\S]*?<\/thought>/gi, '');
cleanedText = cleanedText.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');cleanedText = cleanedText.replace(/<think>[\s\S]*?<\/think>/gi, '');
cleanedText = cleanedText.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '');
// 移除Markdown风格的思考标签
cleanedText = cleanedText.replace(/\[THOUGHT\][\s\S]*?\[\/THOUGHT\]/gi, '');
cleanedText = cleanedText.replace(/\[REASONING\][\s\S]*?\[\/REASONING\]/gi, '');
// 移除以特定关键词开头的思考过程
cleanedText = cleanedText.replace(/^(思考过程:|思考:|Thought process:|Thought:|Thinking:|Reasoning:)[\s\S]*?(\n|$)/gim, '');
// 移除常见的工具交互XML标签
cleanedText = cleanedText.replace(/<tool_code>[\s\S]*?<\/tool_code>/gi, '');
cleanedText = cleanedText.replace(/<tool_code_executing>[\s\S]*?<\/tool_code_executing>/gi, '');
cleanedText = cleanedText.replace(/<tool_code_completed>[\s\S]*?<\/tool_code_completed>/gi, '');
cleanedText = cleanedText.replace(/<tool_code_error>[\s\S]*?<\/tool_code_error>/gi, '');
cleanedText = cleanedText.replace(/<tool_code_output>[\s\S]*?<\/tool_code_output>/gi, '');
cleanedText = cleanedText.replace(/<tool_code_execution_succeeded>[\s\S]*?<\/tool_code_execution_succeeded>/gi, '');
cleanedText = cleanedText.replace(/<tool_code_execution_failed>[\s\S]*?<\/tool_code_execution_failed>/gi, '');
// 移除 SEARCH/REPLACE 块标记
cleanedText = cleanedText.replace(/<<<<<<< SEARCH[\s\S]*?>>>>>>> REPLACE/gi, '');
// 清理多余的空行,并将多个连续空行合并为一个
cleanedText = cleanedText.replace(/\n\s*\n/g, '\n');
// 移除首尾空白字符 (包括换行符)
cleanedText = cleanedText.trim();
return cleanedText;
}
const translationCache = {};
const translationsInProgress = {};
async function getCurrentStringId() {
const pathParts = window.location.pathname.split('/');
let stringId = null;
const stringsIndex = pathParts.indexOf('strings');
if (stringsIndex !== -1 && pathParts.length > stringsIndex + 1) {
const idFromPath = pathParts[stringsIndex + 1];
if (!isNaN(parseInt(idFromPath, 10))) {
stringId = idFromPath;
}
}
if (!stringId) {
const copyLinkButton = document.querySelector('.string-editor a.float-right.no-select[href*="/strings?id="]');
if (copyLinkButton) {
const href = copyLinkButton.getAttribute('href');
const urlParams = new URLSearchParams(href.split('?')[1]);
stringId = urlParams.get('id');
} else {
const settingsLink = document.querySelector('.string-editor .tab.context-tab a[href*="/settings/strings?id="]');
if (settingsLink) {
const href = settingsLink.getAttribute('href');
const urlParams = new URLSearchParams(href.split('?')[1]);
stringId = urlParams.get('id');
}
}
}
return stringId && !isNaN(parseInt(stringId, 10)) ? stringId : null;
}
function updateTranslationUI(text, modelName, stringIdForUI) {
document.getElementById('translatedText').value = text;
if (localStorage.getItem('autoPasteEnabled') === 'true') {
const targetTextarea = document.querySelector('textarea.translation.form-control');
// 修复:仅当翻译框为空时才自动粘贴
if (targetTextarea && targetTextarea.value.trim() === '') {
simulateInputChange(targetTextarea, text);
}
}
let translationMemoryDiv = document.querySelector('.translation-memory');
let mtListContainer;
if (!translationMemoryDiv) {
const tabs = document.querySelector('.sidebar-right .tabs');
if (!tabs) {
console.error('找不到.sidebar-right .tabs元素');
return;
}
translationMemoryDiv = document.createElement('div');
translationMemoryDiv.className = 'translation-memory';
translationMemoryDiv.style.display = 'block';
const header = document.createElement('header');
header.className = 'mb-3';
const headerContent = document.createElement('div');
headerContent.className = 'row medium align-items-center';
headerContent.innerHTML = `
<div class="col-auto">
<button title="Ctrl + Shift + F" type="button" class="btn btn-secondary btn-sm">
<i class="far fa-search"></i> 搜索历史翻译
</button>
</div>
<div class="col text-right">
<span class="text-muted">共 0 条建议</span>
<button type="button" class="btn btn-secondary btn-sm"><i class="far fa-cog fa-fw"></i></button>
</div>`;
header.appendChild(headerContent);
translationMemoryDiv.appendChild(header);
mtListContainer = document.createElement('div');
mtListContainer.className = 'list mt-list';
translationMemoryDiv.appendChild(mtListContainer);
tabs.insertBefore(translationMemoryDiv, tabs.firstChild);
} else {
mtListContainer = translationMemoryDiv.querySelector('.list.mt-list');
if (!mtListContainer) {
mtListContainer = document.createElement('div');
mtListContainer.className = 'list mt-list';
const header = translationMemoryDiv.querySelector('header');
if (header) header.insertAdjacentElement('afterend', mtListContainer);
else translationMemoryDiv.appendChild(mtListContainer);
}
}
const existingAiReferences = mtListContainer.querySelectorAll('.mt-reference.paratranz-ai-reference');
existingAiReferences.forEach(ref => ref.remove());
if (mtListContainer) {
const newReferenceDiv = document.createElement('div');
newReferenceDiv.className = 'mt-reference paratranz-ai-reference';
newReferenceDiv.dataset.stringId = stringIdForUI;
const header = document.createElement('header');
header.className = 'medium mb-2 text-muted';
const icon = document.createElement('i');
icon.className = 'far fa-language';
header.appendChild(icon);
header.appendChild(document.createTextNode(' 机器翻译参考'));
newReferenceDiv.appendChild(header);
const bodyRow = document.createElement('div');
bodyRow.className = 'row align-items-center';
const colAuto = document.createElement('div');
colAuto.className = 'col-auto pr-0';
const copyButton = document.createElement('button');
copyButton.title = '复制当前文本至翻译框';
copyButton.type = 'button';
copyButton.className = 'btn btn-link';
const copyIcon = document.createElement('i');
copyIcon.className = 'far fa-clone';
copyButton.appendChild(copyIcon);
copyButton.addEventListener('click', function() {
simulateInputChange(document.querySelector('textarea.translation.form-control'), text);
});
colAuto.appendChild(copyButton);
bodyRow.appendChild(colAuto);
const colText = document.createElement('div');
colText.className = 'col';
const translationSpan = document.createElement('span');
translationSpan.className = 'translation notranslate';
translationSpan.textContent = text;
colText.appendChild(translationSpan);
bodyRow.appendChild(colText);
newReferenceDiv.appendChild(bodyRow);
const footer = document.createElement('footer');
footer.className = 'medium mt-2 text-muted';
const leftText = document.createElement('span');
leftText.textContent = 'Paratranz-AI';
const rightText = document.createElement('div');
rightText.className = 'float-right';
rightText.textContent = modelName || 'N/A';
footer.appendChild(leftText);
footer.appendChild(rightText);
newReferenceDiv.appendChild(footer);
mtListContainer.prepend(newReferenceDiv);
}
}
async function processTranslationRequest(stringIdToProcess, textToTranslate) {
const translateButtonElement = document.getElementById('translateButton');
if (!stringIdToProcess) {
console.warn('processTranslationRequest called with no stringId.');
return;
}
if (translationsInProgress[stringIdToProcess]) {
console.log(`Translation for ${stringIdToProcess} is already in progress. Ignoring new request.`);
return;
}
translationsInProgress[stringIdToProcess] = true;
if (translateButtonElement) translateButtonElement.disabled = true;
document.getElementById('translatedText').value = '翻译中...';
let translatedTextOutput = '';
try {
console.log(`Processing translation for stringId ${stringIdToProcess}:`, textToTranslate);
const model = localStorage.getItem('model') || 'gpt-4o-mini';
const promptStr = localStorage.getItem('prompt') || `You are a translator, you will translate all the message I send to you.\n\nSource Language: en\nTarget Language: zh-cn\n\nOutput result and thought with zh-cn, and keep the result pure text\nwithout any markdown syntax and any thought or references.\n\nInstructions:\n - Accuracy: Ensure the translation accurately conveys the original meaning.\n - Context: Adapt to cultural nuances and specific context to avoid misinterpretation.\n - Tone: Match the tone (formal, informal, technical) of the source text.\n - Grammar: Use correct grammar and sentence structure in the target language.\n - Readability: Ensure the translation is clear and easy to understand.\n - Keep Tags: Maintain the original tags intact, do not translate tags themselves!\n - Keep or remove the spaces around the tags based on the language manners (in CJK, usually the spaces will be removed).\n\nTags are matching the following regular expressions (one per line):\n/{\w+}/\n/%[ds]?\d/\n/\\s#\d{1,2}/\n/<[^>]+?>/\n/%{\d}[a-z]/\n/@[a-zA-Z.]+?@/`;
const temperature = parseFloat(localStorage.getItem('temperature')) || 0;
translatedTextOutput = await translateText(textToTranslate, model, promptStr, temperature);
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
replaceList.forEach(rule => {
if (!rule.disabled && rule.findText) {
translatedTextOutput = translatedTextOutput.replaceAll(rule.findText, rule.replacementText);
}
});
// 新增:去除思维链内容
translatedTextOutput = removeThoughtProcessContent(translatedTextOutput);
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);
});
}
}
// 获取术语表数据 (异步)
async function getTermsData() {
const terms = [];
const pathParts = window.location.pathname.split('/');
let projectId = null;
let stringId = null;
const projectIndex = pathParts.indexOf('projects');
if (projectIndex !== -1 && pathParts.length > projectIndex + 1) {
projectId = pathParts[projectIndex + 1];
}
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]);
const idFromHref = urlParams.get('id');
if (idFromHref && !isNaN(parseInt(idFromHref, 10))) {
stringId = idFromHref;
// console.log(`从页面 context-tab 的“复制链接”按钮获取到 stringId: ${stringId}`);
const hrefPathParts = new URL(href, window.location.origin).pathname.split('/');
const projectIdx = hrefPathParts.indexOf('projects');
if (projectIdx !== -1 && hrefPathParts.length > projectIdx + 1) {
const pidFromHref = hrefPathParts[projectIdx + 1];
if (pidFromHref && projectId !== pidFromHref) {
// console.log(`从“复制链接”的 href 中更新 projectId 从 ${projectId} 到 ${pidFromHref}`);
projectId = pidFromHref;
}
}
}
} 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]);
const idFromHref = urlParams.get('id');
if (idFromHref && !isNaN(parseInt(idFromHref, 10))) {
stringId = idFromHref;
// console.log(`从页面 context-tab 的“设置”链接获取到 stringId: ${stringId}`);
}
}
}
}
if (!projectId) {
console.warn('无法从 URL 中解析项目 ID。URL:', window.location.pathname);
return terms;
}
if (!stringId || isNaN(parseInt(stringId, 10))) {
// console.warn(`无法从 URL 或页面元素中解析有效的字符串 ID "${stringId}",跳过术语获取。`);
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}`
};
}
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();
if (termsMessage && termsMessage.content) {
// console.log('成功获取术语表信息,添加到请求中。');
messages.push(termsMessage);
} else {
// console.log('未获取到术语表信息或术语表为空,翻译请求将不包含术语表。');
}
messages.push({ role: "user", content: query });
const requestBody = { model, temperature, messages };
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 25000); // 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
})();