ParaTranz-AI

ParaTranz文本替换和AI翻译功能拓展。

  1. // ==UserScript==
  2. // @name ParaTranz-AI
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.4.4
  5. // @description ParaTranz文本替换和AI翻译功能拓展。
  6. // @author HCPTangHY
  7. // @license WTFPL
  8. // @match https://paratranz.cn/*
  9. // @icon https://paratranz.cn/favicon.png
  10. // @require https://cdn.jsdelivr.net/npm/diff@5.1.0/dist/diff.min.js
  11. // @require https://cdn.jsdelivr.net/npm/diff2html@3.4.51/bundles/js/diff2html-ui.min.js
  12. // @resource css https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css
  13. // @grant GM_getResourceURL
  14. // @grant GM_getResourceText
  15. // @grant GM_addStyle
  16. // ==/UserScript==
  17.  
  18. const PARATRANZ_AI_TOAST_STYLES = `
  19. /* Toast Notifications */
  20. #toast-container-paratranz-ai {
  21. position: fixed;
  22. bottom: 20px;
  23. left: 50%;
  24. transform: translateX(-50%);
  25. z-index: 10000;
  26. display: flex;
  27. flex-direction: column-reverse;
  28. align-items: center;
  29. pointer-events: none; /* Allow clicks to pass through the container */
  30. }
  31.  
  32. .toast-message {
  33. padding: 10px 20px;
  34. margin-top: 10px;
  35. border-radius: 5px;
  36. color: white;
  37. box-shadow: 0 2px 10px rgba(0,0,0,0.2);
  38. opacity: 0;
  39. transform: translateY(20px);
  40. transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
  41. min-width: 250px;
  42. max-width: 80vw;
  43. text-align: center;
  44. pointer-events: all; /* Individual toasts should be interactive if needed */
  45. }
  46.  
  47. .toast-message.show {
  48. opacity: 1;
  49. transform: translateY(0);
  50. }
  51.  
  52. .toast-message.toast-success { background-color: #28a745; }
  53. .toast-message.toast-error { background-color: #dc3545; }
  54. .toast-message.toast-warning { background-color: #ffc107; color: black; }
  55. .toast-message.toast-info { background-color: #17a2b8; }
  56. `;
  57. GM_addStyle(GM_getResourceText("css") + PARATRANZ_AI_TOAST_STYLES);
  58.  
  59. // fork from HeliumOctahelide https://greasyfork.org/zh-CN/scripts/503063-paratranz-tools
  60. (function() {
  61. 'use strict';
  62.  
  63. // Helper function for Toast Notifications
  64. function showToast(message, type = 'info', duration = 3000) {
  65. let toastContainer = document.getElementById('toast-container-paratranz-ai');
  66. if (!toastContainer) {
  67. toastContainer = document.createElement('div');
  68. toastContainer.id = 'toast-container-paratranz-ai';
  69. document.body.appendChild(toastContainer);
  70. }
  71.  
  72. const toast = document.createElement('div');
  73. toast.className = `toast-message toast-${type}`;
  74. toast.textContent = message;
  75.  
  76. toastContainer.appendChild(toast);
  77.  
  78. // Animate in
  79. requestAnimationFrame(() => {
  80. toast.classList.add('show');
  81. });
  82.  
  83. // Auto-dismiss
  84. setTimeout(() => {
  85. toast.classList.remove('show');
  86. toast.addEventListener('transitionend', () => {
  87. if (toast.parentElement) { // Check if still attached
  88. toast.remove();
  89. }
  90. if (toastContainer && !toastContainer.hasChildNodes()) {
  91. // Check if toastContainer is still in the DOM before removing
  92. if (toastContainer.parentElement) {
  93. toastContainer.remove();
  94. }
  95. }
  96. }, { once: true });
  97. }, duration);
  98. }
  99.  
  100. // 基类定义
  101. class BaseComponent {
  102. constructor(selector) {
  103. this.selector = selector;
  104. this.init();
  105. }
  106.  
  107. init() {
  108. this.checkExistence();
  109. }
  110.  
  111. checkExistence() {
  112. const element = document.querySelector(this.selector);
  113. if (!element) {
  114. this.insert();
  115. }
  116. setTimeout(() => this.checkExistence(), 1000);
  117. }
  118.  
  119. insert() {
  120. // 留空,子类实现具体插入逻辑
  121. }
  122. }
  123.  
  124. // 按钮类定义,继承自BaseComponent
  125. class Button extends BaseComponent {
  126. constructor(selector, toolbarSelector, htmlContent, callback) {
  127. super(selector);
  128. this.toolbarSelector = toolbarSelector;
  129. this.htmlContent = htmlContent;
  130. this.callback = callback;
  131. }
  132.  
  133. insert() {
  134. const toolbar = document.querySelector(this.toolbarSelector);
  135. if (!toolbar) {
  136. console.log(`Toolbar not found: ${this.toolbarSelector}`);
  137. return;
  138. }
  139. if (toolbar && !document.querySelector(this.selector)) {
  140. const button = document.createElement('button');
  141. button.className = this.selector.split('.').join(' ');
  142. button.innerHTML = this.htmlContent;
  143. button.type = 'button';
  144. button.addEventListener('click', this.callback);
  145. toolbar.insertAdjacentElement('afterbegin', button);
  146. console.log(`Button inserted: ${this.selector}`);
  147. }
  148. }
  149. }
  150.  
  151. // 手风琴类定义,继承自BaseComponent
  152. class Accordion extends BaseComponent {
  153. constructor(selector, parentSelector) {
  154. super(selector);
  155. this.parentSelector = parentSelector;
  156. }
  157.  
  158. insert() {
  159. const parentElement = document.querySelector(this.parentSelector);
  160. if (!parentElement) {
  161. console.log(`Parent element not found: ${this.parentSelector}`);
  162. return;
  163. }
  164. if (parentElement && !document.querySelector(this.selector)) {
  165. const accordionHTML = `
  166. <div class="accordion" id="accordionExample"></div>
  167. <hr>
  168. `;
  169. parentElement.insertAdjacentHTML('afterbegin', accordionHTML);
  170. }
  171. }
  172.  
  173. addCard(card) {
  174. card.insert();
  175. }
  176. }
  177.  
  178. // 卡片类定义,继承自BaseComponent
  179. class Card extends BaseComponent {
  180. constructor(selector, parentSelector, headingId, title, contentHTML) {
  181. super(selector);
  182. this.parentSelector = parentSelector;
  183. this.headingId = headingId;
  184. this.title = title;
  185. this.contentHTML = contentHTML;
  186. }
  187.  
  188. insert() {
  189. const parentElement = document.querySelector(this.parentSelector);
  190. if (!parentElement) {
  191. console.log(`Parent element not found: ${this.parentSelector}`);
  192. return;
  193. }
  194. if (parentElement && !document.querySelector(this.selector)) {
  195. const cardHTML = `
  196. <div class="card m-0">
  197. <div class="card-header p-0" id="${this.headingId}">
  198. <h2 class="mb-0">
  199. <button class="btn btn-link" type="button" aria-expanded="false" aria-controls="${this.selector.substring(1)}">
  200. ${this.title}
  201. </button>
  202. </h2>
  203. </div>
  204. <div id="${this.selector.substring(1)}" class="collapse" aria-labelledby="${this.headingId}" data-parent="#accordionExample" style="max-height: 70vh; overflow-y: auto;">
  205. <div class="card-body">
  206. ${this.contentHTML}
  207. </div>
  208. </div>
  209. </div>
  210. `;
  211. parentElement.insertAdjacentHTML('beforeend', cardHTML);
  212.  
  213. const toggleButton = document.querySelector(`#${this.headingId} button`);
  214. const collapseDiv = document.querySelector(this.selector);
  215. toggleButton.addEventListener('click', function() {
  216. if (collapseDiv.style.maxHeight === '0px' || !collapseDiv.style.maxHeight) {
  217. collapseDiv.style.display = 'block';
  218. requestAnimationFrame(() => {
  219. collapseDiv.style.maxHeight = collapseDiv.scrollHeight + 'px';
  220. });
  221. toggleButton.setAttribute('aria-expanded', 'true');
  222. } else {
  223. collapseDiv.style.maxHeight = '0px';
  224. toggleButton.setAttribute('aria-expanded', 'false');
  225. collapseDiv.addEventListener('transitionend', () => {
  226. if (collapseDiv.style.maxHeight === '0px') {
  227. collapseDiv.style.display = 'none';
  228. }
  229. }, { once: true });
  230. }
  231. });
  232.  
  233. collapseDiv.style.maxHeight = '0px';
  234. collapseDiv.style.overflow = 'hidden';
  235. collapseDiv.style.transition = 'max-height 0.3s ease';
  236. }
  237. }
  238. }
  239.  
  240. // 定义具体的文本替换管理卡片
  241. class StringReplaceCard extends Card {
  242. constructor(parentSelector) {
  243. const headingId = 'headingOne';
  244. const contentHTML = `
  245. <div id="manageReplacePage">
  246. <div id="replaceListContainer"></div>
  247. <div class="replace-item mb-3 p-2" style="border: 1px solid #ccc; border-radius: 8px;">
  248. <input type="text" placeholder="查找文本" id="newFindText" class="form-control mb-2"/>
  249. <input type="text" placeholder="替换为" id="newReplacementText" class="form-control mb-2"/>
  250. <button class="btn btn-secondary" id="addReplaceRuleButton">
  251. <i class="far fa-plus-circle"></i> 添加替换规则
  252. </button>
  253. </div>
  254. <div class="mt-3">
  255. <button class="btn btn-primary" id="exportReplaceRulesButton">导出替换规则</button>
  256. <input type="file" id="importReplaceRuleInput" class="d-none"/>
  257. <button class="btn btn-primary" id="importReplaceRuleButton">导入替换规则</button>
  258. </div>
  259. </div>
  260. `;
  261. super('#collapseOne', parentSelector, headingId, '文本替换', contentHTML);
  262. }
  263.  
  264. insert() {
  265. super.insert();
  266. if (!document.querySelector('#collapseOne')) {
  267. return;
  268. }
  269. document.getElementById('addReplaceRuleButton').addEventListener('click', this.addReplaceRule);
  270. document.getElementById('exportReplaceRulesButton').addEventListener('click', this.exportReplaceRules);
  271. document.getElementById('importReplaceRuleButton').addEventListener('click', () => {
  272. document.getElementById('importReplaceRuleInput').click();
  273. });
  274. document.getElementById('importReplaceRuleInput').addEventListener('change', this.importReplaceRules);
  275. this.loadReplaceList();
  276. }
  277.  
  278. addReplaceRule = () => {
  279. const findText = document.getElementById('newFindText').value;
  280. const replacementText = document.getElementById('newReplacementText').value;
  281.  
  282. if (findText) {
  283. const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
  284. replaceList.push({ findText, replacementText, disabled: false });
  285. localStorage.setItem('replaceList', JSON.stringify(replaceList));
  286. this.loadReplaceList();
  287. document.getElementById('newFindText').value = '';
  288. document.getElementById('newReplacementText').value = '';
  289. }
  290. };
  291.  
  292. updateRuleText(index, type, value) {
  293. const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
  294. if (replaceList[index]) {
  295. if (type === 'findText') {
  296. replaceList[index].findText = value;
  297. } else if (type === 'replacementText') {
  298. replaceList[index].replacementText = value;
  299. }
  300. localStorage.setItem('replaceList', JSON.stringify(replaceList));
  301. }
  302. }
  303.  
  304. loadReplaceList() {
  305. const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
  306. const replaceListDiv = document.getElementById('replaceListContainer');
  307. replaceListDiv.innerHTML = '';
  308. // Add scrollbar when rules are too many
  309. replaceListDiv.style.maxHeight = '40vh'; // Adjust as needed
  310. replaceListDiv.style.overflowY = 'auto';
  311. replaceList.forEach((rule, index) => {
  312. const ruleDiv = document.createElement('div');
  313. ruleDiv.className = 'replace-item mb-3 p-2';
  314. ruleDiv.style.border = '1px solid #ccc';
  315. ruleDiv.style.borderRadius = '8px';
  316. ruleDiv.style.transition = 'transform 0.3s';
  317. ruleDiv.style.backgroundColor = rule.disabled ? '#f2dede' : '#fff';
  318.  
  319. const inputsDiv = document.createElement('div');
  320. inputsDiv.className = 'mb-2';
  321.  
  322. const findInput = document.createElement('input');
  323. findInput.type = 'text';
  324. findInput.className = 'form-control mb-1';
  325. findInput.value = rule.findText;
  326. findInput.placeholder = '查找文本';
  327. findInput.dataset.index = index;
  328. findInput.addEventListener('change', (event) => this.updateRuleText(index, 'findText', event.target.value));
  329. inputsDiv.appendChild(findInput);
  330.  
  331. const replInput = document.createElement('input');
  332. replInput.type = 'text';
  333. replInput.className = 'form-control';
  334. replInput.value = rule.replacementText;
  335. replInput.placeholder = '替换为';
  336. replInput.dataset.index = index;
  337. replInput.addEventListener('change', (event) => this.updateRuleText(index, 'replacementText', event.target.value));
  338. inputsDiv.appendChild(replInput);
  339. ruleDiv.appendChild(inputsDiv);
  340.  
  341. const buttonsDiv = document.createElement('div');
  342. buttonsDiv.className = 'd-flex justify-content-between';
  343.  
  344. const leftButtonGroup = document.createElement('div');
  345. leftButtonGroup.className = 'btn-group';
  346. leftButtonGroup.setAttribute('role', 'group');
  347.  
  348. const moveUpButton = this.createButton('上移', 'fas fa-arrow-up', () => this.moveReplaceRule(index, -1));
  349. const moveDownButton = this.createButton('下移', 'fas fa-arrow-down', () => this.moveReplaceRule(index, 1));
  350. const toggleButton = this.createButton('禁用/启用', rule.disabled ? 'fas fa-toggle-off' : 'fas fa-toggle-on', () => this.toggleReplaceRule(index));
  351. const applyButton = this.createButton('应用此规则', 'fas fa-play', () => this.applySingleReplaceRule(index));
  352.  
  353. leftButtonGroup.append(moveUpButton, moveDownButton, toggleButton, applyButton);
  354.  
  355. const rightButtonGroup = document.createElement('div');
  356. rightButtonGroup.className = 'btn-group';
  357. rightButtonGroup.setAttribute('role', 'group');
  358.  
  359. const deleteButton = this.createButton('删除', 'far fa-trash-alt', () => this.deleteReplaceRule(index), 'btn-danger');
  360. rightButtonGroup.appendChild(deleteButton);
  361.  
  362. buttonsDiv.append(leftButtonGroup, rightButtonGroup);
  363. ruleDiv.appendChild(buttonsDiv);
  364. replaceListDiv.appendChild(ruleDiv);
  365. });
  366.  
  367. replaceListDiv.style.display = 'none';
  368. replaceListDiv.offsetHeight;
  369. replaceListDiv.style.display = '';
  370. }
  371.  
  372. createButton(title, iconClass, onClick, btnClass = 'btn-secondary') {
  373. const button = document.createElement('button');
  374. button.className = `btn ${btnClass}`;
  375. button.title = title;
  376. button.innerHTML = `<i class="${iconClass}"></i>`;
  377. button.addEventListener('click', onClick);
  378. return button;
  379. }
  380.  
  381. deleteReplaceRule(index) {
  382. const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
  383. replaceList.splice(index, 1);
  384. localStorage.setItem('replaceList', JSON.stringify(replaceList));
  385. this.loadReplaceList();
  386. }
  387.  
  388. toggleReplaceRule(index) {
  389. const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
  390. replaceList[index].disabled = !replaceList[index].disabled;
  391. localStorage.setItem('replaceList', JSON.stringify(replaceList));
  392. this.loadReplaceList();
  393. }
  394.  
  395. applySingleReplaceRule(index) {
  396. const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
  397. const rule = replaceList[index];
  398. if (rule.disabled || !rule.findText) return;
  399.  
  400. const textareas = document.querySelectorAll('textarea.translation.form-control');
  401. textareas.forEach(textarea => {
  402. let text = textarea.value;
  403. text = text.replaceAll(rule.findText, rule.replacementText);
  404. this.simulateInputChange(textarea, text);
  405. });
  406. }
  407.  
  408. moveReplaceRule(index, direction) {
  409. const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
  410. const newIndex = index + direction;
  411. if (newIndex >= 0 && newIndex < replaceList.length) {
  412. const [movedItem] = replaceList.splice(index, 1);
  413. replaceList.splice(newIndex, 0, movedItem);
  414. localStorage.setItem('replaceList', JSON.stringify(replaceList));
  415. this.loadReplaceListWithAnimation(index, newIndex);
  416. }
  417. }
  418.  
  419. loadReplaceListWithAnimation(oldIndex, newIndex) {
  420. const replaceListDiv = document.getElementById('replaceListContainer');
  421. const items = replaceListDiv.querySelectorAll('.replace-item');
  422. if (items[oldIndex] && items[newIndex]) {
  423. items[oldIndex].style.transform = `translateY(${(newIndex - oldIndex) * 100}%)`;
  424. items[newIndex].style.transform = `translateY(${(oldIndex - newIndex) * 100}%)`;
  425. }
  426.  
  427. setTimeout(() => {
  428. this.loadReplaceList();
  429. }, 300);
  430. }
  431.  
  432. simulateInputChange(element, newValue) {
  433. const inputEvent = new Event('input', { bubbles: true });
  434. const originalValue = element.value;
  435. element.value = newValue;
  436.  
  437. const tracker = element._valueTracker;
  438. if (tracker) {
  439. tracker.setValue(originalValue);
  440. }
  441. element.dispatchEvent(inputEvent);
  442. }
  443.  
  444. exportReplaceRules() {
  445. const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
  446. const json = JSON.stringify(replaceList, null, 2);
  447. const blob = new Blob([json], { type: 'application/json' });
  448. const url = URL.createObjectURL(blob);
  449. const a = document.createElement('a');
  450. a.href = url;
  451. a.download = 'replaceList.json';
  452. a.click();
  453. URL.revokeObjectURL(url);
  454. }
  455.  
  456. importReplaceRules(event) {
  457. const file = event.target.files[0];
  458. if (!file) return;
  459. const reader = new FileReader();
  460. reader.onload = e => {
  461. try {
  462. const content = e.target.result;
  463. const importedList = JSON.parse(content);
  464. if (Array.isArray(importedList) && importedList.every(item => typeof item.findText === 'string' && typeof item.replacementText === 'string')) {
  465. localStorage.setItem('replaceList', JSON.stringify(importedList));
  466. this.loadReplaceList();
  467. showToast('替换规则导入成功!', 'success');
  468. } else {
  469. showToast('导入的文件格式不正确。', 'error');
  470. }
  471. } catch (error) {
  472. console.error('Error importing rules:', error);
  473. showToast('导入失败,文件可能已损坏或格式不正确。', 'error');
  474. }
  475. };
  476. reader.readAsText(file);
  477. event.target.value = null;
  478. }
  479. }
  480.  
  481. // 定义具体的机器翻译卡片
  482. class MachineTranslationCard extends Card {
  483. constructor(parentSelector) {
  484. const headingId = 'headingTwo';
  485. const contentHTML = `
  486. <button class="btn btn-primary" id="openTranslationConfigButton">配置翻译</button>
  487. <div class="mt-3">
  488. <div class="d-flex">
  489. <textarea id="originalText" class="form-control" style="width: 100%; height: 25vh;"></textarea>
  490. <div class="d-flex flex-column ml-2">
  491. <button class="btn btn-secondary mb-2" id="copyOriginalButton">
  492. <i class="fas fa-copy"></i>
  493. </button>
  494. <button class="btn btn-secondary" id="translateButton">
  495. <i class="fas fa-globe"></i>
  496. </button>
  497. </div>
  498. </div>
  499. <div class="d-flex mt-2">
  500. <textarea id="translatedText" class="form-control" style="width: 100%; height: 25vh;"></textarea>
  501. <div class="d-flex flex-column ml-2">
  502. <button class="btn btn-secondary mb-2" id="pasteTranslationButton">
  503. <i class="fas fa-arrow-alt-left"></i>
  504. </button>
  505. <button class="btn btn-secondary" id="copyTranslationButton">
  506. <i class="fas fa-copy"></i>
  507. </button>
  508. </div>
  509. </div>
  510. </div>
  511.  
  512. <!-- Translation Configuration Modal -->
  513. <div class="modal" id="translationConfigModal" tabindex="-1" role="dialog" style="display: none;">
  514. <div class="modal-dialog modal-lg" role="document"> <!-- Added modal-lg -->
  515. <div class="modal-content">
  516. <div class="modal-header py-2">
  517. <h5 class="modal-title">翻译配置</h5>
  518. <button type="button" class="close" id="closeTranslationConfigModal" aria-label="Close">
  519. <span aria-hidden="true">&times;</span>
  520. </button>
  521. </div>
  522. <div class="modal-body p-3" style="max-height: 80vh; overflow-y: auto;"> <!-- Increased max-height, added p-3 -->
  523. <form id="translationConfigForm">
  524. <div class="form-row">
  525. <div class="form-group col-md-7">
  526. <label for="apiConfigSelect">API 配置</label>
  527. <select class="custom-select" id="apiConfigSelect">
  528. <option value="" selected>选择或新建配置...</option>
  529. </select>
  530. </div>
  531. <div class="form-group col-md-5 d-flex align-items-end">
  532. <button type="button" class="btn btn-success mr-2 w-100" id="saveApiConfigButton" title="保存或更新当前填写的配置"><i class="fas fa-save"></i> 保存</button>
  533. <button type="button" class="btn btn-info mr-2 w-100" id="newApiConfigButton" title="清空表单以新建配置"><i class="fas fa-plus-circle"></i> 新建</button>
  534. <button type="button" class="btn btn-danger w-100" id="deleteApiConfigButton" title="删除下拉框中选中的配置"><i class="fas fa-trash-alt"></i> 删除</button>
  535. </div>
  536. </div>
  537. <hr>
  538. <p><strong>当前配置详情:</strong></p>
  539. <div class="form-row">
  540. <div class="form-group col-md-6">
  541. <label for="apiConfigName">配置名称</label>
  542. <input type="text" class="form-control" id="apiConfigName" placeholder="为此配置命名 (例如 My OpenAI)">
  543. </div>
  544. <div class="form-group col-md-6">
  545. <label for="apiKey">API Key</label>
  546. <input type="text" class="form-control" id="apiKey" placeholder="Enter API key">
  547. </div>
  548. </div>
  549. <div class="form-group">
  550. <label for="baseUrl">Base URL</label>
  551. <div class="input-group">
  552. <input type="text" class="form-control" id="baseUrl" placeholder="Enter base URL">
  553. <div class="input-group-append">
  554. <button class="btn btn-outline-secondary" type="button" title="OpenAI API" id="openaiButton">
  555. <img src="https://paratranz.cn/media/f2014e0647283fcff54e3a8f4edaa488.png!webp160" style="width: 16px; height: 16px;">
  556. </button>
  557. <button class="btn btn-outline-secondary" type="button" title="DeepSeek API" id="deepseekButton">
  558. <img src="https://paratranz.cn/media/0bfd294f99b9141e3432c0ffbf3d8e78.png!webp160" style="width: 16px; height: 16px;">
  559. </button>
  560. </div>
  561. </div>
  562. <small id="fullUrlPreview" class="form-text text-muted mt-1" style="word-break: break-all;"></small>
  563. </div>
  564. <div class="form-row">
  565. <div class="form-group col-md-8">
  566. <label for="model">Model</label>
  567. <div class="input-group">
  568. <input type="text" class="form-control" id="model" placeholder="Enter model (e.g., gpt-4o-mini)" list="modelDatalist">
  569. <datalist id="modelDatalist"></datalist>
  570. <div class="input-group-append">
  571. <button class="btn btn-outline-secondary" type="button" id="fetchModelsButton" title="Fetch Models from API">
  572. <i class="fas fa-sync-alt"></i>
  573. </button>
  574. </div>
  575. </div>
  576. </div>
  577. <div class="form-group col-md-4">
  578. <label for="temperature">Temperature</label>
  579. <input type="number" step="0.1" class="form-control" id="temperature" placeholder="e.g., 0.7">
  580. </div>
  581. </div>
  582. <div class="form-group">
  583. <label for="prompt">Prompt</label>
  584. <textarea class="form-control" id="prompt" rows="3" placeholder="Enter prompt or use default prompt. 可用变量: {{original}}, {{context}}, {{terms}}"></textarea>
  585. </div>
  586. <div class="form-group">
  587. <label for="promptLibrarySelect">Prompt 库</label>
  588. <div class="input-group">
  589. <select class="custom-select" id="promptLibrarySelect">
  590. <option value="" selected>从库中选择或管理...</option>
  591. </select>
  592. <div class="input-group-append">
  593. <button class="btn btn-outline-secondary" type="button" id="saveToPromptLibraryButton" title="保存当前Prompt到库"><i class="fas fa-save"></i></button>
  594. <button class="btn btn-outline-danger" type="button" id="deleteFromPromptLibraryButton" title="从库中删除选定Prompt"><i class="fas fa-trash-alt"></i></button>
  595. </div>
  596. </div>
  597. </div>
  598. <div class="form-group">
  599. <label>自动化选项</label>
  600. <div class="d-flex">
  601. <div class="custom-control custom-switch mr-3">
  602. <input type="checkbox" class="custom-control-input" id="autoTranslateToggle">
  603. <label class="custom-control-label" for="autoTranslateToggle">自动翻译</label>
  604. </div>
  605. <div class="custom-control custom-switch">
  606. <input type="checkbox" class="custom-control-input" id="autoPasteToggle">
  607. <label class="custom-control-label" for="autoPasteToggle">自动粘贴</label>
  608. </div>
  609. </div>
  610. <small class="form-text text-muted">自动翻译:进入新条目时自动翻译 / 自动粘贴:翻译完成后自动填充到翻译框</small>
  611. </div>
  612. </form>
  613. </div>
  614. <div class="modal-footer">
  615. <button type="button" class="btn btn-secondary" id="closeTranslationConfigModalButton">关闭</button>
  616. </div>
  617. </div>
  618. </div>
  619. </div>
  620. `;
  621. super('#collapseTwo', parentSelector, headingId, '机器翻译', contentHTML);
  622. }
  623.  
  624. insert() {
  625. super.insert();
  626. if (!document.querySelector('#collapseTwo')) {
  627. return;
  628. }
  629. const translationConfigModal = document.getElementById('translationConfigModal');
  630. document.getElementById('openTranslationConfigButton').addEventListener('click', function() {
  631. translationConfigModal.style.display = 'block';
  632. });
  633.  
  634. function closeModal() {
  635. translationConfigModal.style.display = 'none';
  636. }
  637.  
  638. document.getElementById('closeTranslationConfigModal').addEventListener('click', closeModal);
  639. document.getElementById('closeTranslationConfigModalButton').addEventListener('click', closeModal);
  640.  
  641. const apiConfigSelect = document.getElementById('apiConfigSelect');
  642. const saveApiConfigButton = document.getElementById('saveApiConfigButton');
  643. const newApiConfigButton = document.getElementById('newApiConfigButton');
  644. const deleteApiConfigButton = document.getElementById('deleteApiConfigButton');
  645. const apiConfigNameInput = document.getElementById('apiConfigName');
  646. const baseUrlInput = document.getElementById('baseUrl');
  647. const apiKeyInput = document.getElementById('apiKey');
  648. const modelSelect = document.getElementById('model'); // This is now an input text field
  649. const fetchModelsButton = document.getElementById('fetchModelsButton');
  650. const promptInput = document.getElementById('prompt');
  651. const temperatureInput = document.getElementById('temperature');
  652. const autoTranslateToggle = document.getElementById('autoTranslateToggle');
  653. const autoPasteToggle = document.getElementById('autoPasteToggle');
  654. const promptLibrarySelect = document.getElementById('promptLibrarySelect');
  655. const saveToPromptLibraryButton = document.getElementById('saveToPromptLibraryButton');
  656. const deleteFromPromptLibraryButton = document.getElementById('deleteFromPromptLibraryButton');
  657.  
  658. // API Config related functions are now defined in IIFE scope
  659.  
  660. function updateActiveConfigField(fieldName, value) {
  661. const activeConfigName = getCurrentApiConfigName();
  662. if (activeConfigName) {
  663. let configs = getApiConfigurations();
  664. const activeConfigIndex = configs.findIndex(c => c.name === activeConfigName);
  665. if (activeConfigIndex > -1) {
  666. configs[activeConfigIndex][fieldName] = value;
  667. saveApiConfigurations(configs);
  668. // console.log(`Field '${fieldName}' for active config '${activeConfigName}' updated to '${value}' and saved.`);
  669. }
  670. }
  671. }
  672. function updateFullUrlPreview(baseUrl) {
  673. const fullUrlPreview = document.getElementById('fullUrlPreview');
  674. if (baseUrl) {
  675. const fullUrl = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}chat/completions`;
  676. fullUrlPreview.textContent = `完整URL: ${fullUrl}`;
  677. } else {
  678. fullUrlPreview.textContent = '';
  679. }
  680. }
  681.  
  682. function populateApiConfigSelect() {
  683. const configs = getApiConfigurations();
  684. const currentConfigName = getCurrentApiConfigName();
  685. apiConfigSelect.innerHTML = '<option value="">选择或新建配置...</option>'; // Changed placeholder
  686. configs.forEach(config => {
  687. const option = document.createElement('option');
  688. option.value = config.name;
  689. option.textContent = config.name;
  690. if (config.name === currentConfigName) {
  691. option.selected = true;
  692. }
  693. apiConfigSelect.appendChild(option);
  694. });
  695. }
  696.  
  697. function clearConfigForm() {
  698. apiConfigNameInput.value = '';
  699. baseUrlInput.value = '';
  700. apiKeyInput.value = '';
  701. // Optionally reset model, prompt, temp, toggles to defaults or leave them
  702. // modelSelect.value = 'gpt-4o-mini';
  703. // promptInput.value = '';
  704. // temperatureInput.value = '';
  705. // autoTranslateToggle.checked = false;
  706. // autoPasteToggle.checked = false;
  707. updateFullUrlPreview('');
  708. apiConfigSelect.value = ""; // Reset dropdown to placeholder
  709. }
  710. function loadConfigToUI(configName) {
  711. const configs = getApiConfigurations();
  712. const config = configs.find(c => c.name === configName);
  713. if (config) {
  714. apiConfigNameInput.value = config.name;
  715. baseUrlInput.value = config.baseUrl;
  716. apiKeyInput.value = config.apiKey;
  717. modelSelect.value = config.model || localStorage.getItem('model') || 'gpt-4o-mini';
  718. promptInput.value = config.prompt || localStorage.getItem('prompt') || '';
  719. temperatureInput.value = config.temperature || localStorage.getItem('temperature') || '';
  720. autoTranslateToggle.checked = config.autoTranslateEnabled !== undefined ? config.autoTranslateEnabled : (localStorage.getItem('autoTranslateEnabled') === 'true');
  721. autoPasteToggle.checked = config.autoPasteEnabled !== undefined ? config.autoPasteEnabled : (localStorage.getItem('autoPasteEnabled') === 'true');
  722. setCurrentApiConfigName(config.name);
  723. apiConfigSelect.value = config.name; // Ensure dropdown reflects loaded config
  724. } else {
  725. clearConfigForm(); // Clear form if no specific config is loaded (e.g., "Select or create new")
  726. }
  727. updateFullUrlPreview(baseUrlInput.value);
  728. }
  729. // Initial load
  730. populateApiConfigSelect();
  731. const activeConfigName = getCurrentApiConfigName();
  732. if (activeConfigName) {
  733. loadConfigToUI(activeConfigName);
  734. } else {
  735. // Try to migrate old settings if no new config is active
  736. const oldBaseUrl = localStorage.getItem('baseUrl'); // Check for old individual settings
  737. const oldApiKey = localStorage.getItem('apiKey');
  738. if (oldBaseUrl && oldApiKey && !getApiConfigurations().length) { // Migrate only if no new configs exist
  739. const defaultConfigName = "默认迁移配置";
  740. const newConfig = {
  741. name: defaultConfigName,
  742. baseUrl: oldBaseUrl,
  743. apiKey: oldApiKey,
  744. model: localStorage.getItem('model') || 'gpt-4o-mini',
  745. prompt: localStorage.getItem('prompt') || '',
  746. temperature: localStorage.getItem('temperature') || '',
  747. autoTranslateEnabled: localStorage.getItem('autoTranslateEnabled') === 'true',
  748. autoPasteEnabled: localStorage.getItem('autoPasteEnabled') === 'true'
  749. };
  750. let configs = getApiConfigurations();
  751. configs.push(newConfig);
  752. saveApiConfigurations(configs);
  753. setCurrentApiConfigName(defaultConfigName);
  754. populateApiConfigSelect();
  755. loadConfigToUI(defaultConfigName);
  756. // Optionally remove old keys after successful migration
  757. // localStorage.removeItem('baseUrl'); localStorage.removeItem('apiKey');
  758. } else {
  759. // If no active config and no old settings to migrate, or if configs already exist, load general settings.
  760. modelSelect.value = localStorage.getItem('model') || 'gpt-4o-mini';
  761. promptInput.value = localStorage.getItem('prompt') || '';
  762. temperatureInput.value = localStorage.getItem('temperature') || '';
  763. autoTranslateToggle.checked = localStorage.getItem('autoTranslateEnabled') === 'true';
  764. autoPasteToggle.checked = localStorage.getItem('autoPasteEnabled') === 'true';
  765. clearConfigForm(); // Start with a clean slate for API specific parts if no config selected
  766. }
  767. }
  768.  
  769. apiConfigSelect.addEventListener('change', function() {
  770. if (this.value) {
  771. loadConfigToUI(this.value);
  772. } else {
  773. clearConfigForm();
  774. // User selected "Select or create new...", so we clear the form for a new entry.
  775. // Do not clear currentApiConfigName here, as they might just be viewing.
  776. }
  777. });
  778. newApiConfigButton.addEventListener('click', function() {
  779. clearConfigForm();
  780. apiConfigNameInput.focus();
  781. });
  782.  
  783. saveApiConfigButton.addEventListener('click', function() {
  784. const name = apiConfigNameInput.value.trim();
  785. const baseUrl = baseUrlInput.value.trim();
  786. const apiKey = apiKeyInput.value.trim();
  787.  
  788. if (!name || !baseUrl || !apiKey) {
  789. showToast('配置名称、Base URL 和 API Key 不能为空。', 'error');
  790. return;
  791. }
  792.  
  793. let configs = getApiConfigurations();
  794. const existingConfigIndex = configs.findIndex(c => c.name === name);
  795.  
  796. const currentConfigData = {
  797. name,
  798. baseUrl,
  799. apiKey,
  800. model: modelSelect.value,
  801. prompt: promptInput.value,
  802. temperature: temperatureInput.value,
  803. autoTranslateEnabled: autoTranslateToggle.checked,
  804. autoPasteEnabled: autoPasteToggle.checked
  805. };
  806.  
  807. if (existingConfigIndex > -1) {
  808. configs[existingConfigIndex] = currentConfigData; // Update existing
  809. } else {
  810. configs.push(currentConfigData); // Add new
  811. }
  812. saveApiConfigurations(configs);
  813. setCurrentApiConfigName(name); // Set this as the active config
  814. populateApiConfigSelect(); // Refresh dropdown
  815. apiConfigSelect.value = name; // Ensure the saved/updated config is selected
  816. showToast(`API 配置 "${name}" 已保存!`, 'success');
  817. });
  818.  
  819. deleteApiConfigButton.addEventListener('click', function() {
  820. const selectedNameToDelete = apiConfigSelect.value; // The config selected in dropdown
  821. if (!selectedNameToDelete) {
  822. showToast('请先从下拉列表中选择一个要删除的配置。', 'error');
  823. return;
  824. }
  825. if (!confirm(`确定要删除配置 "${selectedNameToDelete}" 吗?`)) {
  826. return;
  827. }
  828.  
  829. let configs = getApiConfigurations();
  830. configs = configs.filter(c => c.name !== selectedNameToDelete);
  831. saveApiConfigurations(configs);
  832.  
  833. // If the deleted config was the currently active one, clear the form and active status
  834. if (getCurrentApiConfigName() === selectedNameToDelete) {
  835. setCurrentApiConfigName('');
  836. clearConfigForm();
  837. }
  838. populateApiConfigSelect(); // Refresh dropdown
  839. showToast(`API 配置 "${selectedNameToDelete}" 已删除!`, 'success');
  840. // If there are other configs, load the first one or leave blank
  841. if (getApiConfigurations().length > 0) {
  842. const firstConfigName = getApiConfigurations()[0].name;
  843. loadConfigToUI(firstConfigName);
  844. apiConfigSelect.value = firstConfigName;
  845. } else {
  846. clearConfigForm(); // No configs left, clear form
  847. }
  848. });
  849. // Event listeners for general (non-API-config specific) fields
  850. // Event listeners for general (non-API-config specific) fields
  851. // These save to general localStorage and also update the active API config if one is selected.
  852. baseUrlInput.addEventListener('input', () => {
  853. updateFullUrlPreview(baseUrlInput.value);
  854. // Base URL and API Key are core to a config, usually not changed outside explicit save.
  855. });
  856. // apiKeyInput does not have a live update to avoid frequent writes of sensitive data.
  857.  
  858. document.getElementById('openaiButton').addEventListener('click', () => {
  859. baseUrlInput.value = 'https://api.openai.com/v1';
  860. updateFullUrlPreview(baseUrlInput.value);
  861. });
  862. document.getElementById('deepseekButton').addEventListener('click', () => {
  863. baseUrlInput.value = 'https://api.deepseek.com';
  864. updateFullUrlPreview(baseUrlInput.value);
  865. });
  866. fetchModelsButton.addEventListener('click', async () => {
  867. await this.fetchModelsAndUpdateDatalist();
  868. });
  869.  
  870. modelSelect.addEventListener('input', () => { // modelSelect is the input field
  871. localStorage.setItem('model', modelSelect.value);
  872. updateActiveConfigField('model', modelSelect.value);
  873. });
  874. promptInput.addEventListener('input', () => {
  875. localStorage.setItem('prompt', promptInput.value);
  876. updateActiveConfigField('prompt', promptInput.value);
  877. });
  878. temperatureInput.addEventListener('input', () => {
  879. const tempValue = temperatureInput.value;
  880. localStorage.setItem('temperature', tempValue);
  881. updateActiveConfigField('temperature', tempValue);
  882. });
  883. autoTranslateToggle.addEventListener('change', () => {
  884. localStorage.setItem('autoTranslateEnabled', autoTranslateToggle.checked);
  885. updateActiveConfigField('autoTranslateEnabled', autoTranslateToggle.checked);
  886. });
  887. autoPasteToggle.addEventListener('change', () => {
  888. localStorage.setItem('autoPasteEnabled', autoPasteToggle.checked);
  889. updateActiveConfigField('autoPasteEnabled', autoPasteToggle.checked);
  890. });
  891.  
  892. const PROMPT_LIBRARY_KEY = 'promptLibrary';
  893.  
  894. function getPromptLibrary() {
  895. return JSON.parse(localStorage.getItem(PROMPT_LIBRARY_KEY)) || [];
  896. }
  897.  
  898. function savePromptLibrary(library) {
  899. localStorage.setItem(PROMPT_LIBRARY_KEY, JSON.stringify(library));
  900. }
  901.  
  902. function populatePromptLibrarySelect() {
  903. const library = getPromptLibrary();
  904. promptLibrarySelect.innerHTML = '<option value="" selected>从库中选择或管理...</option>';
  905. library.forEach((promptText) => {
  906. const option = document.createElement('option');
  907. option.value = promptText;
  908. option.textContent = promptText.substring(0, 50) + (promptText.length > 50 ? '...' : '');
  909. option.dataset.fulltext = promptText;
  910. promptLibrarySelect.appendChild(option);
  911. });
  912. }
  913.  
  914. promptLibrarySelect.addEventListener('change', function() {
  915. if (this.value) {
  916. promptInput.value = this.value;
  917. localStorage.setItem('prompt', this.value); // Keep for fallback if no config selected
  918. updateActiveConfigField('prompt', this.value);
  919. }
  920. });
  921.  
  922. saveToPromptLibraryButton.addEventListener('click', function() {
  923. const currentPrompt = promptInput.value.trim();
  924. if (currentPrompt) {
  925. let library = getPromptLibrary();
  926. if (!library.includes(currentPrompt)) {
  927. library.push(currentPrompt);
  928. savePromptLibrary(library);
  929. populatePromptLibrarySelect();
  930. promptLibrarySelect.value = currentPrompt;
  931. showToast('Prompt 已保存到库中。', 'success');
  932. } else {
  933. showToast('此 Prompt 已存在于库中。', 'warning');
  934. }
  935. } else {
  936. showToast('Prompt 内容不能为空。', 'error');
  937. }
  938. });
  939.  
  940. deleteFromPromptLibraryButton.addEventListener('click', function() {
  941. const selectedPromptValue = promptLibrarySelect.value;
  942. if (selectedPromptValue) {
  943. let library = getPromptLibrary();
  944. const indexToRemove = library.indexOf(selectedPromptValue);
  945. if (indexToRemove > -1) {
  946. library.splice(indexToRemove, 1);
  947. savePromptLibrary(library);
  948. populatePromptLibrarySelect();
  949. if (promptInput.value === selectedPromptValue) {
  950. promptInput.value = '';
  951. localStorage.setItem('prompt', '');
  952. }
  953. showToast('选定的 Prompt 已从库中删除。', 'success');
  954. }
  955. } else {
  956. showToast('请先从库中选择一个 Prompt 进行删除。', 'error');
  957. }
  958. });
  959.  
  960. populatePromptLibrarySelect();
  961.  
  962. // Sync promptLibrarySelect with the initial promptInput value
  963. const initialPromptValue = promptInput.value;
  964. if (initialPromptValue) {
  965. const library = getPromptLibrary();
  966. if (library.includes(initialPromptValue)) {
  967. promptLibrarySelect.value = initialPromptValue;
  968. } else {
  969. promptLibrarySelect.value = ""; // If not in library, keep placeholder
  970. }
  971. } else {
  972. promptLibrarySelect.value = ""; // Default to placeholder if no initial prompt
  973. }
  974. // Removed duplicated listeners for temperature and autoTranslateToggle here,
  975. // as they are already defined above with updateActiveConfigField logic.
  976.  
  977. this.setupTranslation();
  978. }
  979.  
  980. async fetchModelsAndUpdateDatalist() {
  981. const modelDatalist = document.getElementById('modelDatalist');
  982. const fetchModelsButton = document.getElementById('fetchModelsButton');
  983. const originalButtonHtml = fetchModelsButton.innerHTML;
  984. fetchModelsButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
  985. fetchModelsButton.disabled = true;
  986.  
  987. let API_SECRET_KEY = '';
  988. let BASE_URL = '';
  989.  
  990. const currentConfigName = getCurrentApiConfigName();
  991. let activeConfig = null;
  992.  
  993. if (currentConfigName) {
  994. const configs = getApiConfigurations();
  995. activeConfig = configs.find(c => c.name === currentConfigName);
  996. }
  997.  
  998. if (activeConfig) {
  999. BASE_URL = activeConfig.baseUrl;
  1000. API_SECRET_KEY = activeConfig.apiKey;
  1001. } else {
  1002. // Fallback to general localStorage if no active config (less ideal)
  1003. BASE_URL = localStorage.getItem('baseUrl');
  1004. API_SECRET_KEY = localStorage.getItem('apiKey');
  1005. }
  1006.  
  1007. if (!BASE_URL || !API_SECRET_KEY) {
  1008. showToast('请先配置并选择一个有效的 API 配置 (包含 Base URL 和 API Key)。', 'error', 5000);
  1009. fetchModelsButton.innerHTML = originalButtonHtml;
  1010. fetchModelsButton.disabled = false;
  1011. return;
  1012. }
  1013.  
  1014. // Construct the models API URL (OpenAI standard is /models)
  1015. const modelsUrl = `${BASE_URL}${BASE_URL.endsWith('/') ? '' : '/'}models`;
  1016.  
  1017. try {
  1018. const response = await fetch(modelsUrl, {
  1019. method: 'GET',
  1020. headers: {
  1021. 'Authorization': `Bearer ${API_SECRET_KEY}`
  1022. }
  1023. });
  1024.  
  1025. if (!response.ok) {
  1026. const errorData = await response.text();
  1027. console.error('Error fetching models:', response.status, errorData);
  1028. showToast(`获取模型列表失败: ${response.status} - ${errorData.substring(0,100)}`, 'error', 5000);
  1029. return;
  1030. }
  1031.  
  1032. const data = await response.json();
  1033. if (data && data.data && Array.isArray(data.data)) {
  1034. modelDatalist.innerHTML = ''; // Clear existing options
  1035. data.data.forEach(model => {
  1036. if (model.id) {
  1037. const option = document.createElement('option');
  1038. option.value = model.id;
  1039. modelDatalist.appendChild(option);
  1040. }
  1041. });
  1042. showToast('模型列表已更新。', 'success');
  1043. } else {
  1044. console.warn('Unexpected models API response structure:', data);
  1045. showToast('获取模型列表成功,但响应数据格式不符合预期。', 'warning', 4000);
  1046. }
  1047. } catch (error) {
  1048. console.error('Failed to fetch models:', error);
  1049. showToast(`获取模型列表时发生网络错误: ${error.message}`, 'error', 5000);
  1050. } finally {
  1051. fetchModelsButton.innerHTML = originalButtonHtml;
  1052. fetchModelsButton.disabled = false;
  1053. }
  1054. }
  1055.  
  1056. setupTranslation() {
  1057. function removeThoughtProcessContent(text) {
  1058. if (typeof text !== 'string') return text;
  1059. // 移除XML风格的思考标签
  1060. let cleanedText = text.replace(/<thought>[\s\S]*?<\/thought>/gi, '');
  1061. cleanedText = cleanedText.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');cleanedText = cleanedText.replace(/<think>[\s\S]*?<\/think>/gi, '');
  1062. cleanedText = cleanedText.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '');
  1063. // 移除Markdown风格的思考标签
  1064. cleanedText = cleanedText.replace(/\[THOUGHT\][\s\S]*?\[\/THOUGHT\]/gi, '');
  1065. cleanedText = cleanedText.replace(/\[REASONING\][\s\S]*?\[\/REASONING\]/gi, '');
  1066. // 移除以特定关键词开头的思考过程
  1067. cleanedText = cleanedText.replace(/^(思考过程:|思考:|Thought process:|Thought:|Thinking:|Reasoning:)[\s\S]*?(\n|$)/gim, '');
  1068. // 移除常见的工具交互XML标签
  1069. cleanedText = cleanedText.replace(/<tool_code>[\s\S]*?<\/tool_code>/gi, '');
  1070. cleanedText = cleanedText.replace(/<tool_code_executing>[\s\S]*?<\/tool_code_executing>/gi, '');
  1071. cleanedText = cleanedText.replace(/<tool_code_completed>[\s\S]*?<\/tool_code_completed>/gi, '');
  1072. cleanedText = cleanedText.replace(/<tool_code_error>[\s\S]*?<\/tool_code_error>/gi, '');
  1073. cleanedText = cleanedText.replace(/<tool_code_output>[\s\S]*?<\/tool_code_output>/gi, '');
  1074. cleanedText = cleanedText.replace(/<tool_code_execution_succeeded>[\s\S]*?<\/tool_code_execution_succeeded>/gi, '');
  1075. cleanedText = cleanedText.replace(/<tool_code_execution_failed>[\s\S]*?<\/tool_code_execution_failed>/gi, '');
  1076. // 移除 SEARCH/REPLACE 块标记
  1077. cleanedText = cleanedText.replace(/<<<<<<< SEARCH[\s\S]*?>>>>>>> REPLACE/gi, '');
  1078.  
  1079. // 清理多余的空行,并将多个连续空行合并为一个
  1080. cleanedText = cleanedText.replace(/\n\s*\n/g, '\n');
  1081. // 移除首尾空白字符 (包括换行符)
  1082. cleanedText = cleanedText.trim();
  1083.  
  1084. return cleanedText;
  1085. }
  1086.  
  1087. const translationCache = {};
  1088. const translationsInProgress = {};
  1089.  
  1090. async function getCurrentStringId() {
  1091. const pathParts = window.location.pathname.split('/');
  1092. let stringId = null;
  1093. const stringsIndex = pathParts.indexOf('strings');
  1094. if (stringsIndex !== -1 && pathParts.length > stringsIndex + 1) {
  1095. const idFromPath = pathParts[stringsIndex + 1];
  1096. if (!isNaN(parseInt(idFromPath, 10))) {
  1097. stringId = idFromPath;
  1098. }
  1099. }
  1100. if (!stringId) {
  1101. const copyLinkButton = document.querySelector('.string-editor a.float-right.no-select[href*="/strings?id="]');
  1102. if (copyLinkButton) {
  1103. const href = copyLinkButton.getAttribute('href');
  1104. const urlParams = new URLSearchParams(href.split('?')[1]);
  1105. stringId = urlParams.get('id');
  1106. } else {
  1107. const settingsLink = document.querySelector('.string-editor .tab.context-tab a[href*="/settings/strings?id="]');
  1108. if (settingsLink) {
  1109. const href = settingsLink.getAttribute('href');
  1110. const urlParams = new URLSearchParams(href.split('?')[1]);
  1111. stringId = urlParams.get('id');
  1112. }
  1113. }
  1114. }
  1115. return stringId && !isNaN(parseInt(stringId, 10)) ? stringId : null;
  1116. }
  1117. function updateTranslationUI(text, modelName, stringIdForUI) {
  1118. document.getElementById('translatedText').value = text;
  1119.  
  1120. if (localStorage.getItem('autoPasteEnabled') === 'true') {
  1121. const targetTextarea = document.querySelector('textarea.translation.form-control');
  1122. // 修复:仅当翻译框为空时才自动粘贴
  1123. if (targetTextarea && targetTextarea.value.trim() === '') {
  1124. simulateInputChange(targetTextarea, text);
  1125. }
  1126. }
  1127.  
  1128. let translationMemoryDiv = document.querySelector('.translation-memory');
  1129. let mtListContainer;
  1130.  
  1131. if (!translationMemoryDiv) {
  1132. const tabs = document.querySelector('.sidebar-right .tabs');
  1133. if (!tabs) {
  1134. console.error('找不到.sidebar-right .tabs元素');
  1135. return;
  1136. }
  1137. translationMemoryDiv = document.createElement('div');
  1138. translationMemoryDiv.className = 'translation-memory';
  1139. translationMemoryDiv.style.display = 'block';
  1140. const header = document.createElement('header');
  1141. header.className = 'mb-3';
  1142. const headerContent = document.createElement('div');
  1143. headerContent.className = 'row medium align-items-center';
  1144. headerContent.innerHTML = `
  1145. <div class="col-auto">
  1146. <button title="Ctrl + Shift + F" type="button" class="btn btn-secondary btn-sm">
  1147. <i class="far fa-search"></i> 搜索历史翻译
  1148. </button>
  1149. </div>
  1150. <div class="col text-right">
  1151. <span class="text-muted">共 0 条建议</span>
  1152. <button type="button" class="btn btn-secondary btn-sm"><i class="far fa-cog fa-fw"></i></button>
  1153. </div>`;
  1154. header.appendChild(headerContent);
  1155. translationMemoryDiv.appendChild(header);
  1156. mtListContainer = document.createElement('div');
  1157. mtListContainer.className = 'list mt-list';
  1158. translationMemoryDiv.appendChild(mtListContainer);
  1159. tabs.insertBefore(translationMemoryDiv, tabs.firstChild);
  1160. } else {
  1161. mtListContainer = translationMemoryDiv.querySelector('.list.mt-list');
  1162. if (!mtListContainer) {
  1163. mtListContainer = document.createElement('div');
  1164. mtListContainer.className = 'list mt-list';
  1165. const header = translationMemoryDiv.querySelector('header');
  1166. if (header) header.insertAdjacentElement('afterend', mtListContainer);
  1167. else translationMemoryDiv.appendChild(mtListContainer);
  1168. }
  1169. }
  1170.  
  1171. const existingAiReferences = mtListContainer.querySelectorAll('.mt-reference.paratranz-ai-reference');
  1172. existingAiReferences.forEach(ref => ref.remove());
  1173.  
  1174. if (mtListContainer) {
  1175. const newReferenceDiv = document.createElement('div');
  1176. newReferenceDiv.className = 'mt-reference paratranz-ai-reference';
  1177. newReferenceDiv.dataset.stringId = stringIdForUI;
  1178. const header = document.createElement('header');
  1179. header.className = 'medium mb-2 text-muted';
  1180. const icon = document.createElement('i');
  1181. icon.className = 'far fa-language';
  1182. header.appendChild(icon);
  1183. header.appendChild(document.createTextNode(' 机器翻译参考'));
  1184. newReferenceDiv.appendChild(header);
  1185. const bodyRow = document.createElement('div');
  1186. bodyRow.className = 'row align-items-center';
  1187. const colAuto = document.createElement('div');
  1188. colAuto.className = 'col-auto pr-0';
  1189. const copyButton = document.createElement('button');
  1190. copyButton.title = '复制当前文本至翻译框';
  1191. copyButton.type = 'button';
  1192. copyButton.className = 'btn btn-link';
  1193. const copyIcon = document.createElement('i');
  1194. copyIcon.className = 'far fa-clone';
  1195. copyButton.appendChild(copyIcon);
  1196. copyButton.addEventListener('click', function() {
  1197. simulateInputChange(document.querySelector('textarea.translation.form-control'), text);
  1198. });
  1199. colAuto.appendChild(copyButton);
  1200. bodyRow.appendChild(colAuto);
  1201. const colText = document.createElement('div');
  1202. colText.className = 'col';
  1203. const translationSpan = document.createElement('span');
  1204. translationSpan.className = 'translation notranslate';
  1205. translationSpan.textContent = text;
  1206. colText.appendChild(translationSpan);
  1207. bodyRow.appendChild(colText);
  1208. newReferenceDiv.appendChild(bodyRow);
  1209. const footer = document.createElement('footer');
  1210. footer.className = 'medium mt-2 text-muted';
  1211. const leftText = document.createElement('span');
  1212. leftText.textContent = 'Paratranz-AI';
  1213. const rightText = document.createElement('div');
  1214. rightText.className = 'float-right';
  1215. rightText.textContent = modelName || 'N/A';
  1216. footer.appendChild(leftText);
  1217. footer.appendChild(rightText);
  1218. newReferenceDiv.appendChild(footer);
  1219. mtListContainer.prepend(newReferenceDiv);
  1220. }
  1221. }
  1222.  
  1223. async function processTranslationRequest(stringIdToProcess, textToTranslate) {
  1224. const translateButtonElement = document.getElementById('translateButton');
  1225.  
  1226. if (!stringIdToProcess) {
  1227. console.warn('processTranslationRequest called with no stringId.');
  1228. return;
  1229. }
  1230. if (translationsInProgress[stringIdToProcess]) {
  1231. console.log(`Translation for ${stringIdToProcess} is already in progress. Ignoring new request.`);
  1232. return;
  1233. }
  1234.  
  1235. translationsInProgress[stringIdToProcess] = true;
  1236. if (translateButtonElement) translateButtonElement.disabled = true;
  1237. document.getElementById('translatedText').value = '翻译中...';
  1238. let translatedTextOutput = '';
  1239.  
  1240. try {
  1241. console.log(`Processing translation for stringId ${stringIdToProcess}:`, textToTranslate);
  1242. const model = localStorage.getItem('model') || 'gpt-4o-mini';
  1243. 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.]+?@/`;
  1244. const temperature = parseFloat(localStorage.getItem('temperature')) || 0;
  1245.  
  1246. translatedTextOutput = await translateText(textToTranslate, model, promptStr, temperature);
  1247.  
  1248. const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
  1249. replaceList.forEach(rule => {
  1250. if (!rule.disabled && rule.findText) {
  1251. translatedTextOutput = translatedTextOutput.replaceAll(rule.findText, rule.replacementText);
  1252. }
  1253. });
  1254.  
  1255. // 新增:去除思维链内容
  1256. translatedTextOutput = removeThoughtProcessContent(translatedTextOutput);
  1257. // 检查翻译是否成功,如果失败则不保存到缓存
  1258. const isTranslationError = translatedTextOutput.startsWith("API Base URL 或 Key 未配置。") ||
  1259. translatedTextOutput.startsWith("API 翻译失败:") ||
  1260. translatedTextOutput === "翻译失败: API响应格式无效" ||
  1261. translatedTextOutput === "翻译请求超时。" ||
  1262. translatedTextOutput.startsWith("翻译请求失败:");
  1263.  
  1264. if (!isTranslationError) {
  1265. translationCache[stringIdToProcess] = translatedTextOutput;
  1266. }
  1267.  
  1268. const currentPageId = await getCurrentStringId();
  1269. if (currentPageId === stringIdToProcess) {
  1270. updateTranslationUI(translatedTextOutput, model, stringIdToProcess);
  1271. } else {
  1272. console.log(`Translated stringId ${stringIdToProcess}, but page is now ${currentPageId}. Reference UI not updated for ${stringIdToProcess}.`);
  1273. document.getElementById('translatedText').value = translatedTextOutput;
  1274. }
  1275.  
  1276. } catch (error) {
  1277. console.error(`Error during translation processing for stringId ${stringIdToProcess}:`, error);
  1278. const translatedTextArea = document.getElementById('translatedText');
  1279. if (translatedTextArea) {
  1280. translatedTextArea.value = `翻译出错 (ID: ${stringIdToProcess}): ${error.message}`;
  1281. }
  1282. } finally {
  1283. delete translationsInProgress[stringIdToProcess];
  1284. if (translateButtonElement) translateButtonElement.disabled = false;
  1285. console.log(`Translation processing for stringId ${stringIdToProcess} finished, flags reset.`);
  1286. }
  1287. }
  1288.  
  1289. async function updateOriginalTextAndTranslateIfNeeded() {
  1290. const currentStringId = await getCurrentStringId();
  1291. if (!currentStringId) {
  1292. return;
  1293. }
  1294.  
  1295. const originalDiv = document.querySelector('.original.well');
  1296. if (originalDiv) {
  1297. const originalText = originalDiv.innerText;
  1298. document.getElementById('originalText').value = originalText;
  1299. const existingAiReference = document.querySelector('.mt-reference.paratranz-ai-reference');
  1300.  
  1301. if (translationCache[currentStringId]) {
  1302. console.log(`Using cached translation for stringId: ${currentStringId}`);
  1303. const model = localStorage.getItem('model') || 'gpt-4o-mini';
  1304. if (existingAiReference && existingAiReference.dataset.stringId !== currentStringId) {
  1305. existingAiReference.remove();
  1306. }
  1307. updateTranslationUI(translationCache[currentStringId], model, currentStringId);
  1308. return;
  1309. } else {
  1310. if (existingAiReference) {
  1311. existingAiReference.remove();
  1312. }
  1313. }
  1314.  
  1315. if (localStorage.getItem('autoTranslateEnabled') === 'true' && originalText.trim() !== '' && !translationsInProgress[currentStringId]) {
  1316. console.log(`Auto-translating for stringId: ${currentStringId}`);
  1317. await processTranslationRequest(currentStringId, originalText);
  1318. } else if (translationsInProgress[currentStringId]) {
  1319. console.log(`Translation already in progress for stringId: ${currentStringId} (checked in updateOriginalText)`);
  1320. }
  1321. }
  1322. }
  1323.  
  1324. let debounceTimer = null;
  1325. const observer = new MutationObserver(async () => {
  1326. if (debounceTimer) clearTimeout(debounceTimer);
  1327. debounceTimer = setTimeout(async () => {
  1328. console.log('Observer triggered, updating original text and checking translation.');
  1329. await updateOriginalTextAndTranslateIfNeeded();
  1330. }, 200);
  1331. });
  1332.  
  1333. const config = { childList: true, subtree: true, characterData: true };
  1334. const originalDivTarget = document.querySelector('.original.well');
  1335. if (originalDivTarget) {
  1336. observer.observe(originalDivTarget, config);
  1337. updateOriginalTextAndTranslateIfNeeded();
  1338. } else {
  1339. console.warn("Original text container (.original.well) not found at observer setup.");
  1340. }
  1341. document.getElementById('copyOriginalButton').addEventListener('click', async () => {
  1342. await updateOriginalTextAndTranslateIfNeeded();
  1343. });
  1344.  
  1345. document.getElementById('translateButton').addEventListener('click', async function() {
  1346. const currentStringId = await getCurrentStringId();
  1347. const originalText = document.getElementById('originalText').value;
  1348. if (!currentStringId) {
  1349. console.error('Cannot translate: No valid stringId found for manual trigger.');
  1350. return;
  1351. }
  1352. await processTranslationRequest(currentStringId, originalText);
  1353. });
  1354.  
  1355. document.getElementById('copyTranslationButton').addEventListener('click', function() {
  1356. const translatedText = document.getElementById('translatedText').value;
  1357. navigator.clipboard.writeText(translatedText).then(() => {
  1358. console.log('Translated text copied to clipboard');
  1359. }).catch(err => {
  1360. console.error('Failed to copy text: ', err);
  1361. });
  1362. });
  1363.  
  1364. document.getElementById('pasteTranslationButton').addEventListener('click', function() {
  1365. const translatedText = document.getElementById('translatedText').value;
  1366. simulateInputChange(document.querySelector('textarea.translation.form-control'), translatedText);
  1367. });
  1368. }
  1369. }
  1370.  
  1371. // 辅助函数:获取项目ID和字符串ID
  1372. async function getProjectIdAndStringId() {
  1373. const pathParts = window.location.pathname.split('/');
  1374. let projectId = null;
  1375. let stringId = null;
  1376.  
  1377. // 尝试从当前URL路径获取项目ID
  1378. let projectPathIndex = pathParts.indexOf('projects');
  1379. if (projectPathIndex !== -1 && pathParts.length > projectPathIndex + 1) {
  1380. projectId = pathParts[projectPathIndex + 1];
  1381. }
  1382.  
  1383. // 尝试从当前URL路径获取字符串ID
  1384. const stringsPathIndex = pathParts.indexOf('strings');
  1385. if (stringsPathIndex !== -1 && pathParts.length > stringsPathIndex + 1) {
  1386. const idFromPath = pathParts[stringsPathIndex + 1];
  1387. if (idFromPath && !isNaN(parseInt(idFromPath, 10))) {
  1388. stringId = idFromPath;
  1389. }
  1390. }
  1391.  
  1392. // 如果未在路径中找到,或为了确认/覆盖,则回退到使用页面元素
  1393. const copyLinkButton = document.querySelector('.string-editor a.float-right.no-select[href*="/strings?id="]');
  1394. if (copyLinkButton) {
  1395. const href = copyLinkButton.getAttribute('href');
  1396. const url = new URL(href, window.location.origin); // 确保是完整URL以便解析
  1397. const urlParams = new URLSearchParams(url.search);
  1398. const idFromHref = urlParams.get('id');
  1399. if (idFromHref && !isNaN(parseInt(idFromHref, 10))) {
  1400. if (!stringId) stringId = idFromHref; // 如果路径中没有,则优先使用href中的ID
  1401. const hrefPathParts = url.pathname.split('/');
  1402. const projectIdxHref = hrefPathParts.indexOf('projects');
  1403. if (projectIdxHref !== -1 && hrefPathParts.length > projectIdxHref + 1) {
  1404. const pidFromHref = hrefPathParts[projectIdxHref + 1];
  1405. if (pidFromHref) {
  1406. if (!projectId || projectId !== pidFromHref) projectId = pidFromHref; // 如果项目ID不同或未找到,则更新
  1407. }
  1408. }
  1409. }
  1410. }
  1411. if (!stringId) { // 如果仍然没有字符串ID,尝试设置链接
  1412. const settingsLink = document.querySelector('.string-editor .tab.context-tab a[href*="/settings/strings?id="]');
  1413. if (settingsLink) {
  1414. const href = settingsLink.getAttribute('href');
  1415. const url = new URL(href, window.location.origin);
  1416. const urlParams = new URLSearchParams(url.search);
  1417. const idFromHref = urlParams.get('id');
  1418. if (idFromHref && !isNaN(parseInt(idFromHref, 10))) {
  1419. stringId = idFromHref;
  1420. const hrefPathParts = url.pathname.split('/');
  1421. const projectIdxHref = hrefPathParts.indexOf('projects');
  1422. if (projectIdxHref !== -1 && hrefPathParts.length > projectIdxHref + 1) {
  1423. const pidFromHref = hrefPathParts[projectIdxHref + 1];
  1424. if (pidFromHref && (!projectId || projectId !== pidFromHref)) {
  1425. projectId = pidFromHref;
  1426. }
  1427. }
  1428. }
  1429. }
  1430. }
  1431. // 确保projectId和stringId是字符串类型
  1432. if (projectId && typeof projectId !== 'string') {
  1433. projectId = String(projectId);
  1434. }
  1435. if (stringId && typeof stringId !== 'string') {
  1436. stringId = String(stringId);
  1437. }
  1438.  
  1439. return { projectId, stringId: stringId && !isNaN(parseInt(stringId, 10)) ? stringId : null };
  1440. }
  1441.  
  1442. // 获取术语表数据 (异步)
  1443. async function getTermsData() {
  1444. const terms = [];
  1445. const { projectId, stringId } = await getProjectIdAndStringId();
  1446.  
  1447. if (!projectId) {
  1448. console.warn('无法从 URL 或页面元素中解析项目 ID,跳过术语获取。');
  1449. return terms;
  1450. }
  1451. if (!stringId) {
  1452. console.warn('无法从 URL 或页面元素中解析有效的字符串 ID,跳过术语获取。');
  1453. return terms;
  1454. }
  1455.  
  1456. const apiUrl = `https://paratranz.cn/api/projects/${projectId}/strings/${stringId}/terms`;
  1457. try {
  1458. const controller = new AbortController();
  1459. const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
  1460.  
  1461. const response = await fetch(apiUrl, { signal: controller.signal });
  1462. clearTimeout(timeoutId);
  1463.  
  1464. if (!response.ok) {
  1465. console.error(`获取术语 API 失败: ${response.status} ${response.statusText}`);
  1466. return terms;
  1467. }
  1468. const apiResult = await response.json();
  1469. apiResult.forEach(item => {
  1470. if (item.term && item.translation) {
  1471. terms.push({
  1472. source: item.term,
  1473. target: item.translation,
  1474. note: item.note || ''
  1475. });
  1476. }
  1477. });
  1478. // console.log(`通过 API 获取到 ${terms.length} 条术语。`);
  1479. } catch (error) {
  1480. if (error.name === 'AbortError') {
  1481. console.error('获取术语 API 超时。');
  1482. } else {
  1483. console.error('调用术语 API 时发生错误:', error);
  1484. }
  1485. }
  1486. return terms;
  1487. }
  1488.  
  1489. async function buildTermsSystemMessageWithRetry() {
  1490. let terms = await getTermsData();
  1491. if (!terms.length) {
  1492. // console.log('第一次通过 API 获取术语表失败或为空,等待100ms后重试...');
  1493. await new Promise(resolve => setTimeout(resolve, 100));
  1494. terms = await getTermsData();
  1495. if (!terms.length) {
  1496. // console.log('第二次通过 API 获取术语表仍然失败或为空。');
  1497. return null;
  1498. }
  1499. // console.log(`第二次尝试通过 API 获取到 ${terms.length} 条术语。`);
  1500. } else {
  1501. // console.log(`第一次尝试通过 API 获取到 ${terms.length} 条术语。`);
  1502. }
  1503.  
  1504. const termsContext = terms.map(term => {
  1505. let termString = `${term.source} ${term.target}`;
  1506. if (term.note) {
  1507. termString += ` (备注(辅助思考不要出现在译文中):${term.note})`;
  1508. }
  1509. return termString;
  1510. }).join('\n');
  1511.  
  1512. return {
  1513. role: "user",
  1514. content: `翻译时请参考以下术语表:\n${termsContext}`
  1515. };
  1516. }
  1517.  
  1518. // 新增:获取翻译建议上下文
  1519. async function getTranslationSuggestionsContext() {
  1520. const { projectId, stringId } = await getProjectIdAndStringId();
  1521. const suggestionsContext = [];
  1522.  
  1523. if (!projectId || !stringId) {
  1524. // console.warn('无法获取翻译建议:项目 ID 或字符串 ID 未找到。');
  1525. return suggestionsContext;
  1526. }
  1527.  
  1528. const apiUrl = `https://paratranz.cn/api/projects/${projectId}/strings/${stringId}/suggestions`;
  1529. try {
  1530. const controller = new AbortController();
  1531. const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
  1532.  
  1533. const response = await fetch(apiUrl, { signal: controller.signal });
  1534. clearTimeout(timeoutId);
  1535.  
  1536. if (!response.ok) {
  1537. console.error(`获取翻译建议 API 失败: ${response.status} ${response.statusText}`);
  1538. return suggestionsContext;
  1539. }
  1540. const apiResult = await response.json();
  1541. if (Array.isArray(apiResult)) {
  1542. apiResult.forEach(suggestion => {
  1543. // 确保 original 和 translation 存在且不为空字符串
  1544. if (suggestion.original && suggestion.translation &&
  1545. typeof suggestion.matching === 'number' && suggestion.matching >= 0.7) {
  1546. suggestionsContext.push({ role: "user", content: suggestion.original });
  1547. suggestionsContext.push({ role: "assistant", content: suggestion.translation });
  1548. }
  1549. });
  1550. }
  1551. // console.log(`获取到 ${suggestionsContext.length / 2} 条符合条件的翻译建议。`);
  1552. } catch (error) {
  1553. if (error.name === 'AbortError') {
  1554. console.error('获取翻译建议 API 超时。');
  1555. } else {
  1556. console.error('调用翻译建议 API 时发生错误:', error);
  1557. }
  1558. }
  1559. return suggestionsContext;
  1560. }
  1561.  
  1562. class PromptTagProcessor {
  1563. constructor() {
  1564. this.tagProcessors = new Map();
  1565. this.setupDefaultTags();
  1566. }
  1567.  
  1568. setupDefaultTags() {
  1569. this.registerTag('original', (text) => text);
  1570. this.registerTag('context', async () => {
  1571. const contextDiv = document.querySelector('.context .well');
  1572. if (!contextDiv) return '';
  1573. return contextDiv.innerText.trim();
  1574. });
  1575. this.registerTag('terms', async () => {
  1576. const terms = await getTermsData();
  1577. if (!terms.length) return '';
  1578. return terms.map(term => {
  1579. let termString = `${term.source} ${term.target}`;
  1580. if (term.note) termString += ` (${term.note})`;
  1581. return termString;
  1582. }).join('\n');
  1583. });
  1584. }
  1585.  
  1586. registerTag(tagName, processor) {
  1587. this.tagProcessors.set(tagName, processor);
  1588. }
  1589.  
  1590. async processPrompt(prompt, originalText) {
  1591. let processedPrompt = prompt;
  1592. for (const [tagName, processor] of this.tagProcessors) {
  1593. const tagPattern = new RegExp(`{{${tagName}}}`, 'g');
  1594. if (tagPattern.test(processedPrompt)) {
  1595. let replacement;
  1596. try {
  1597. replacement = (tagName === 'original') ? originalText : await processor();
  1598. processedPrompt = processedPrompt.replace(tagPattern, replacement || '');
  1599. // console.log(`替换标签 {{${tagName}}} 成功`);
  1600. } catch (error) {
  1601. console.error(`处理标签 {{${tagName}}} 时出错:`, error);
  1602. }
  1603. }
  1604. }
  1605. // console.log('处理后的prompt:', processedPrompt);
  1606. return processedPrompt;
  1607. }
  1608. }
  1609. // Define API config utility functions in IIFE scope
  1610. const API_CONFIGURATIONS_KEY = 'apiConfigurations';
  1611. const CURRENT_API_CONFIG_NAME_KEY = 'currentApiConfigName';
  1612.  
  1613. function getApiConfigurations() {
  1614. return JSON.parse(localStorage.getItem(API_CONFIGURATIONS_KEY)) || [];
  1615. }
  1616.  
  1617. function saveApiConfigurations(configs) {
  1618. localStorage.setItem(API_CONFIGURATIONS_KEY, JSON.stringify(configs));
  1619. }
  1620.  
  1621. function getCurrentApiConfigName() {
  1622. return localStorage.getItem(CURRENT_API_CONFIG_NAME_KEY);
  1623. }
  1624.  
  1625. function setCurrentApiConfigName(name) {
  1626. localStorage.setItem(CURRENT_API_CONFIG_NAME_KEY, name);
  1627. }
  1628.  
  1629. async function translateText(query, model, prompt, temperature) {
  1630. let API_SECRET_KEY = '';
  1631. let BASE_URL = '';
  1632. const currentConfigName = getCurrentApiConfigName();
  1633. let activeConfig = null;
  1634.  
  1635. if (currentConfigName) {
  1636. const configs = getApiConfigurations();
  1637. activeConfig = configs.find(c => c.name === currentConfigName);
  1638. }
  1639.  
  1640. if (activeConfig) {
  1641. BASE_URL = activeConfig.baseUrl;
  1642. API_SECRET_KEY = activeConfig.apiKey;
  1643. model = activeConfig.model || localStorage.getItem('model') || 'gpt-4o-mini'; // Fallback to general localStorage then default
  1644. prompt = activeConfig.prompt || localStorage.getItem('prompt') || '';
  1645. temperature = activeConfig.temperature !== undefined && activeConfig.temperature !== '' ? parseFloat(activeConfig.temperature) : (localStorage.getItem('temperature') !== null ? parseFloat(localStorage.getItem('temperature')) : 0);
  1646. } else {
  1647. // If no active config, try to use general localStorage settings as a last resort for key/URL
  1648. // This case should ideally be handled by prompting user to select/create a config
  1649. console.warn("No active API configuration selected. Translation might fail or use stale settings.");
  1650. BASE_URL = localStorage.getItem('baseUrl_fallback_for_translate') || ''; // Example of a dedicated fallback key
  1651. API_SECRET_KEY = localStorage.getItem('apiKey_fallback_for_translate') || '';
  1652. // For other params, use general localStorage or defaults
  1653. model = localStorage.getItem('model') || 'gpt-4o-mini';
  1654. prompt = localStorage.getItem('prompt') || '';
  1655. temperature = localStorage.getItem('temperature') !== null ? parseFloat(localStorage.getItem('temperature')) : 0;
  1656. }
  1657.  
  1658. if (!BASE_URL || !API_SECRET_KEY) {
  1659. console.error("API Base URL or Key is missing. Please configure an API setting.");
  1660. return "API Base URL 或 Key 未配置。请在翻译配置中设置。";
  1661. }
  1662. if (!prompt) { // Default prompt if still empty after all fallbacks
  1663. 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.";
  1664. }
  1665.  
  1666. const tagProcessor = new PromptTagProcessor();
  1667. const processedPrompt = await tagProcessor.processPrompt(prompt, query);
  1668.  
  1669. const messages = [{ role: "system", content: processedPrompt }];
  1670.  
  1671. // console.log('准备获取术语表信息...');
  1672. const termsMessage = await buildTermsSystemMessageWithRetry(); // 间接使用 getProjectIdAndStringId
  1673. if (termsMessage && termsMessage.content) {
  1674. // console.log('成功获取术语表信息,添加到请求中。');
  1675. messages.push(termsMessage);
  1676. } else {
  1677. // console.log('未获取到术语表信息或术语表为空,翻译请求将不包含术语表。');
  1678. }
  1679.  
  1680. // 新增:获取并添加翻译建议上下文
  1681. // console.log('准备获取翻译建议上下文...');
  1682. const suggestionContextMessages = await getTranslationSuggestionsContext(); // 直接使用 getProjectIdAndStringId
  1683. if (suggestionContextMessages && suggestionContextMessages.length > 0) {
  1684. // console.log(`成功获取 ${suggestionContextMessages.length / 2} 条翻译建议,添加到请求中。`);
  1685. messages.push(...suggestionContextMessages);
  1686. } else {
  1687. // console.log('未获取到符合条件的翻译建议,或获取失败。');
  1688. }
  1689.  
  1690. messages.push({ role: "user", content: "text below\n```\n" + query + "\n```\nreturn without `" });
  1691.  
  1692. const requestBody = { model, temperature, messages };
  1693.  
  1694. try {
  1695. const controller = new AbortController();
  1696. const timeoutId = setTimeout(() => controller.abort(), 250000); // 25秒超时
  1697.  
  1698. const response = await fetch(`${BASE_URL}${BASE_URL.endsWith('/') ? '' : '/'}chat/completions`, {
  1699. method: 'POST',
  1700. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_SECRET_KEY}` },
  1701. body: JSON.stringify(requestBody),
  1702. signal: controller.signal
  1703. });
  1704. clearTimeout(timeoutId);
  1705.  
  1706. if (!response.ok) {
  1707. let errorData;
  1708. try { errorData = await response.json(); } catch (e) { /* ignore */ }
  1709. console.error('API Error:', errorData || response.statusText);
  1710. return `API 翻译失败: ${response.status} - ${errorData?.error?.message || errorData?.message || response.statusText}`;
  1711. }
  1712.  
  1713. const data = await response.json();
  1714. if (data.choices && data.choices[0]?.message?.content) {
  1715. return data.choices[0].message.content;
  1716. } else {
  1717. console.error('Invalid API response structure:', data);
  1718. return '翻译失败: API响应格式无效';
  1719. }
  1720. } catch (error) {
  1721. if (error.name === 'AbortError') {
  1722. console.error('API translation request timed out.');
  1723. return '翻译请求超时。';
  1724. }
  1725. console.error('Translation Fetch/Network Error:', error);
  1726. return `翻译请求失败: ${error.message || error.toString()}`;
  1727. }
  1728. }
  1729.  
  1730. function simulateInputChange(element, newValue) {
  1731. if (element.value.trim() !== '') {
  1732. // return; // Allowing overwrite now based on typical user expectation for paste
  1733. }
  1734. const inputEvent = new Event('input', { bubbles: true });
  1735. const originalValue = element.value;
  1736. element.value = newValue;
  1737. const tracker = element._valueTracker;
  1738. if (tracker) tracker.setValue(originalValue);
  1739. element.dispatchEvent(inputEvent);
  1740. }
  1741.  
  1742. const accordion = new Accordion('#accordionExample', '.sidebar-right');
  1743. const stringReplaceCard = new StringReplaceCard('#accordionExample');
  1744. const machineTranslationCard = new MachineTranslationCard('#accordionExample');
  1745.  
  1746. accordion.addCard(stringReplaceCard);
  1747. accordion.addCard(machineTranslationCard);
  1748.  
  1749. // Diff对比模态框类
  1750. class DiffModal {
  1751. constructor() {
  1752. this.modalId = 'diffModal';
  1753. this.diffLib = null;
  1754. this.initModal();
  1755. this.initDiffLibraries();
  1756. }
  1757.  
  1758. initDiffLibraries() {
  1759. if (typeof Diff !== 'undefined') {
  1760. this.diffLib = Diff;
  1761. console.log('jsdiff library initialized successfully');
  1762. } else {
  1763. console.error('jsdiff library is not available');
  1764. }
  1765. }
  1766.  
  1767. initModal() {
  1768. if (document.getElementById(this.modalId)) return;
  1769.  
  1770. const modalHTML = `
  1771. <div class="modal" id="${this.modalId}" tabindex="-1" role="dialog" style="display: none;">
  1772. <div class="modal-dialog modal-xl" role="document">
  1773. <div class="modal-content">
  1774. <div class="modal-header py-2">
  1775. <h5 class="modal-title">文本对比</h5>
  1776. <button type="button" class="close" id="closeDiffModal" aria-label="Close">
  1777. <span aria-hidden="true">&times;</span>
  1778. </button>
  1779. </div>
  1780. <div class="modal-body p-0" style="height: 70vh;">
  1781. <div class="diff-container d-flex h-100">
  1782. <div class="diff-original w-50 border-right" style="overflow-y: auto;">
  1783. <div class="diff-header bg-light p-2">原文</div>
  1784. <div class="diff-content" id="originalDiffContent"></div>
  1785. </div>
  1786. <div class="diff-translation w-50" style="overflow-y: auto;">
  1787. <div class="diff-header bg-light p-2 d-flex justify-content-between align-items-center">
  1788. <span>当前翻译</span>
  1789. <button class="btn btn-sm btn-primary" id="editTranslationButton">编辑</button>
  1790. </div>
  1791. <div class="diff-content" id="translationDiffContent" style="display: block;"></div>
  1792. <textarea class="form-control" id="translationEditor" style="display: none; height: 100%; width: 100%; border: none; resize: none; font-family: monospace;" placeholder="在此编辑翻译内容..."></textarea>
  1793. </div>
  1794. </div>
  1795. </div>
  1796. <div class="modal-footer">
  1797. <button type="button" class="btn btn-secondary" id="closeDiffModalButton">关闭</button>
  1798. <button type="button" class="btn btn-primary" id="saveTranslationButton" style="display: none;">保存</button>
  1799. </div>
  1800. </div>
  1801. </div>
  1802. </div>
  1803. `;
  1804. document.body.insertAdjacentHTML('beforeend', modalHTML);
  1805.  
  1806. const style = document.createElement('style');
  1807. style.textContent = `
  1808. .diff-line {
  1809. display: flex;
  1810. padding: 2px 5px;
  1811. font-family: monospace;
  1812. line-height: 1.4;
  1813. }
  1814. .diff-line-number {
  1815. min-width: 35px;
  1816. color: #999;
  1817. text-align: right;
  1818. padding-right: 10px;
  1819. user-select: none;
  1820. font-size: 0.9em;
  1821. }
  1822. .diff-line-content {
  1823. flex: 1;
  1824. white-space: pre-wrap;
  1825. word-break: break-word;
  1826. padding-left: 5px;
  1827. }
  1828. .diff-line.diff-added {
  1829. background-color: #e6ffed; /* Light green for whole line add */
  1830. }
  1831. .diff-line.diff-removed {
  1832. background-color: #ffeef0; /* Light red for whole line remove */
  1833. }
  1834. .diff-line.diff-common {
  1835. background-color: #ffffff;
  1836. }
  1837. .diff-line.diff-placeholder,
  1838. .diff-line.diff-modified-old, /* Placeholder for original side of a modification */
  1839. .diff-line.diff-added-extra { /* Placeholder for translation side of a modification where original has fewer lines */
  1840. background-color: #f0f0f0; /* Grey for placeholders */
  1841. }
  1842. .copy-action-button { /* Unified class for action buttons */
  1843. cursor: pointer;
  1844. margin-left: 8px;
  1845. padding: 0 4px;
  1846. font-size: 0.9em;
  1847. line-height: 1;
  1848. border: 1px solid #ccc;
  1849. border-radius: 3px;
  1850. background-color: #f0f0f0;
  1851. }
  1852. .copy-action-button:hover {
  1853. background-color: #e0e0e0;
  1854. }
  1855. .diff-header {
  1856. font-weight: bold;
  1857. position: sticky;
  1858. top: 0;
  1859. z-index: 1;
  1860. background-color: #f8f9fa; /* Ensure header bg covers scrolling content */
  1861. }
  1862. /* Intra-line diff styles */
  1863. .diff-intraline-added {
  1864. background-color: #acf2bd; /* More prominent green for intra-line additions */
  1865. /* text-decoration: underline; */
  1866. }
  1867. .diff-intraline-removed {
  1868. background-color: #fdb8c0; /* More prominent red for intra-line deletions */
  1869. text-decoration: line-through;
  1870. }
  1871. `;
  1872. document.head.appendChild(style);
  1873.  
  1874. document.getElementById('closeDiffModal').addEventListener('click', this.closeModal.bind(this));
  1875. document.getElementById('closeDiffModalButton').addEventListener('click', this.closeModal.bind(this));
  1876. document.getElementById('editTranslationButton').addEventListener('click', this.toggleEditMode.bind(this));
  1877. document.getElementById('saveTranslationButton').addEventListener('click', this.saveTranslation.bind(this));
  1878. }
  1879.  
  1880. toggleEditMode() {
  1881. const translationContent = document.getElementById('translationDiffContent');
  1882. const translationEditor = document.getElementById('translationEditor');
  1883. const editButton = document.getElementById('editTranslationButton');
  1884. const saveButton = document.getElementById('saveTranslationButton');
  1885.  
  1886. if (translationContent.style.display === 'block') {
  1887. translationContent.style.display = 'none';
  1888. translationEditor.style.display = 'block';
  1889. editButton.textContent = '取消编辑';
  1890. saveButton.style.display = 'inline-block';
  1891. translationEditor.value = document.querySelector('textarea.translation.form-control')?.value || '';
  1892. translationEditor.focus();
  1893. } else {
  1894. translationContent.style.display = 'block';
  1895. translationEditor.style.display = 'none';
  1896. editButton.textContent = '编辑';
  1897. saveButton.style.display = 'none';
  1898. }
  1899. }
  1900.  
  1901. saveTranslation() {
  1902. const translationEditor = document.getElementById('translationEditor');
  1903. const textarea = document.querySelector('textarea.translation.form-control');
  1904. if (textarea) {
  1905. textarea.value = translationEditor.value;
  1906. simulateInputChange(textarea, textarea.value); // Ensure change is registered by React/Vue if applicable
  1907. this.toggleEditMode(); // Switch back to diff view
  1908. this.generateDiff(); // Regenerate diff with new translation
  1909. }
  1910. }
  1911.  
  1912. show() {
  1913. const modal = document.getElementById(this.modalId);
  1914. modal.style.display = 'block';
  1915. this.generateDiff();
  1916. }
  1917.  
  1918. closeModal() {
  1919. document.getElementById(this.modalId).style.display = 'none';
  1920. }
  1921.  
  1922. // Helper to split lines, handling trailing newline consistently and removing CR
  1923. splitIntoLines(text) {
  1924. if (text === null || text === undefined) return [];
  1925. if (text === '') return ['']; // An empty text is one empty line for diffing purposes
  1926. let lines = text.split('\n');
  1927. // If the text ends with a newline, split will produce an empty string at the end.
  1928. // jsdiff's diffLines handles this by considering the newline as part of the last line's value or as a separate token.
  1929. // For our rendering, we want to represent each line distinctly.
  1930. // If text is "a\nb\n", split gives ["a", "b", ""]. We want ["a", "b"].
  1931. // If text is "a\nb", split gives ["a", "b"]. We want ["a", "b"].
  1932. // If text is "\n", split gives ["", ""]. We want [""] for one empty line.
  1933. if (text.endsWith('\n') && lines.length > 0) {
  1934. lines.pop(); // Remove the empty string caused by a trailing newline
  1935. }
  1936. return lines.map(l => l.replace(/\r$/, '')); // Remove CR if present for consistency
  1937. }
  1938.  
  1939.  
  1940. generateDiff() {
  1941. const originalText = document.querySelector('.original.well')?.innerText || '';
  1942. const translationText = document.querySelector('textarea.translation.form-control')?.value || '';
  1943.  
  1944. const originalContent = document.getElementById('originalDiffContent');
  1945. const translationContent = document.getElementById('translationDiffContent');
  1946. originalContent.innerHTML = '';
  1947. translationContent.innerHTML = '';
  1948.  
  1949. if (!this.diffLib) {
  1950. console.error('Diff library (jsdiff) not loaded.');
  1951. originalContent.innerHTML = '<p>差异库未加载</p>';
  1952. return;
  1953. }
  1954.  
  1955. const lineDiffResult = this.diffLib.diffLines(originalText, translationText, { newlineIsToken: false, ignoreWhitespace: false });
  1956.  
  1957. let origDisplayLineNum = 1;
  1958. let transDisplayLineNum = 1;
  1959. let currentTranslationLineIndexForAction = 0;
  1960.  
  1961. for (let i = 0; i < lineDiffResult.length; i++) {
  1962. const part = lineDiffResult[i];
  1963. const nextPart = (i + 1 < lineDiffResult.length) ? lineDiffResult[i + 1] : null;
  1964.  
  1965. let linesInPart = this.splitIntoLines(part.value);
  1966.  
  1967. if (part.removed) {
  1968. if (nextPart && nextPart.added) { // This is a modification block
  1969. let linesInNextPart = this.splitIntoLines(nextPart.value);
  1970. const maxLines = Math.max(linesInPart.length, linesInNextPart.length);
  1971.  
  1972. for (let j = 0; j < maxLines; j++) {
  1973. const removedLine = j < linesInPart.length ? linesInPart[j] : null;
  1974. const addedLine = j < linesInNextPart.length ? linesInNextPart[j] : null;
  1975.  
  1976. if (removedLine !== null) {
  1977. this.appendLine(originalContent, origDisplayLineNum++, removedLine, 'diff-removed', removedLine, currentTranslationLineIndexForAction, true, 'original', addedLine, 'replace'); // Action: replace for modified lines
  1978. } else {
  1979. this.appendLine(originalContent, '-', '', 'diff-placeholder diff-added-extra', null, null, false, 'original', null);
  1980. }
  1981.  
  1982. if (addedLine !== null) {
  1983. this.appendLine(translationContent, transDisplayLineNum++, addedLine, 'diff-added', addedLine, currentTranslationLineIndexForAction, true, 'translation', removedLine);
  1984. } else {
  1985. this.appendLine(translationContent, '-', '', 'diff-placeholder diff-modified-old', null, null, false, 'translation', null);
  1986. }
  1987. currentTranslationLineIndexForAction++;
  1988. }
  1989. i++; // Skip nextPart as it's processed
  1990. } else { // Pure removal
  1991. linesInPart.forEach(lineText => {
  1992. this.appendLine(originalContent, origDisplayLineNum++, lineText, 'diff-removed', lineText, currentTranslationLineIndexForAction, true, 'original', '', 'insert'); // Action: insert for removed lines
  1993. this.appendLine(translationContent, '-', '', 'diff-placeholder diff-removed', null, null, false, 'translation', null);
  1994. // currentTranslationLineIndexForAction does not advance for placeholders on translation side if original is removed
  1995. });
  1996. }
  1997. } else if (part.added) { // Pure addition (modification handled above)
  1998. linesInPart.forEach(lineText => {
  1999. this.appendLine(originalContent, '-', '', 'diff-placeholder diff-added', null, null, false, 'original', null, 'insert'); // Or 'replace' if that makes more sense for placeholder context
  2000. this.appendLine(translationContent, transDisplayLineNum++, lineText, 'diff-added', lineText, currentTranslationLineIndexForAction, true, 'translation', '');
  2001. currentTranslationLineIndexForAction++;
  2002. });
  2003. } else { // Common part
  2004. linesInPart.forEach(lineText => {
  2005. this.appendLine(originalContent, origDisplayLineNum++, lineText, 'diff-common', lineText, currentTranslationLineIndexForAction, true, 'original', lineText, 'replace'); // Action: replace for common lines
  2006. this.appendLine(translationContent, transDisplayLineNum++, lineText, 'diff-common', lineText, currentTranslationLineIndexForAction, true, 'translation', lineText, 'replace');
  2007. currentTranslationLineIndexForAction++;
  2008. });
  2009. }
  2010. }
  2011. }
  2012.  
  2013. appendLine(container, lineNumber, text, diffClass, lineTextForAction = null, translationLineIndexForAction = null, showActionButton = false, side = 'original', otherTextForIntralineDiff = null, actionType = 'replace') { // Added actionType, default to 'replace'
  2014. const lineDiv = document.createElement('div');
  2015. lineDiv.className = `diff-line ${diffClass || ''}`;
  2016.  
  2017. const numberSpan = document.createElement('span');
  2018. numberSpan.className = 'diff-line-number';
  2019. numberSpan.textContent = lineNumber;
  2020. lineDiv.appendChild(numberSpan);
  2021.  
  2022. const contentSpan = document.createElement('span');
  2023. contentSpan.className = 'diff-line-content';
  2024.  
  2025. if (text === null || (text === '' && diffClass.includes('placeholder'))) {
  2026. contentSpan.innerHTML = '&nbsp;';
  2027. } else if (this.diffLib && otherTextForIntralineDiff !== null && (diffClass.includes('diff-removed') || diffClass.includes('diff-added') || diffClass.includes('diff-common'))) {
  2028. let oldContentForWordDiff, newContentForWordDiff;
  2029.  
  2030. if (diffClass.includes('diff-removed')) { // Displaying on original side, text is old
  2031. oldContentForWordDiff = text;
  2032. newContentForWordDiff = otherTextForIntralineDiff || '';
  2033. } else if (diffClass.includes('diff-added')) { // Displaying on translation side, text is new
  2034. oldContentForWordDiff = otherTextForIntralineDiff || '';
  2035. newContentForWordDiff = text;
  2036. } else { // Common line
  2037. oldContentForWordDiff = text;
  2038. newContentForWordDiff = text; // or otherTextForIntralineDiff, they are the same
  2039. }
  2040.  
  2041. const wordDiff = this.diffLib.diffWordsWithSpace(oldContentForWordDiff, newContentForWordDiff);
  2042. wordDiff.forEach(part => {
  2043. const span = document.createElement('span');
  2044. if (part.added) {
  2045. // Style as added if we are on the side that displays the "new" content of the pair
  2046. if (diffClass.includes('diff-added') || (diffClass.includes('diff-removed') && side === 'original')) {
  2047. span.className = 'diff-intraline-added';
  2048. }
  2049. } else if (part.removed) {
  2050. // Style as removed if we are on the side that displays the "old" content of the pair
  2051. if (diffClass.includes('diff-removed') || (diffClass.includes('diff-added') && side === 'translation')) {
  2052. span.className = 'diff-intraline-removed';
  2053. }
  2054. }
  2055. span.textContent = part.value;
  2056. contentSpan.appendChild(span);
  2057. });
  2058.  
  2059. } else {
  2060. contentSpan.textContent = text;
  2061. }
  2062. lineDiv.appendChild(contentSpan);
  2063.  
  2064. if (showActionButton && lineTextForAction !== null && translationLineIndexForAction !== null && !diffClass.includes('placeholder')) {
  2065. const actionButton = document.createElement('button');
  2066. actionButton.className = `btn btn-link p-0 ml-2 copy-action-button`;
  2067. let buttonTitle = '';
  2068. let buttonIconClass = '';
  2069.  
  2070. if (side === 'original') {
  2071. buttonIconClass = 'fas fa-arrow-right';
  2072. if (actionType === 'replace') {
  2073. buttonTitle = '使用此原文行覆盖译文对应行';
  2074. } else { // actionType === 'insert'
  2075. buttonTitle = '将此原文行插入到译文对应位置';
  2076. }
  2077. }
  2078. // Add logic for buttons on translation side if needed later
  2079.  
  2080. if (buttonIconClass && !diffClass.includes('diff-common')) { // <--- 修改点在这里
  2081. actionButton.innerHTML = `<i class="${buttonIconClass}"></i>`;
  2082. actionButton.title = buttonTitle;
  2083. actionButton.addEventListener('click', () => {
  2084. const textarea = document.querySelector('textarea.translation.form-control');
  2085. if (!textarea) return;
  2086. let lines = textarea.value.split('\n');
  2087. const targetIndex = Math.max(0, translationLineIndexForAction);
  2088.  
  2089. while (lines.length <= targetIndex) {
  2090. lines.push('');
  2091. }
  2092. if (actionType === 'replace') {
  2093. // 确保目标索引在数组范围内,如果超出则扩展数组
  2094. while (lines.length <= targetIndex) {
  2095. lines.push('');
  2096. }
  2097. lines[targetIndex] = lineTextForAction;
  2098. } else { // actionType === 'insert'
  2099. const effectiveTargetIndex = Math.min(lines.length, targetIndex);
  2100. lines.splice(effectiveTargetIndex, 0, lineTextForAction);
  2101. }
  2102.  
  2103. textarea.value = lines.join('\n');
  2104. simulateInputChange(textarea, textarea.value);
  2105. requestAnimationFrame(() => this.generateDiff());
  2106. });
  2107. lineDiv.appendChild(actionButton);
  2108. }
  2109. }
  2110. container.appendChild(lineDiv);
  2111. }
  2112. }
  2113.  
  2114. // 添加对比按钮
  2115. const diffButton = new Button(
  2116. '.btn.btn-secondary.show-diff-button',
  2117. '.toolbar .right .btn-group',
  2118. '<i class="fas fa-file-alt"></i> 对比文本',
  2119. function() {
  2120. new DiffModal().show();
  2121. }
  2122. );
  2123.  
  2124. const runAllReplacementsButton = new Button(
  2125. '.btn.btn-secondary.apply-all-rules-button',
  2126. '.toolbar .right .btn-group',
  2127. '<i class="fas fa-cogs"></i> 应用全部替换',
  2128. function() {
  2129. const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
  2130. const textareas = document.querySelectorAll('textarea.translation.form-control');
  2131. textareas.forEach(textarea => {
  2132. let text = textarea.value;
  2133. replaceList.forEach(rule => {
  2134. if (!rule.disabled && rule.findText) {
  2135. text = text.replaceAll(rule.findText, rule.replacementText);
  2136. }
  2137. });
  2138. simulateInputChange(textarea, text);
  2139. });
  2140. }
  2141. );
  2142.  
  2143. // AI 对话框类
  2144. class AIChatDialog {
  2145. constructor() {
  2146. this.fabId = 'ai-chat-fab';
  2147. this.dialogId = 'ai-chat-dialog';
  2148. this.messagesContainerId = 'ai-chat-messages';
  2149. this.inputAreaId = 'ai-chat-input';
  2150. this.sendButtonId = 'ai-chat-send';
  2151. this.closeButtonId = 'ai-chat-close';
  2152. this.clearHistoryButtonId = 'ai-chat-clear-history'; // New ID for clear button
  2153. this.isDragging = false;
  2154. this.dragStartX = 0;
  2155. this.dragStartY = 0;
  2156. this.dialogX = 0;
  2157. this.dialogY = 0;
  2158. this.sendContextToggleId = 'ai-chat-send-context-toggle';
  2159. this.localStorageKeySendContext = 'aiChatSendContextEnabled';
  2160. this.aiChatModelInputId = 'aiChatModelInput';
  2161. this.aiChatModelDatalistId = 'aiChatModelDatalist';
  2162. this.fetchAiChatModelsButtonId = 'fetchAiChatModelsButton';
  2163. this.localStorageKeyAiChatModel = 'aiChatModelName'; // New key for AI chat model
  2164. this.init();
  2165. }
  2166.  
  2167. init() {
  2168. this.addStyles();
  2169. this.insertFab();
  2170. // Dialog is inserted only when FAB is clicked for the first time
  2171. }
  2172.  
  2173. addStyles() {
  2174. const css = `
  2175. #${this.fabId} {
  2176. position: fixed;
  2177. bottom: 20px;
  2178. right: 20px;
  2179. width: 50px;
  2180. height: 50px;
  2181. background-color: #007bff;
  2182. color: white;
  2183. border-radius: 50%;
  2184. display: flex;
  2185. justify-content: center;
  2186. align-items: center;
  2187. font-size: 24px;
  2188. cursor: pointer;
  2189. box-shadow: 0 2px 10px rgba(0,0,0,0.2);
  2190. z-index: 9998; /* Below dialog */
  2191. transition: background-color 0.3s ease;
  2192. }
  2193. #${this.fabId}:hover {
  2194. background-color: #0056b3;
  2195. }
  2196. #${this.dialogId} {
  2197. position: fixed;
  2198. bottom: 80px; /* Position above FAB */
  2199. right: 20px;
  2200. width: 380px; /* Increased width */
  2201. height: 450px;
  2202. background-color: white;
  2203. border: 1px solid #ccc;
  2204. border-radius: 8px;
  2205. box-shadow: 0 5px 15px rgba(0,0,0,0.3);
  2206. display: none; /* Hidden by default */
  2207. flex-direction: column;
  2208. z-index: 9999;
  2209. overflow: hidden; /* Prevent content spill */
  2210. }
  2211. #${this.dialogId} .ai-chat-header {
  2212. padding: 10px 15px;
  2213. background-color: #f8f9fa;
  2214. border-bottom: 1px solid #dee2e6;
  2215. display: flex;
  2216. justify-content: space-between;
  2217. align-items: center;
  2218. cursor: move; /* Make header draggable */
  2219. }
  2220. #${this.dialogId} .ai-chat-header h5 {
  2221. margin: 0;
  2222. font-size: 1rem;
  2223. flex-grow: 1; /* Allow title to take space */
  2224. }
  2225. #${this.dialogId} .ai-chat-header .header-buttons {
  2226. display: flex;
  2227. align-items: center;
  2228. }
  2229. #${this.dialogId} .ai-chat-header .btn-icon { /* Style for icon buttons */
  2230. background: none;
  2231. border: none;
  2232. font-size: 1.2rem; /* Adjust icon size */
  2233. opacity: 0.6;
  2234. cursor: pointer;
  2235. padding: 5px;
  2236. margin-left: 8px;
  2237. }
  2238. #${this.dialogId} .ai-chat-header .btn-icon:hover {
  2239. opacity: 1;
  2240. }
  2241. #${this.messagesContainerId} {
  2242. flex-grow: 1;
  2243. overflow-y: auto;
  2244. padding: 15px;
  2245. background-color: #f0f0f0; /* Light grey background for messages */
  2246. }
  2247. #${this.messagesContainerId} .message {
  2248. margin-bottom: 10px;
  2249. padding: 8px 12px;
  2250. border-radius: 15px;
  2251. max-width: 80%;
  2252. word-wrap: break-word;
  2253. }
  2254. #${this.messagesContainerId} .message.user {
  2255. background-color: #007bff;
  2256. color: white;
  2257. margin-left: auto;
  2258. border-bottom-right-radius: 5px;
  2259. }
  2260. #${this.messagesContainerId} .message.ai {
  2261. background-color: #e9ecef;
  2262. color: #333;
  2263. margin-right: auto;
  2264. border-bottom-left-radius: 5px;
  2265. }
  2266. #${this.messagesContainerId} .message.error {
  2267. background-color: #f8d7da;
  2268. color: #721c24;
  2269. margin-right: auto;
  2270. border-bottom-left-radius: 5px;
  2271. font-style: italic;
  2272. }
  2273. #${this.dialogId} .ai-chat-input-area {
  2274. display: flex;
  2275. align-items: flex-start; /* Align items to the start for multi-line textarea */
  2276. padding: 10px;
  2277. border-top: 1px solid #dee2e6;
  2278. background-color: #f8f9fa;
  2279. }
  2280. #${this.inputAreaId} {
  2281. flex-grow: 1;
  2282. margin-right: 8px; /* Reduced margin */
  2283. resize: none; /* Prevent manual resize */
  2284. min-height: 40px; /* Ensure it's at least one line */
  2285. max-height: 120px; /* Limit max height for textarea */
  2286. overflow-y: auto; /* Allow scroll if content exceeds max-height */
  2287. line-height: 1.5; /* Adjust line height for better readability */
  2288. }
  2289. #${this.sendButtonId} {
  2290. height: 40px; /* Keep button height consistent */
  2291. min-width: 65px; /* Ensure button has enough space for "发送" */
  2292. padding-left: 12px;
  2293. padding-right: 12px;
  2294. align-self: flex-end; /* Align button to bottom if textarea grows */
  2295. }
  2296. .ai-chat-options {
  2297. padding: 5px 10px;
  2298. background-color: #f8f9fa;
  2299. border-bottom: 1px solid #dee2e6;
  2300. font-size: 0.85rem;
  2301. }
  2302. .ai-chat-options .custom-control-label {
  2303. font-weight: normal;
  2304. }
  2305. `;
  2306. GM_addStyle(css);
  2307. }
  2308.  
  2309. insertFab() {
  2310. if (document.getElementById(this.fabId)) return;
  2311. const fab = document.createElement('div');
  2312. fab.id = this.fabId;
  2313. fab.innerHTML = '<i class="fas fa-robot"></i>'; // Example icon
  2314. fab.title = 'AI 助手';
  2315. fab.addEventListener('click', () => this.toggleDialog());
  2316. document.body.appendChild(fab);
  2317. }
  2318.  
  2319. insertDialog() {
  2320. if (document.getElementById(this.dialogId)) return;
  2321.  
  2322. const dialog = document.createElement('div');
  2323. dialog.id = this.dialogId;
  2324. dialog.innerHTML = `
  2325. <div class="ai-chat-header">
  2326. <h5>AI 助手</h5>
  2327. <div class="header-buttons">
  2328. <button type="button" class="btn-icon" id="${this.clearHistoryButtonId}" title="清空聊天记录">
  2329. <i class="fas fa-trash-alt"></i>
  2330. </button>
  2331. <button type="button" class="btn-icon close" id="${this.closeButtonId}" aria-label="Close" title="关闭对话框">
  2332. <span aria-hidden="true">&times;</span>
  2333. </button>
  2334. </div>
  2335. </div>
  2336. <div id="${this.messagesContainerId}">
  2337. <div class="message ai">你好!有什么可以帮你的吗?</div>
  2338. </div>
  2339. <div class="ai-chat-options">
  2340. <div class="custom-control custom-switch custom-control-sm">
  2341. <input type="checkbox" class="custom-control-input" id="${this.sendContextToggleId}">
  2342. <label class="custom-control-label" for="${this.sendContextToggleId}">发送页面上下文给AI</label>
  2343. </div>
  2344. </div>
  2345. <div class="ai-chat-options" style="border-top: 1px solid #dee2e6; padding-top: 8px; margin-top: 5px;"> <!-- Model selection for AI Chat -->
  2346. <div class="form-group mb-1">
  2347. <label for="${this.aiChatModelInputId}" style="font-size: 0.85rem; margin-bottom: .2rem;">AI 模型:</label>
  2348. <div class="input-group input-group-sm">
  2349. <input type="text" class="form-control form-control-sm" id="${this.aiChatModelInputId}" placeholder="默认 (gpt-4o-mini)" list="${this.aiChatModelDatalistId}">
  2350. <datalist id="${this.aiChatModelDatalistId}"></datalist>
  2351. <div class="input-group-append">
  2352. <button class="btn btn-outline-secondary btn-sm" type="button" id="${this.fetchAiChatModelsButtonId}" title="获取模型列表">
  2353. <i class="fas fa-sync-alt"></i>
  2354. </button>
  2355. </div>
  2356. </div>
  2357. </div>
  2358. </div>
  2359. <div class="ai-chat-input-area">
  2360. <textarea id="${this.inputAreaId}" class="form-control" placeholder="输入消息..."></textarea>
  2361. <button id="${this.sendButtonId}" class="btn btn-primary">发送</button>
  2362. </div>
  2363. `;
  2364. document.body.appendChild(dialog);
  2365.  
  2366. // Add event listeners
  2367. document.getElementById(this.closeButtonId).addEventListener('click', () => this.toggleDialog(false));
  2368. document.getElementById(this.clearHistoryButtonId).addEventListener('click', () => this.clearChatHistory());
  2369. document.getElementById(this.sendButtonId).addEventListener('click', () => this.sendMessage());
  2370. const sendContextToggle = document.getElementById(this.sendContextToggleId);
  2371. const aiChatModelInput = document.getElementById(this.aiChatModelInputId);
  2372. const fetchAiChatModelsButton = document.getElementById(this.fetchAiChatModelsButtonId);
  2373.  
  2374. // Load saved preference for sending context
  2375. const savedSendContextPreference = localStorage.getItem(this.localStorageKeySendContext);
  2376. if (savedSendContextPreference === 'true') {
  2377. sendContextToggle.checked = true;
  2378. } else if (savedSendContextPreference === 'false') {
  2379. sendContextToggle.checked = false;
  2380. } else {
  2381. sendContextToggle.checked = true; // Default to true if not set
  2382. localStorage.setItem(this.localStorageKeySendContext, 'true');
  2383. }
  2384.  
  2385. sendContextToggle.addEventListener('change', (e) => {
  2386. localStorage.setItem(this.localStorageKeySendContext, e.target.checked);
  2387. });
  2388.  
  2389. // AI Chat Model preferences
  2390. let initialAiChatModel = localStorage.getItem(this.localStorageKeyAiChatModel);
  2391. if (!initialAiChatModel) {
  2392. // If no specific AI chat model is saved, try to use the model from the current translation config
  2393. const currentTranslationConfigName = getCurrentApiConfigName();
  2394. if (currentTranslationConfigName) {
  2395. const configs = getApiConfigurations();
  2396. const activeTranslationConfig = configs.find(c => c.name === currentTranslationConfigName);
  2397. if (activeTranslationConfig && activeTranslationConfig.model) {
  2398. initialAiChatModel = activeTranslationConfig.model;
  2399. // Save this inherited model as the current AI chat model
  2400. localStorage.setItem(this.localStorageKeyAiChatModel, initialAiChatModel);
  2401. }
  2402. }
  2403. }
  2404. aiChatModelInput.value = initialAiChatModel || ''; // Fallback to empty if no model found
  2405.  
  2406. aiChatModelInput.addEventListener('input', () => {
  2407. localStorage.setItem(this.localStorageKeyAiChatModel, aiChatModelInput.value);
  2408. });
  2409. fetchAiChatModelsButton.addEventListener('click', async () => {
  2410. await this.fetchModelsAndUpdateDatalistForChat();
  2411. });
  2412.  
  2413. document.getElementById(this.inputAreaId).addEventListener('keypress', (e) => {
  2414. if (e.key === 'Enter' && !e.shiftKey) {
  2415. e.preventDefault(); // Prevent newline
  2416. this.sendMessage();
  2417. }
  2418. });
  2419. // Auto-resize textarea
  2420. const textarea = document.getElementById(this.inputAreaId);
  2421. textarea.addEventListener('input', () => {
  2422. // Auto-resize textarea based on content, up to max-height
  2423. textarea.style.height = 'auto'; // Reset height to shrink if text is deleted
  2424. let scrollHeight = textarea.scrollHeight;
  2425. const maxHeight = parseInt(window.getComputedStyle(textarea).maxHeight, 10);
  2426. if (maxHeight && scrollHeight > maxHeight) {
  2427. textarea.style.height = maxHeight + 'px';
  2428. textarea.style.overflowY = 'auto';
  2429. } else {
  2430. textarea.style.height = scrollHeight + 'px';
  2431. textarea.style.overflowY = 'hidden';
  2432. }
  2433. });
  2434.  
  2435. // Make dialog draggable
  2436. const header = dialog.querySelector('.ai-chat-header');
  2437. header.addEventListener('mousedown', (e) => {
  2438. this.isDragging = true;
  2439. this.dragStartX = e.clientX - dialog.offsetLeft;
  2440. this.dragStartY = e.clientY - dialog.offsetTop;
  2441. header.style.cursor = 'grabbing'; // Change cursor while dragging
  2442. // Prevent text selection during drag
  2443. document.body.style.userSelect = 'none';
  2444. });
  2445.  
  2446. document.addEventListener('mousemove', (e) => {
  2447. if (!this.isDragging) return;
  2448. const newX = e.clientX - this.dragStartX;
  2449. const newY = e.clientY - this.dragStartY;
  2450.  
  2451. // Keep dialog within viewport boundaries (optional)
  2452. const maxX = window.innerWidth - dialog.offsetWidth;
  2453. const maxY = window.innerHeight - dialog.offsetHeight;
  2454.  
  2455. dialog.style.left = Math.max(0, Math.min(newX, maxX)) + 'px';
  2456. dialog.style.top = Math.max(0, Math.min(newY, maxY)) + 'px';
  2457. // Update position relative to bottom/right if needed, but left/top is simpler for dragging
  2458. dialog.style.bottom = 'auto';
  2459. dialog.style.right = 'auto';
  2460. });
  2461.  
  2462. document.addEventListener('mouseup', () => {
  2463. if (this.isDragging) {
  2464. this.isDragging = false;
  2465. header.style.cursor = 'move';
  2466. document.body.style.userSelect = ''; // Restore text selection
  2467. }
  2468. });
  2469. }
  2470.  
  2471. toggleDialog(forceShow = null) {
  2472. if (!document.getElementById(this.dialogId)) {
  2473. this.insertDialog(); // Create dialog on first open
  2474. }
  2475. const dialog = document.getElementById(this.dialogId);
  2476. const shouldShow = forceShow !== null ? forceShow : dialog.style.display === 'none';
  2477.  
  2478. if (shouldShow) {
  2479. dialog.style.display = 'flex';
  2480. // Focus input when opened
  2481. setTimeout(() => document.getElementById(this.inputAreaId)?.focus(), 0);
  2482. } else {
  2483. dialog.style.display = 'none';
  2484. }
  2485. }
  2486.  
  2487. displayMessage(text, sender = 'ai', isError = false) {
  2488. const messagesContainer = document.getElementById(this.messagesContainerId);
  2489. if (!messagesContainer) return;
  2490.  
  2491. const messageDiv = document.createElement('div');
  2492. messageDiv.classList.add('message', sender);
  2493. if (isError) {
  2494. messageDiv.classList.add('error');
  2495. }
  2496. if (sender === 'ai' && !isError) {
  2497. messageDiv.innerHTML = text.replace(/\n/g, '<br>'); // Initial text or full text if not streaming
  2498. } else {
  2499. messageDiv.textContent = text;
  2500. }
  2501. messagesContainer.appendChild(messageDiv);
  2502. messagesContainer.scrollTop = messagesContainer.scrollHeight;
  2503. return messageDiv; // Return the created message element for potential stream updates
  2504. }
  2505.  
  2506. updateAIMessage(messageElement, chunk) {
  2507. if (!messageElement) return;
  2508. // Append new chunk, converting newlines.
  2509. // For proper Markdown streaming, this would need to be more sophisticated,
  2510. // potentially re-rendering the whole Markdown on each chunk or using a lib that supports streaming.
  2511. messageElement.innerHTML += chunk.replace(/\n/g, '<br>');
  2512. const messagesContainer = document.getElementById(this.messagesContainerId);
  2513. if (messagesContainer) {
  2514. messagesContainer.scrollTop = messagesContainer.scrollHeight;
  2515. }
  2516. }
  2517. clearChatHistory() {
  2518. const messagesContainer = document.getElementById(this.messagesContainerId);
  2519. if (messagesContainer) {
  2520. messagesContainer.innerHTML = ''; // Clear all messages
  2521. this.displayMessage('你好!有什么可以帮你的吗?', 'ai'); // Display initial greeting
  2522. }
  2523. }
  2524.  
  2525. async sendMessage() {
  2526. const inputArea = document.getElementById(this.inputAreaId);
  2527. const sendButton = document.getElementById(this.sendButtonId);
  2528. const messageText = inputArea.value.trim();
  2529.  
  2530. if (!messageText) return;
  2531.  
  2532. this.displayMessage(messageText, 'user');
  2533. inputArea.value = '';
  2534. // Reset textarea height after sending
  2535. inputArea.style.height = 'auto';
  2536. inputArea.style.height = (inputArea.scrollHeight < 40 ? 40 : inputArea.scrollHeight) + 'px';
  2537. if (parseInt(inputArea.style.height) > parseInt(window.getComputedStyle(inputArea).maxHeight)) {
  2538. inputArea.style.height = window.getComputedStyle(inputArea).maxHeight;
  2539. inputArea.style.overflowY = 'auto';
  2540. } else {
  2541. inputArea.style.overflowY = 'hidden';
  2542. }
  2543. inputArea.disabled = true;
  2544. sendButton.disabled = true;
  2545. // Display "Thinking..." and get the message element
  2546. let aiMessageElement = this.displayMessage('思考中...', 'ai');
  2547. const messagesContainerElement = document.getElementById(this.messagesContainerId);
  2548.  
  2549. try {
  2550. // Call chatWithAI, now potentially streaming
  2551. await this.chatWithAI(messageText, (chunk) => {
  2552. if (aiMessageElement && aiMessageElement.textContent === '思考中...') {
  2553. // Replace "Thinking..." with the first chunk
  2554. aiMessageElement.innerHTML = chunk.replace(/\n/g, '<br>');
  2555. } else if (aiMessageElement) {
  2556. // Append subsequent chunks
  2557. this.updateAIMessage(aiMessageElement, chunk);
  2558. }
  2559. });
  2560.  
  2561. // If the "Thinking..." message is still there (e.g. stream was empty or very fast non-streamed error)
  2562. // This case should ideally be handled by the streaming logic itself replacing "Thinking..."
  2563. // For non-streaming success, chatWithAI would have to call the onChunk callback once.
  2564. // If chatWithAI throws an error before any chunk, the catch block handles it.
  2565.  
  2566. } catch (error) {
  2567. if (aiMessageElement && messagesContainerElement) { // Ensure element exists
  2568. // If "Thinking..." is still shown, replace it with error. Otherwise, display a new error message.
  2569. if (aiMessageElement.textContent === '思考中...') {
  2570. aiMessageElement.classList.add('error');
  2571. aiMessageElement.innerHTML = `抱歉,与 AI 通信时出错: ${error.message}`.replace(/\n/g, '<br>');
  2572. } else {
  2573. this.displayMessage(`抱歉,与 AI 通信时出错: ${error.message}`, 'ai', true);
  2574. }
  2575. } else { // Fallback if aiMessageElement somehow isn't there
  2576. this.displayMessage(`抱歉,与 AI 通信时出错: ${error.message}`, 'ai', true);
  2577. }
  2578. console.error('AI Chat Error:', error);
  2579. this.displayMessage(`抱歉,与 AI 通信时出错: ${error.message}`, 'ai', true);
  2580. } finally {
  2581. inputArea.disabled = false;
  2582. sendButton.disabled = false;
  2583. inputArea.focus();
  2584. }
  2585. }
  2586.  
  2587. // Modified chat function to support streaming
  2588. async fetchModelsAndUpdateDatalistForChat() {
  2589. const modelDatalist = document.getElementById(this.aiChatModelDatalistId);
  2590. const fetchButton = document.getElementById(this.fetchAiChatModelsButtonId);
  2591. const originalButtonHtml = fetchButton.innerHTML;
  2592. fetchButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
  2593. fetchButton.disabled = true;
  2594.  
  2595. let API_SECRET_KEY = '';
  2596. let BASE_URL = '';
  2597. const currentConfigName = getCurrentApiConfigName();
  2598. let activeConfig = null;
  2599.  
  2600. if (currentConfigName) {
  2601. const configs = getApiConfigurations();
  2602. activeConfig = configs.find(c => c.name === currentConfigName);
  2603. }
  2604.  
  2605. if (activeConfig) {
  2606. BASE_URL = activeConfig.baseUrl;
  2607. API_SECRET_KEY = activeConfig.apiKey;
  2608. } else {
  2609. showToast('请先在“机器翻译”配置中选择一个有效的 API 配置。', 'error', 5000);
  2610. fetchButton.innerHTML = originalButtonHtml;
  2611. fetchButton.disabled = false;
  2612. return;
  2613. }
  2614.  
  2615. if (!BASE_URL || !API_SECRET_KEY) {
  2616. showToast('当前选中的 API 配置缺少 Base URL 或 API Key。', 'error', 5000);
  2617. fetchButton.innerHTML = originalButtonHtml;
  2618. fetchButton.disabled = false;
  2619. return;
  2620. }
  2621.  
  2622. const modelsUrl = `${BASE_URL}${BASE_URL.endsWith('/') ? '' : '/'}models`;
  2623.  
  2624. try {
  2625. const response = await fetch(modelsUrl, {
  2626. method: 'GET',
  2627. headers: { 'Authorization': `Bearer ${API_SECRET_KEY}` }
  2628. });
  2629. if (!response.ok) {
  2630. const errorData = await response.text();
  2631. showToast(`为AI助手获取模型列表失败: ${response.status} - ${errorData.substring(0,100)}`, 'error', 5000);
  2632. return;
  2633. }
  2634. const data = await response.json();
  2635. if (data && data.data && Array.isArray(data.data)) {
  2636. modelDatalist.innerHTML = ''; // Clear existing options
  2637. data.data.forEach(model => {
  2638. if (model.id) {
  2639. const option = document.createElement('option');
  2640. option.value = model.id;
  2641. modelDatalist.appendChild(option);
  2642. }
  2643. });
  2644. showToast('AI助手模型列表已更新。', 'success');
  2645. } else {
  2646. showToast('AI助手模型列表响应数据格式不符合预期。', 'warning', 4000);
  2647. }
  2648. } catch (error) {
  2649. showToast(`为AI助手获取模型列表时发生网络错误: ${error.message}`, 'error', 5000);
  2650. } finally {
  2651. fetchButton.innerHTML = originalButtonHtml;
  2652. fetchButton.disabled = false;
  2653. }
  2654. }
  2655.  
  2656. async chatWithAI(userMessage, onChunkReceived) {
  2657. let API_SECRET_KEY = '';
  2658. let BASE_URL = '';
  2659. const currentConfigName = getCurrentApiConfigName(); // This is the translation config
  2660. let activeTranslationConfig = null;
  2661. if (currentConfigName) {
  2662. const configs = getApiConfigurations();
  2663. activeTranslationConfig = configs.find(c => c.name === currentConfigName);
  2664. }
  2665.  
  2666. // Get AI Chat specific model.
  2667. // Priority: 1. localStorageKeyAiChatModel, 2. activeTranslationConfig.model, 3. 'gpt-4o-mini'
  2668. let model = localStorage.getItem(this.localStorageKeyAiChatModel);
  2669. if (!model && activeTranslationConfig && activeTranslationConfig.model) {
  2670. model = activeTranslationConfig.model;
  2671. }
  2672. if (!model) {
  2673. model = 'gpt-4o-mini'; // Ultimate fallback
  2674. }
  2675. let temperature = 0.7; // Default temperature for chat
  2676. let systemPrompt = `你是一个在 Paratranz 翻译平台工作的 AI 助手。请根据用户的问题,结合当前条目的原文、上下文、术语等信息(如果提供),提供翻译建议、解释或回答相关问题。请保持回答简洁明了。`;
  2677.  
  2678. if (activeTranslationConfig) {
  2679. BASE_URL = activeTranslationConfig.baseUrl;
  2680. API_SECRET_KEY = activeTranslationConfig.apiKey;
  2681. temperature = (activeTranslationConfig.temperature !== undefined && activeTranslationConfig.temperature !== '')
  2682. ? parseFloat(activeTranslationConfig.temperature)
  2683. : temperature;
  2684. } else {
  2685. console.warn("AI Chat: No active API configuration selected for API credentials. Chat might fail.");
  2686. // Attempt to use fallback keys if absolutely necessary, but ideally user should configure
  2687. BASE_URL = localStorage.getItem('baseUrl_fallback_for_translate') || '';
  2688. API_SECRET_KEY = localStorage.getItem('apiKey_fallback_for_translate') || '';
  2689. }
  2690.  
  2691. if (!BASE_URL || !API_SECRET_KEY) {
  2692. throw new Error("API Base URL 或 Key 未配置。请在“机器翻译”配置中设置。");
  2693. }
  2694.  
  2695. // --- Context Gathering (Optional but Recommended) ---
  2696. let contextInfo = "";
  2697. const shouldSendContext = localStorage.getItem(this.localStorageKeySendContext) === 'true';
  2698.  
  2699. if (shouldSendContext) {
  2700. try {
  2701. const originalDiv = document.querySelector('.original.well');
  2702. if (originalDiv) contextInfo += `当前原文 (Original Text):\n${originalDiv.innerText.trim()}\n\n`;
  2703.  
  2704. const currentTranslationTextarea = document.querySelector('textarea.translation.form-control');
  2705. if (currentTranslationTextarea && currentTranslationTextarea.value.trim()) {
  2706. contextInfo += `当前翻译 (Current Translation):\n${currentTranslationTextarea.value.trim()}\n\n`;
  2707. }
  2708.  
  2709. const contextNoteDiv = document.querySelector('.context .well');
  2710. if (contextNoteDiv) contextInfo += `上下文注释 (Context Note):\n${contextNoteDiv.innerText.trim()}\n\n`;
  2711.  
  2712. const terms = await getTermsData(); // Reuse existing function
  2713. if (terms.length > 0) {
  2714. contextInfo += `相关术语 (Terms):\n${terms.map(t => `${t.source} -> ${t.target}${t.note ? ` (${t.note})` : ''}`).join('\n')}\n\n`;
  2715. }
  2716. } catch (e) {
  2717. console.warn("AI Chat: Error gathering context:", e);
  2718. }
  2719. }
  2720. // --- End Context Gathering ---
  2721.  
  2722. const messages = [
  2723. { role: "system", content: systemPrompt }
  2724. ];
  2725.  
  2726. if (contextInfo) {
  2727. messages.push({ role: "user", content: `请参考以下上下文信息:\n${contextInfo}我的问题是:\n${userMessage}` });
  2728. } else {
  2729. messages.push({ role: "user", content: userMessage });
  2730. }
  2731.  
  2732. const requestBody = { model, temperature, messages, stream: true }; // Enable streaming
  2733.  
  2734. const response = await fetch(`${BASE_URL}${BASE_URL.endsWith('/') ? '' : '/'}chat/completions`, {
  2735. method: 'POST',
  2736. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_SECRET_KEY}` },
  2737. body: JSON.stringify(requestBody),
  2738. });
  2739.  
  2740. if (!response.ok) {
  2741. let errorData;
  2742. try { errorData = await response.json(); } catch (e) { /* ignore parsing error for non-json errors */ }
  2743. console.error('AI Chat API Error:', errorData || response.statusText);
  2744. throw new Error(`API 请求失败: ${response.status} - ${errorData?.error?.message || errorData?.message || response.statusText}`);
  2745. }
  2746.  
  2747. if (!response.body) {
  2748. throw new Error('ReadableStream not available in response.');
  2749. }
  2750.  
  2751. const reader = response.body.getReader();
  2752. const decoder = new TextDecoder();
  2753. let buffer = '';
  2754.  
  2755. try {
  2756. while (true) {
  2757. const { done, value } = await reader.read();
  2758. if (done) break;
  2759.  
  2760. buffer += decoder.decode(value, { stream: true });
  2761. let eolIndex;
  2762. while ((eolIndex = buffer.indexOf('\n')) >= 0) {
  2763. const line = buffer.substring(0, eolIndex).trim();
  2764. buffer = buffer.substring(eolIndex + 1);
  2765.  
  2766. if (line.startsWith('data: ')) {
  2767. const jsonData = line.substring(6);
  2768. if (jsonData === '[DONE]') {
  2769. console.log("Stream finished.");
  2770. return; // Stream ended
  2771. }
  2772. try {
  2773. const parsed = JSON.parse(jsonData);
  2774. if (parsed.choices && parsed.choices[0]?.delta?.content) {
  2775. onChunkReceived(parsed.choices[0].delta.content);
  2776. }
  2777. } catch (e) {
  2778. console.error('Error parsing stream JSON:', e, jsonData);
  2779. }
  2780. }
  2781. }
  2782. }
  2783. // Process any remaining buffer content if necessary (though for SSE, lines usually end with \n)
  2784. if (buffer.trim().startsWith('data: ')) {
  2785. const jsonData = buffer.trim().substring(6);
  2786. if (jsonData !== '[DONE]') {
  2787. try {
  2788. const parsed = JSON.parse(jsonData);
  2789. if (parsed.choices && parsed.choices[0]?.delta?.content) {
  2790. onChunkReceived(parsed.choices[0].delta.content);
  2791. }
  2792. } catch (e) {
  2793. console.error('Error parsing final buffer JSON:', e, jsonData);
  2794. }
  2795. }
  2796. }
  2797.  
  2798.  
  2799. } catch (error) {
  2800. console.error('Error reading stream:', error);
  2801. throw new Error(`读取流时出错: ${error.message}`);
  2802. } finally {
  2803. reader.releaseLock();
  2804. }
  2805. }
  2806. }
  2807.  
  2808. // --- Initialization ---
  2809. const aiChatDialog = new AIChatDialog(); // Initialize AI Chat Dialog
  2810.  
  2811. })();