@lynn123411/dsh-chat-translate 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -888,36 +888,29 @@ function dshHomePath(...segments) {
888
888
 
889
889
  // src/server/config.ts
890
890
  import * as fs from "node:fs/promises";
891
- var MAX_CONCURRENCY = 100;
892
891
  var AI_TIMEOUT_MIN = 500;
893
- var AI_TIMEOUT_MAX = 12e4;
894
- var THINK_TIMEOUT_MIN = 500;
895
- var THINK_TIMEOUT_MAX = 9e5;
896
- var THINK_CACHE_ENTRIES = 300;
892
+ var AI_TIMEOUT_MAX = 9e5;
897
893
  var SETTINGS_NAMESPACE = "dsh-chat-translate";
898
894
  var DEFAULT_CONFIG = {
899
895
  enabled: true,
900
- concurrency: 3,
901
- timeoutMs: 2e3,
902
- aiTimeoutMs: 3e4,
903
- thinkTimeoutMs: 6e5,
904
- aiEnabled: true,
905
- bingEnabled: true,
906
- thinkEnabled: true,
896
+ aiTimeoutMs: 6e5,
907
897
  baseUrl: "",
908
898
  model: "",
909
899
  targetLang: "zh-Hans"
910
900
  };
901
+ var RETIRED_CONFIG_KEYS = [
902
+ "channels",
903
+ "concurrency",
904
+ "timeoutMs",
905
+ "thinkTimeoutMs",
906
+ "aiEnabled",
907
+ "bingEnabled",
908
+ "thinkEnabled"
909
+ ];
911
910
  function readPluginConfig(refs) {
912
911
  return {
913
912
  enabled: refs.enabled.get(),
914
- concurrency: refs.concurrency.get(),
915
- timeoutMs: refs.timeoutMs.get(),
916
913
  aiTimeoutMs: refs.aiTimeoutMs.get(),
917
- thinkTimeoutMs: refs.thinkTimeoutMs.get(),
918
- aiEnabled: refs.aiEnabled.get(),
919
- bingEnabled: refs.bingEnabled.get(),
920
- thinkEnabled: refs.thinkEnabled.get(),
921
914
  baseUrl: refs.baseUrl.get(),
922
915
  model: refs.model.get(),
923
916
  targetLang: refs.targetLang.get()
@@ -953,27 +946,12 @@ var ConfigManager = class {
953
946
  function sanitizePatch(input) {
954
947
  const next = {};
955
948
  if (typeof input.enabled === "boolean") next.enabled = input.enabled;
956
- if (typeof input.aiEnabled === "boolean") next.aiEnabled = input.aiEnabled;
957
- if (typeof input.bingEnabled === "boolean") next.bingEnabled = input.bingEnabled;
958
- if (typeof input.thinkEnabled === "boolean") next.thinkEnabled = input.thinkEnabled;
959
- if (typeof input.concurrency === "number" && Number.isFinite(input.concurrency)) {
960
- next.concurrency = Math.min(Math.max(Math.round(input.concurrency), 1), MAX_CONCURRENCY);
961
- }
962
- if (typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs)) {
963
- next.timeoutMs = Math.min(Math.max(Math.round(input.timeoutMs), 500), 1e4);
964
- }
965
949
  if (typeof input.aiTimeoutMs === "number" && Number.isFinite(input.aiTimeoutMs)) {
966
950
  next.aiTimeoutMs = Math.min(
967
951
  Math.max(Math.round(input.aiTimeoutMs), AI_TIMEOUT_MIN),
968
952
  AI_TIMEOUT_MAX
969
953
  );
970
954
  }
971
- if (typeof input.thinkTimeoutMs === "number" && Number.isFinite(input.thinkTimeoutMs)) {
972
- next.thinkTimeoutMs = Math.min(
973
- Math.max(Math.round(input.thinkTimeoutMs), THINK_TIMEOUT_MIN),
974
- THINK_TIMEOUT_MAX
975
- );
976
- }
977
955
  if (typeof input.baseUrl === "string") next.baseUrl = input.baseUrl.trim();
978
956
  if (typeof input.model === "string") next.model = input.model.trim();
979
957
  if (typeof input.targetLang === "string" && input.targetLang.trim()) {
@@ -981,6 +959,26 @@ function sanitizePatch(input) {
981
959
  }
982
960
  return next;
983
961
  }
962
+ async function retireRemovedConfigKeys(settings) {
963
+ const descriptor = settings.describe().find((d) => d.ns === SETTINGS_NAMESPACE);
964
+ const user = descriptor?.user;
965
+ if (typeof user !== "object" || user === null || Array.isArray(user)) return false;
966
+ const record = user;
967
+ const ops = RETIRED_CONFIG_KEYS.filter(
968
+ (key) => record[key] !== void 0
969
+ ).map((key) => ({ op: "unset", path: [key] }));
970
+ if (ops.length === 0) return false;
971
+ try {
972
+ await settings.mutate(SETTINGS_NAMESPACE, ops, descriptor?.revision);
973
+ } catch (err) {
974
+ console.warn(
975
+ "[dsh-chat-translate] Failed to retire removed config keys; will retry on next boot:",
976
+ err
977
+ );
978
+ return false;
979
+ }
980
+ return true;
981
+ }
984
982
  async function migrateLegacyConfigFile(settings, legacyPath) {
985
983
  let raw;
986
984
  try {
@@ -1298,7 +1296,7 @@ var LruDiskCache = class {
1298
1296
  dirty = false;
1299
1297
  /** Legacy root-level cache file (<=1.1); moved under the plugin subdir. */
1300
1298
  legacyPath;
1301
- /** Only the original title cache carries a pre-1.2 root-level predecessor. */
1299
+ /** Only the original cache file carries a pre-1.2 root-level predecessor. */
1302
1300
  hasLegacyPredecessor;
1303
1301
  constructor(maxEntries = 1e3, fileName = "cache.json") {
1304
1302
  this.maxEntries = maxEntries;
@@ -1430,6 +1428,17 @@ var LruDiskCache = class {
1430
1428
  }
1431
1429
  }
1432
1430
  };
1431
+ async function retireStoreFiles(fileNames) {
1432
+ let removed = false;
1433
+ for (const fileName of fileNames) {
1434
+ try {
1435
+ await fs2.unlink(dshHomePath("dsh-chat-translate", fileName));
1436
+ removed = true;
1437
+ } catch {
1438
+ }
1439
+ }
1440
+ return removed;
1441
+ }
1433
1442
 
1434
1443
  // src/describe-error.ts
1435
1444
  function describeError(err) {
@@ -1457,129 +1466,6 @@ function describeError(err) {
1457
1466
  return parts.join(" \u2190 ");
1458
1467
  }
1459
1468
 
1460
- // src/server/adapters/bing.ts
1461
- var TRANSLATOR_URL = "https://cn.bing.com/translator";
1462
- var TRANSLATE_URL = "https://cn.bing.com/ttranslatev3?isVertical=1&&IG={IG}&IID=translator.5025.1";
1463
- var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
1464
- var IG_RES = [
1465
- /_IG="([a-zA-Z0-9]+)"/,
1466
- /,IG:"([a-zA-Z0-9]+)"/,
1467
- /IG:"([a-zA-Z0-9]+)"/,
1468
- /"IG":"([a-zA-Z0-9]+)"/
1469
- ];
1470
- var ABUSE_RES = [
1471
- /params_AbusePreventionHelper\s*=\s*\[\s*(\d+)\s*,\s*"([^"]+)"/,
1472
- /var\s+params_AbusePreventionHelper\s*=\s*\[\s*(\d+)\s*,\s*"([^"]+)"/
1473
- ];
1474
- var cachedTokens = null;
1475
- var tokensFetchedAt = 0;
1476
- var TOKEN_TTL_MS = 15 * 60 * 1e3;
1477
- var inFlightTokenPromise = null;
1478
- function parseTokens(html) {
1479
- let ig;
1480
- for (const re of IG_RES) {
1481
- const m = re.exec(html);
1482
- if (m && m[1]) {
1483
- ig = m[1];
1484
- break;
1485
- }
1486
- }
1487
- let key;
1488
- let token;
1489
- for (const re of ABUSE_RES) {
1490
- const m = re.exec(html);
1491
- if (m && m[1] && m[2]) {
1492
- key = m[1];
1493
- token = m[2];
1494
- break;
1495
- }
1496
- }
1497
- if (!ig || !key || !token) {
1498
- throw new Error(`Bing translator page: missing tokens (ig: ${!!ig}, key: ${!!key}, token: ${!!token})`);
1499
- }
1500
- return { ig, key, token };
1501
- }
1502
- async function fetchTokens(signal, forceRefresh = false) {
1503
- if (!forceRefresh && cachedTokens && Date.now() - tokensFetchedAt < TOKEN_TTL_MS) {
1504
- return cachedTokens;
1505
- }
1506
- if (inFlightTokenPromise) {
1507
- return inFlightTokenPromise;
1508
- }
1509
- inFlightTokenPromise = (async () => {
1510
- try {
1511
- const response = await fetch(TRANSLATOR_URL, {
1512
- headers: {
1513
- "User-Agent": UA,
1514
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1515
- },
1516
- signal
1517
- });
1518
- if (!response.ok) {
1519
- throw new Error(`Bing translator page responded with status ${response.status}`);
1520
- }
1521
- const html = await response.text();
1522
- const tokens = parseTokens(html);
1523
- cachedTokens = tokens;
1524
- tokensFetchedAt = Date.now();
1525
- return tokens;
1526
- } finally {
1527
- inFlightTokenPromise = null;
1528
- }
1529
- })();
1530
- return inFlightTokenPromise;
1531
- }
1532
- var BingWebAdapter = class {
1533
- id = "bing";
1534
- name = "\u5FAE\u8F6F Bing \u7F51\u9875\u7FFB\u8BD1 (\u514DKey\u76F4\u8FDE)";
1535
- isAvailable(_config) {
1536
- return true;
1537
- }
1538
- async translate(text, signal, config) {
1539
- const targetLang = config.targetLang || "zh-Hans";
1540
- return this.executeTranslate(text, signal, targetLang, false);
1541
- }
1542
- async executeTranslate(text, signal, targetLang, isRetry) {
1543
- const tokens = await fetchTokens(signal, isRetry);
1544
- const body = new URLSearchParams({
1545
- fromLang: "auto-detect",
1546
- text,
1547
- to: targetLang,
1548
- key: tokens.key,
1549
- token: tokens.token,
1550
- tryFetchingGenderDebiasedTranslations: "true"
1551
- });
1552
- const response = await fetch(TRANSLATE_URL.replace("{IG}", tokens.ig), {
1553
- method: "POST",
1554
- headers: {
1555
- "User-Agent": UA,
1556
- Referer: "https://cn.bing.com/translator/",
1557
- Origin: "https://cn.bing.com",
1558
- "Content-Type": "application/x-www-form-urlencoded"
1559
- },
1560
- body,
1561
- signal
1562
- });
1563
- if (!response.ok) {
1564
- cachedTokens = null;
1565
- if (!isRetry && (response.status === 400 || response.status === 401 || response.status === 403)) {
1566
- return this.executeTranslate(text, signal, targetLang, true);
1567
- }
1568
- throw new Error(`Bing translate responded with status ${response.status}`);
1569
- }
1570
- const json = await response.json();
1571
- const translated = json?.[0]?.translations?.[0]?.text?.trim();
1572
- if (!translated) {
1573
- cachedTokens = null;
1574
- if (!isRetry) {
1575
- return this.executeTranslate(text, signal, targetLang, true);
1576
- }
1577
- throw new Error("Bing translate returned an empty result");
1578
- }
1579
- return translated;
1580
- }
1581
- };
1582
-
1583
1469
  // src/server/adapters/openai.ts
1584
1470
  var LANG_HINTS = {
1585
1471
  "zh-hans": "Simplified Chinese",
@@ -1598,7 +1484,7 @@ var LANG_HINTS = {
1598
1484
  it: "Italian"
1599
1485
  };
1600
1486
  function buildSystemPrompt(langName, mode) {
1601
- const base = `You are a professional translator. Translate the user's message into ${langName}. Output ONLY the translated text \u2014 no explanations, no quotation marks, no extra words. Some fragments of the message are opaque code between the bracket characters U+27E6 and U+27E7. Copy each such fragment into your output character-for-character, keeping its position in the sentence. Never invent such a fragment and never remove one.`;
1487
+ const base = `You are a professional translator. Translate the user's message into ${langName}. If a passage is already in ${langName}, keep it exactly as it is. Output ONLY the translated text \u2014 no explanations, no quotation marks, no extra words. Some fragments of the message are opaque code between the bracket characters U+27E6 and U+27E7. Copy each such fragment into your output character-for-character, keeping its position in the sentence. Never invent such a fragment and never remove one.`;
1602
1488
  if (mode === "plain") return base;
1603
1489
  return base + ` The message is a sequence of parts. Each part starts with its own line holding a marker: an index between the bracket characters U+27EA and U+27EB. For every part, output its marker line unchanged, then that part's translation on the following lines. Translate each part on its own, keep the parts in the original order, and never merge two parts into one answer.`;
1604
1490
  }
@@ -1610,9 +1496,7 @@ var OpenAiCompatibleAdapter = class {
1610
1496
  this.credentials = credentials;
1611
1497
  }
1612
1498
  isAvailable(config) {
1613
- return Boolean(
1614
- config.aiEnabled && config.baseUrl?.trim() && config.model?.trim() && this.credentials.getApiKey()
1615
- );
1499
+ return Boolean(config.baseUrl?.trim() && config.model?.trim() && this.credentials.getApiKey());
1616
1500
  }
1617
1501
  async translate(text, signal, config, options) {
1618
1502
  const apiKey = this.credentials.getApiKey();
@@ -1661,9 +1545,9 @@ var OpenAiCompatibleAdapter = class {
1661
1545
  }
1662
1546
  };
1663
1547
 
1664
- // src/server/pipeline/think.ts
1665
- var THINK_MAX_INPUT_TOKENS = 4096;
1666
- var THINK_MAX_OUTPUT_TOKENS = 8192;
1548
+ // src/server/pipeline/blocks.ts
1549
+ var REPLY_MAX_INPUT_TOKENS = 4096;
1550
+ var REPLY_MAX_OUTPUT_TOKENS = 8192;
1667
1551
  var CJK_CHAR = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/;
1668
1552
  function estimateTokens(text) {
1669
1553
  let cjk = 0;
@@ -1704,7 +1588,7 @@ function hardSlice(text, maxTokens) {
1704
1588
  if (current !== "") out.push(current);
1705
1589
  return out;
1706
1590
  }
1707
- function splitOversizedBlock(text, maxTokens = THINK_MAX_INPUT_TOKENS) {
1591
+ function splitOversizedBlock(text, maxTokens = REPLY_MAX_INPUT_TOKENS) {
1708
1592
  if (estimateTokens(text) <= maxTokens) return [text];
1709
1593
  let chunks = splitKeepingSeparator(text, /(\n{2,})/);
1710
1594
  if (chunks.length <= 1) chunks = splitKeepingSeparator(text, /(\n)/);
@@ -1729,7 +1613,7 @@ function splitOversizedBlock(text, maxTokens = THINK_MAX_INPUT_TOKENS) {
1729
1613
  if (current !== "") out.push(current);
1730
1614
  return out;
1731
1615
  }
1732
- function packPieces(pieces, maxTokens = THINK_MAX_INPUT_TOKENS) {
1616
+ function packPieces(pieces, maxTokens = REPLY_MAX_INPUT_TOKENS) {
1733
1617
  const batches = [];
1734
1618
  let current = [];
1735
1619
  let tokens = 0;
@@ -1746,7 +1630,7 @@ function packPieces(pieces, maxTokens = THINK_MAX_INPUT_TOKENS) {
1746
1630
  if (current.length > 0) batches.push(current);
1747
1631
  return batches;
1748
1632
  }
1749
- function createThinkBatchFormat() {
1633
+ function createBatchFormat() {
1750
1634
  const id = randomTokenId();
1751
1635
  return {
1752
1636
  id,
@@ -1780,170 +1664,44 @@ function splitBatchTranslation(translated, format, count) {
1780
1664
  }
1781
1665
  return parts;
1782
1666
  }
1783
- function hasThinkBatchResidue(text) {
1667
+ function hasBatchResidue(text) {
1784
1668
  return new RegExp(BATCH_TOKEN_PATTERN_SOURCE, "u").test(text);
1785
1669
  }
1786
1670
 
1787
1671
  // src/server/dispatcher.ts
1788
- function thinkPieceKey(piece) {
1672
+ var CHANNEL_ID = "openai";
1673
+ function replyPieceKey(piece) {
1789
1674
  return `${piece.block}:${piece.index}`;
1790
1675
  }
1791
1676
  var TranslationDispatcher = class {
1792
1677
  configManager;
1793
1678
  cache;
1794
- /** 思考正文译文的独立缓存池,与工具标题的池互不挤占。 */
1795
- thinkCache;
1796
1679
  credentials;
1797
1680
  masking = new ContentMaskingPipeline();
1798
1681
  adapters = /* @__PURE__ */ new Map();
1799
1682
  circuitStates = /* @__PURE__ */ new Map();
1800
- inFlightMap = /* @__PURE__ */ new Map();
1801
- activeCount = 0;
1802
- queue = [];
1803
- /** 思考链请求的串行队列尾,保证同时最多一个在途请求。 */
1804
- thinkTail = Promise.resolve();
1805
- constructor(configManager, cache, credentials, thinkCache = new LruDiskCache(THINK_CACHE_ENTRIES, "think-cache.json")) {
1683
+ /** 正文请求的串行队列尾,保证同时最多一个在途请求。 */
1684
+ replyTail = Promise.resolve();
1685
+ constructor(configManager, cache, credentials) {
1806
1686
  this.configManager = configManager;
1807
1687
  this.cache = cache;
1808
- this.thinkCache = thinkCache;
1809
1688
  this.credentials = credentials ?? { getApiKey: () => "" };
1810
1689
  this.registerAdapter(new OpenAiCompatibleAdapter(this.credentials));
1811
- this.registerAdapter(new BingWebAdapter());
1812
- this.configManager.onConfigChange(() => {
1813
- this.processNext();
1814
- });
1815
1690
  }
1816
1691
  registerAdapter(adapter) {
1817
1692
  this.adapters.set(adapter.id, adapter);
1818
1693
  }
1819
1694
  /**
1820
- * Decide which channels are active for the current config, in priority order.
1695
+ * 翻译回答正文的块:本次唯一的翻译入口。
1821
1696
  *
1822
- * Truth table (user contract):
1823
- * - AI on + configured + Bing on -> [openai, bing] (AI first, Bing fallback)
1824
- * - AI on + NOT configured + Bing on -> [bing]
1825
- * - AI on + NOT configured + Bing off -> [] (no translation)
1826
- * - AI off + Bing on -> [bing]
1827
- * - AI off + Bing off -> [] (no translation)
1828
- */
1829
- computeChannels(config) {
1830
- const channels = [];
1831
- for (const [id, adapter] of this.adapters) {
1832
- if (id === "openai") {
1833
- if (config.aiEnabled && config.baseUrl?.trim() && config.model?.trim() && this.credentials.getApiKey()) {
1834
- channels.push(id);
1835
- }
1836
- continue;
1837
- }
1838
- if (id === "bing") {
1839
- if (config.bingEnabled) channels.push(id);
1840
- continue;
1841
- }
1842
- if (adapter.isAvailable(config)) channels.push(id);
1843
- }
1844
- return channels;
1845
- }
1846
- async translateBatch(texts, forceRefresh = false) {
1847
- return Promise.all(texts.map((t) => this.translateOne(t, forceRefresh)));
1848
- }
1849
- async translateOne(rawText, forceRefresh = false) {
1850
- const text = rawText.trim();
1851
- if (!text) {
1852
- return { original: rawText, translated: rawText, channel: "none", cached: true };
1853
- }
1854
- const config = this.configManager.getConfig();
1855
- if (!config.enabled) {
1856
- return { original: rawText, translated: rawText, channel: "disabled", cached: true };
1857
- }
1858
- const cacheKey = text.toLowerCase();
1859
- if (!forceRefresh) {
1860
- const cached = this.cache.get(cacheKey);
1861
- if (cached) {
1862
- return { original: rawText, translated: cached, channel: "cache", cached: true };
1863
- }
1864
- }
1865
- if (!forceRefresh) {
1866
- const inFlight = this.inFlightMap.get(cacheKey);
1867
- if (inFlight) {
1868
- return inFlight;
1869
- }
1870
- }
1871
- const { maskedText, unmask, legacyFragments } = this.masking.mask(text);
1872
- const documentedLegacy = new Set(legacyFragments.map((fragment) => fragment.toLowerCase()));
1873
- const taskPromise = this.enqueueTask(async () => {
1874
- const currentConfig = this.configManager.getConfig();
1875
- const channels = this.computeChannels(currentConfig);
1876
- for (const chId of channels) {
1877
- const adapter = this.adapters.get(chId);
1878
- if (!adapter || !adapter.isAvailable(currentConfig) || this.isCircuitOpen(chId)) {
1879
- continue;
1880
- }
1881
- try {
1882
- const timeout = chId === "openai" ? currentConfig.aiTimeoutMs || 3e4 : currentConfig.timeoutMs || 2e3;
1883
- const abortCtrl = new AbortController();
1884
- const timer = setTimeout(() => abortCtrl.abort(), timeout);
1885
- let translatedMasked = "";
1886
- try {
1887
- translatedMasked = await adapter.translate(maskedText, abortCtrl.signal, currentConfig);
1888
- } finally {
1889
- clearTimeout(timer);
1890
- }
1891
- const cleaned = translatedMasked?.trim();
1892
- if (cleaned && cleaned.length > 0) {
1893
- const finalTranslated = unmask(cleaned);
1894
- const legacyLeftovers = findLegacyMaskTokens(finalTranslated).filter(
1895
- (fragment) => !documentedLegacy.has(fragment.toLowerCase())
1896
- );
1897
- if (hasMaskResidue(finalTranslated) || legacyLeftovers.length > 0) {
1898
- this.recordFailure(chId);
1899
- console.warn(
1900
- `[dsh-chat-translate] channel ${chId} left a mask placeholder in the translation, discarding its result | text: ${text.slice(0, 60)}`
1901
- );
1902
- continue;
1903
- }
1904
- this.recordSuccess(chId);
1905
- this.cache.set(cacheKey, finalTranslated);
1906
- return {
1907
- original: rawText,
1908
- translated: finalTranslated,
1909
- channel: chId,
1910
- cached: false
1911
- };
1912
- }
1913
- this.recordFailure(chId);
1914
- console.warn(
1915
- `[dsh-chat-translate] channel ${chId} returned an empty translation | text: ${text.slice(0, 60)}`
1916
- );
1917
- } catch (err) {
1918
- this.recordFailure(chId);
1919
- const detail = err instanceof MaskRestoreError ? ` (protected fragments expected ${err.expectedCount}, restored ${err.foundCount})` : "";
1920
- console.warn(
1921
- `[dsh-chat-translate] channel ${chId} failed: ${describeError(err)}${detail} | text: ${text.slice(0, 60)}`
1922
- );
1923
- }
1924
- }
1925
- return { original: rawText, translated: rawText, channel: "fallback", cached: false };
1926
- });
1927
- if (!forceRefresh) {
1928
- this.inFlightMap.set(cacheKey, taskPromise);
1929
- }
1930
- try {
1931
- return await taskPromise;
1932
- } finally {
1933
- if (!forceRefresh) {
1934
- this.inFlightMap.delete(cacheKey);
1935
- }
1936
- }
1937
- }
1938
- /**
1939
- * 翻译思考正文的块。整条链路与工具标题翻译分离:只走 AI 通道、独立串行
1940
- * 队列、独立超时、独立缓存池,Bing 从不参与。
1697
+ * 每个块先按输入上限切成片段,再各自掩码、相邻片段打包成一个请求;整批失败
1698
+ * 时退回逐片段单发。调用方传进来的每个块要么整块译出、要么整块保持原文:
1699
+ * 任一片段缺失(含掩码还原不通过)都让该块作废,避免半中半英的段落。
1941
1700
  *
1942
- * 每个块先按输入上限切成片段,再各自掩码、相邻片段打包成一个请求;整批
1943
- * 失败时退回逐片段单发,仍失败的片段保留原文。块内任一片段失败即整块不译,
1944
- * 避免半中半英的段落。
1701
+ * 客户端按 2048 估算 token 分块,所以一个 markdown 块可能跨多次调用;每次
1702
+ * 调用都独立决定成败,不会出现「前一段已挂译文、后一段失败」的半截结果。
1945
1703
  */
1946
- async translateThinkBlocks(blocks) {
1704
+ async translateReplyBlocks(blocks) {
1947
1705
  const results = blocks.map((original) => ({
1948
1706
  original,
1949
1707
  translated: original,
@@ -1952,16 +1710,15 @@ var TranslationDispatcher = class {
1952
1710
  channel: "none"
1953
1711
  }));
1954
1712
  const config = this.configManager.getConfig();
1955
- if (!config.enabled || !config.thinkEnabled) return results;
1956
- const adapter = this.adapters.get("openai");
1957
- if (!adapter || !adapter.isAvailable(config) || this.isChannelCoolingDown("openai")) {
1958
- return results;
1959
- }
1713
+ if (!config.enabled) return results;
1714
+ const adapter = this.adapters.get(CHANNEL_ID);
1715
+ if (!adapter || !adapter.isAvailable(config)) return results;
1716
+ if (this.isCircuitOpen(CHANNEL_ID)) return results;
1960
1717
  const pieces = [];
1961
1718
  for (let block = 0; block < blocks.length; block++) {
1962
1719
  const text = (blocks[block] ?? "").trim();
1963
1720
  if (!text) continue;
1964
- const cached = this.thinkCache.get(text.toLowerCase());
1721
+ const cached = this.cache.get(text.toLowerCase());
1965
1722
  if (cached) {
1966
1723
  results[block] = {
1967
1724
  original: blocks[block],
@@ -1985,8 +1742,8 @@ var TranslationDispatcher = class {
1985
1742
  }
1986
1743
  const translated = /* @__PURE__ */ new Map();
1987
1744
  for (const batch of packPieces(pieces)) {
1988
- const outcome = await this.runThinkSerial(
1989
- () => this.translateThinkBatch(adapter, batch, config)
1745
+ const outcome = await this.runReplySerial(
1746
+ () => this.translateReplyBatch(adapter, batch, config)
1990
1747
  );
1991
1748
  for (const [key, value] of outcome) translated.set(key, value);
1992
1749
  }
@@ -2006,40 +1763,53 @@ var TranslationDispatcher = class {
2006
1763
  if (!complete) continue;
2007
1764
  const original = blocks[block];
2008
1765
  const finalText = parts.join("");
2009
- this.thinkCache.set(original.trim().toLowerCase(), finalText);
2010
- results[block] = { original, translated: finalText, ok: true, cached: false, channel: "openai" };
1766
+ this.cache.set(original.trim().toLowerCase(), finalText);
1767
+ results[block] = {
1768
+ original,
1769
+ translated: finalText,
1770
+ ok: true,
1771
+ cached: false,
1772
+ channel: CHANNEL_ID
1773
+ };
2011
1774
  }
2012
1775
  return results;
2013
1776
  }
2014
1777
  /**
2015
1778
  * 一整批一次请求;失败则该批逐片段单发重试一次,仍失败的片段直接放弃
2016
- * (保留原文),不在界面上留提示。
1779
+ * (保留原文),不在界面上留提示。失败的片段不进缓存,因此下一次滚回视口
1780
+ * 时会自然重试。
1781
+ *
1782
+ * 熔断记账按「批」算:一次批请求失败记一次,逐片段重试失败再各记一次。
2017
1783
  */
2018
- async translateThinkBatch(adapter, batch, config) {
1784
+ async translateReplyBatch(adapter, batch, config) {
2019
1785
  try {
2020
- return await this.requestThinkBatch(adapter, batch, config);
1786
+ const outcome = await this.requestReplyBatch(adapter, batch, config);
1787
+ this.recordSuccess(CHANNEL_ID);
1788
+ return outcome;
2021
1789
  } catch (err) {
1790
+ this.recordFailure(CHANNEL_ID);
2022
1791
  console.warn(
2023
- `[dsh-chat-translate] think batch of ${batch.length} failed, retrying per block: ${describeError(err)}`
1792
+ `[dsh-chat-translate] reply batch of ${batch.length} failed, retrying per block: ${describeError(err)}`
2024
1793
  );
2025
1794
  }
2026
1795
  const out = /* @__PURE__ */ new Map();
2027
1796
  for (const piece of batch) {
2028
1797
  try {
2029
- for (const [key, value] of await this.requestThinkBatch(adapter, [piece], config)) {
1798
+ for (const [key, value] of await this.requestReplyBatch(adapter, [piece], config)) {
2030
1799
  out.set(key, value);
2031
1800
  }
2032
1801
  } catch (err) {
1802
+ this.recordFailure(CHANNEL_ID);
2033
1803
  console.warn(
2034
- `[dsh-chat-translate] think block ${piece.block} #${piece.index} failed, keeping the original: ${describeError(err)}`
1804
+ `[dsh-chat-translate] reply block ${piece.block} #${piece.index} failed, keeping the original: ${describeError(err)}`
2035
1805
  );
2036
1806
  }
2037
1807
  }
2038
1808
  return out;
2039
1809
  }
2040
- /** 发出一次思考链请求并把结果还原成每个片段的最终译文。 */
2041
- async requestThinkBatch(adapter, batch, config) {
2042
- const timeout = config.thinkTimeoutMs || 6e5;
1810
+ /** 发出一次正文请求并把结果还原成每个片段的最终译文。 */
1811
+ async requestReplyBatch(adapter, batch, config) {
1812
+ const timeout = config.aiTimeoutMs || 6e5;
2043
1813
  const abortCtrl = new AbortController();
2044
1814
  const timer = setTimeout(() => abortCtrl.abort(), timeout);
2045
1815
  let answers;
@@ -2047,21 +1817,21 @@ var TranslationDispatcher = class {
2047
1817
  if (batch.length === 1) {
2048
1818
  answers = [
2049
1819
  await adapter.translate(batch[0].text, abortCtrl.signal, config, {
2050
- maxTokens: THINK_MAX_OUTPUT_TOKENS,
1820
+ maxTokens: REPLY_MAX_OUTPUT_TOKENS,
2051
1821
  mode: "plain"
2052
1822
  })
2053
1823
  ];
2054
1824
  } else {
2055
- const format = createThinkBatchFormat();
1825
+ const format = createBatchFormat();
2056
1826
  const answer = await adapter.translate(
2057
1827
  buildBatchPayload(batch.map((piece) => piece.text), format),
2058
1828
  abortCtrl.signal,
2059
1829
  config,
2060
- { maxTokens: THINK_MAX_OUTPUT_TOKENS, mode: "blocks" }
1830
+ { maxTokens: REPLY_MAX_OUTPUT_TOKENS, mode: "blocks" }
2061
1831
  );
2062
1832
  const parts = splitBatchTranslation(answer, format, batch.length);
2063
1833
  if (parts === null) {
2064
- throw new Error("think block markers did not survive the translation");
1834
+ throw new Error("reply block markers did not survive the translation");
2065
1835
  }
2066
1836
  answers = parts;
2067
1837
  }
@@ -2072,39 +1842,38 @@ var TranslationDispatcher = class {
2072
1842
  batch.forEach((piece, index) => {
2073
1843
  const answer = (answers[index] ?? "").trim();
2074
1844
  if (!answer) {
2075
- throw new Error(`think block ${piece.block} #${piece.index} came back empty`);
1845
+ throw new Error(`reply block ${piece.block} #${piece.index} came back empty`);
2076
1846
  }
2077
- if (hasThinkBatchResidue(answer)) {
2078
- throw new Error("think translation left a block marker behind");
1847
+ if (hasBatchResidue(answer)) {
1848
+ throw new Error("reply translation left a block marker behind");
2079
1849
  }
2080
1850
  const finalText = piece.mask.unmask(answer);
2081
1851
  const leftovers = findLegacyMaskTokens(finalText).filter(
2082
1852
  (fragment) => !piece.legacy.has(fragment.toLowerCase())
2083
1853
  );
2084
1854
  if (hasMaskResidue(finalText) || leftovers.length > 0) {
2085
- throw new Error("think translation left a mask placeholder behind");
1855
+ throw new Error("reply translation left a mask placeholder behind");
2086
1856
  }
2087
- out.set(thinkPieceKey(piece), finalText);
1857
+ out.set(replyPieceKey(piece), finalText);
2088
1858
  });
2089
1859
  return out;
2090
1860
  }
2091
- /** 串行执行器:同时最多一个在途请求,与工具标题的并发池完全独立。 */
2092
- runThinkSerial(task) {
2093
- const next = this.thinkTail.then(task, task);
2094
- this.thinkTail = next.then(
1861
+ /** 串行执行器:同时最多一个在途请求。 */
1862
+ runReplySerial(task) {
1863
+ const next = this.replyTail.then(task, task);
1864
+ this.replyTail = next.then(
2095
1865
  () => void 0,
2096
1866
  () => void 0
2097
1867
  );
2098
1868
  return next;
2099
1869
  }
2100
1870
  /**
2101
- * 只读判断某通道是否还在熔断冷却期。思考链翻译只借用这个判断避开已经
2102
- * 出问题的服务,不改动熔断状态本身(那是工具标题通道的记账)。
1871
+ * 通道探针:设置面板的「测试 AI 通道」按钮走这里。探测文本用一句英文正文,
1872
+ * 与真实请求同形。
1873
+ *
1874
+ * 探针是用户主动发起的连通性检查,因此不写也不读熔断账本:连点几次测试
1875
+ * 不该把正文翻译挡在冷却期外,正文的失败也不该让按钮变哑。
2103
1876
  */
2104
- isChannelCoolingDown(channelId) {
2105
- const state = this.circuitStates.get(channelId);
2106
- return state !== void 0 && state.state === "open" && Date.now() < state.openUntil;
2107
- }
2108
1877
  async testChannel(channelId) {
2109
1878
  const adapter = this.adapters.get(channelId);
2110
1879
  const config = this.configManager.getConfig();
@@ -2114,10 +1883,10 @@ var TranslationDispatcher = class {
2114
1883
  if (!adapter.isAvailable(config)) {
2115
1884
  return { ok: false, latencyMs: 0, error: `Channel ${channelId} is not configured or disabled` };
2116
1885
  }
2117
- const testText = "List files in current directory";
1886
+ const testText = "Here is what I changed.";
2118
1887
  const start = Date.now();
2119
1888
  try {
2120
- const timeout = channelId === "openai" ? Math.min(config.aiTimeoutMs || 3e4, 3e4) : 4e3;
1889
+ const timeout = Math.min(config.aiTimeoutMs || 3e4, 3e4);
2121
1890
  const abortCtrl = new AbortController();
2122
1891
  const timer = setTimeout(() => abortCtrl.abort(), timeout);
2123
1892
  let res = "";
@@ -2135,43 +1904,6 @@ var TranslationDispatcher = class {
2135
1904
  return { ok: false, latencyMs: Date.now() - start, error: describeError(err) };
2136
1905
  }
2137
1906
  }
2138
- enqueueTask(task) {
2139
- return new Promise((resolve3, reject) => {
2140
- const exec = async () => {
2141
- this.activeCount++;
2142
- try {
2143
- const result = await task();
2144
- resolve3(result);
2145
- } catch (err) {
2146
- reject(err);
2147
- } finally {
2148
- this.activeCount--;
2149
- this.processNext();
2150
- }
2151
- };
2152
- const maxConcurrency = Math.min(
2153
- Math.max(this.configManager.getConfig().concurrency || 3, 1),
2154
- MAX_CONCURRENCY
2155
- );
2156
- if (this.activeCount < maxConcurrency) {
2157
- exec();
2158
- } else {
2159
- this.queue.push(exec);
2160
- }
2161
- });
2162
- }
2163
- processNext() {
2164
- const maxConcurrency = Math.min(
2165
- Math.max(this.configManager.getConfig().concurrency || 3, 1),
2166
- MAX_CONCURRENCY
2167
- );
2168
- while (this.queue.length > 0 && this.activeCount < maxConcurrency) {
2169
- const next = this.queue.shift();
2170
- if (next) {
2171
- next();
2172
- }
2173
- }
2174
- }
2175
1907
  isCircuitOpen(channelId) {
2176
1908
  let state = this.circuitStates.get(channelId);
2177
1909
  if (!state) return false;
@@ -2221,9 +1953,8 @@ var TranslationDispatcher = class {
2221
1953
  };
2222
1954
 
2223
1955
  // src/server/router.ts
2224
- var TRANSLATE_ROUTE_PATH = "/api/dsh-chat-translate/translate";
1956
+ var REPLY_ROUTE_PATH = "/api/dsh-chat-translate/translate-reply";
2225
1957
  var TEST_CHANNEL_ROUTE_PATH = "/api/dsh-chat-translate/test-channel";
2226
- var THINK_ROUTE_PATH = "/api/dsh-chat-translate/translate-think";
2227
1958
  function sendJson(status, body) {
2228
1959
  return new Response(JSON.stringify(body), {
2229
1960
  status,
@@ -2238,36 +1969,8 @@ async function readJson(request) {
2238
1969
  }
2239
1970
  }
2240
1971
  function createFetchRoutes(dispatcher, ready = Promise.resolve()) {
2241
- const translate = {
2242
- path: TRANSLATE_ROUTE_PATH,
2243
- methods: ["POST"],
2244
- requestBody: "buffered",
2245
- fetch: async (request) => {
2246
- await ready;
2247
- try {
2248
- const body = await readJson(request);
2249
- if (!body.ok) return body.response;
2250
- const parsed = body.value;
2251
- const rawTexts = parsed.texts !== void 0 ? parsed.texts : parsed.text;
2252
- let texts = [];
2253
- if (Array.isArray(rawTexts)) {
2254
- texts = rawTexts.filter((t) => typeof t === "string");
2255
- } else if (typeof rawTexts === "string") {
2256
- texts = [rawTexts];
2257
- }
2258
- const forceRefresh = Boolean(parsed.forceRefresh);
2259
- if (texts.length === 0) {
2260
- return sendJson(200, { ok: true, results: [] });
2261
- }
2262
- const results = await dispatcher.translateBatch(texts, forceRefresh);
2263
- return sendJson(200, { ok: true, results });
2264
- } catch (err) {
2265
- return sendJson(500, { ok: false, error: describeError(err) });
2266
- }
2267
- }
2268
- };
2269
- const think = {
2270
- path: THINK_ROUTE_PATH,
1972
+ const reply = {
1973
+ path: REPLY_ROUTE_PATH,
2271
1974
  methods: ["POST"],
2272
1975
  requestBody: "buffered",
2273
1976
  fetch: async (request) => {
@@ -2280,7 +1983,7 @@ function createFetchRoutes(dispatcher, ready = Promise.resolve()) {
2280
1983
  if (blocks.length === 0) {
2281
1984
  return sendJson(200, { ok: true, results: [] });
2282
1985
  }
2283
- const results = await dispatcher.translateThinkBlocks(blocks);
1986
+ const results = await dispatcher.translateReplyBlocks(blocks);
2284
1987
  return sendJson(200, { ok: true, results });
2285
1988
  } catch (err) {
2286
1989
  return sendJson(500, { ok: false, error: describeError(err) });
@@ -2304,7 +2007,7 @@ function createFetchRoutes(dispatcher, ready = Promise.resolve()) {
2304
2007
  }
2305
2008
  }
2306
2009
  };
2307
- return [translate, think, testChannel];
2010
+ return [reply, testChannel];
2308
2011
  }
2309
2012
 
2310
2013
  // src/index.ts
@@ -2312,13 +2015,7 @@ var name = "dsh-chat-translate";
2312
2015
  var inject = ["settings", "credentials"];
2313
2016
  var Config = Schema.object({
2314
2017
  enabled: Schema.boolean().default(DEFAULT_CONFIG.enabled).volatile(),
2315
- concurrency: Schema.number().min(1).max(MAX_CONCURRENCY).default(DEFAULT_CONFIG.concurrency).volatile(),
2316
- timeoutMs: Schema.number().min(500).max(1e4).default(DEFAULT_CONFIG.timeoutMs).volatile(),
2317
2018
  aiTimeoutMs: Schema.number().min(AI_TIMEOUT_MIN).max(AI_TIMEOUT_MAX).default(DEFAULT_CONFIG.aiTimeoutMs).volatile(),
2318
- thinkTimeoutMs: Schema.number().min(THINK_TIMEOUT_MIN).max(THINK_TIMEOUT_MAX).default(DEFAULT_CONFIG.thinkTimeoutMs).volatile(),
2319
- aiEnabled: Schema.boolean().default(DEFAULT_CONFIG.aiEnabled).volatile(),
2320
- bingEnabled: Schema.boolean().default(DEFAULT_CONFIG.bingEnabled).volatile(),
2321
- thinkEnabled: Schema.boolean().default(DEFAULT_CONFIG.thinkEnabled).volatile(),
2322
2019
  baseUrl: Schema.string().default(DEFAULT_CONFIG.baseUrl).volatile(),
2323
2020
  model: Schema.string().default(DEFAULT_CONFIG.model).volatile(),
2324
2021
  targetLang: Schema.string().default(DEFAULT_CONFIG.targetLang).volatile()
@@ -2332,8 +2029,7 @@ function apply(ctx, config) {
2332
2029
  credentials
2333
2030
  );
2334
2031
  const cache = new LruDiskCache(1e3);
2335
- const thinkCache = new LruDiskCache(THINK_CACHE_ENTRIES, "think-cache.json");
2336
- const dispatcher = new TranslationDispatcher(configManager, cache, credentials, thinkCache);
2032
+ const dispatcher = new TranslationDispatcher(configManager, cache, credentials);
2337
2033
  ctx.inject(["settings"], (child) => {
2338
2034
  child.effect(
2339
2035
  () => child.settings.configure({ auto: false }, child.fiber),
@@ -2344,8 +2040,9 @@ function apply(ctx, config) {
2344
2040
  const initPromise = Promise.all([
2345
2041
  credentials.init(),
2346
2042
  cache.init(),
2347
- thinkCache.init(),
2348
- migrateLegacyConfigFile(ctx.settings, legacyConfigPath)
2043
+ migrateLegacyConfigFile(ctx.settings, legacyConfigPath),
2044
+ retireRemovedConfigKeys(ctx.settings),
2045
+ retireStoreFiles(["think-cache.json"])
2349
2046
  ]).catch((err) => {
2350
2047
  console.warn("[dsh-chat-translate] Initialization error:", err);
2351
2048
  });
@@ -2359,7 +2056,7 @@ function apply(ctx, config) {
2359
2056
  (route) => connectionCtx.connection.fetch.register(route)
2360
2057
  );
2361
2058
  return () => {
2362
- void Promise.all(disposers.map((dispose) => dispose())).then(() => Promise.all([cache.dispose(), thinkCache.dispose()])).catch((err) => {
2059
+ void Promise.all(disposers.map((dispose) => dispose())).then(() => cache.dispose()).catch((err) => {
2363
2060
  console.warn("[dsh-chat-translate] Dispose translation routes error:", err);
2364
2061
  });
2365
2062
  };