@lynn123411/dsh-chat-translate 1.3.2 → 1.7.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.
Files changed (32) hide show
  1. package/README.md +14 -9
  2. package/docs//351/205/215/347/275/256.md +11 -1
  3. package/lib/client.js +1527 -489
  4. package/lib/client.js.map +4 -4
  5. package/lib/index.js +565 -173
  6. package/lib/index.js.map +4 -4
  7. package/lib/types/client/index.d.ts +7 -5
  8. package/lib/types/client/locales.d.ts +51 -4
  9. package/lib/types/client/reading-settings-page.d.ts +1 -1
  10. package/lib/types/client/settings/store.d.ts +17 -4
  11. package/lib/types/client/settings/ui.d.ts +5 -3
  12. package/lib/types/client/translate/api.d.ts +13 -0
  13. package/lib/types/client/translate/client-cache.d.ts +6 -1
  14. package/lib/types/client/translate/hold.d.ts +135 -0
  15. package/lib/types/client/translate/lazy.d.ts +8 -0
  16. package/lib/types/client/translate/mount.d.ts +22 -0
  17. package/lib/types/client/translate/observer.d.ts +57 -0
  18. package/lib/types/client/translate/think-button.d.ts +19 -0
  19. package/lib/types/client/translate/think.d.ts +55 -0
  20. package/lib/types/client/translate/viewport-observer.d.ts +14 -2
  21. package/lib/types/index.d.ts +11 -18
  22. package/lib/types/server/adapters/base.d.ts +1 -1
  23. package/lib/types/server/adapters/openai.d.ts +2 -2
  24. package/lib/types/server/cache.d.ts +5 -1
  25. package/lib/types/server/config.d.ts +35 -7
  26. package/lib/types/server/dispatcher.d.ts +30 -3
  27. package/lib/types/server/pipeline/mask-tokens.d.ts +54 -48
  28. package/lib/types/server/pipeline/masking.d.ts +35 -12
  29. package/lib/types/server/pipeline/think.d.ts +49 -0
  30. package/lib/types/server/router.d.ts +24 -3
  31. package/lib/types/server/types.d.ts +18 -1
  32. package/package.json +23 -10
package/lib/index.js CHANGED
@@ -786,7 +786,7 @@ defineMethod("transform", [
786
786
  "preserve"
787
787
  ], ({ inner }, isInner) => inner.toString(isInner));
788
788
 
789
- // node_modules/.pnpm/@deepseek-ai+dsh-home-paths@0.1.5-rc.1_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-home-paths/lib/index.js
789
+ // node_modules/.pnpm/@deepseek-ai+dsh-home-paths@0.1.6-alpha.2_@deepseek-ai+cordis@4.0.2/node_modules/@deepseek-ai/dsh-home-paths/lib/index.js
790
790
  import { homedir } from "node:os";
791
791
  import { basename, dirname, join, resolve as resolve2 } from "node:path";
792
792
  var DSH_HOME_DIR_NAME = ".dsh";
@@ -813,14 +813,19 @@ import * as fs from "node:fs/promises";
813
813
  var MAX_CONCURRENCY = 100;
814
814
  var AI_TIMEOUT_MIN = 500;
815
815
  var AI_TIMEOUT_MAX = 12e4;
816
+ var THINK_TIMEOUT_MIN = 500;
817
+ var THINK_TIMEOUT_MAX = 9e5;
818
+ var THINK_CACHE_ENTRIES = 300;
816
819
  var SETTINGS_NAMESPACE = "dsh-chat-translate";
817
820
  var DEFAULT_CONFIG = {
818
821
  enabled: true,
819
822
  concurrency: 3,
820
823
  timeoutMs: 2e3,
821
824
  aiTimeoutMs: 3e4,
825
+ thinkTimeoutMs: 6e5,
822
826
  aiEnabled: true,
823
827
  bingEnabled: true,
828
+ thinkEnabled: true,
824
829
  baseUrl: "",
825
830
  model: "",
826
831
  targetLang: "zh-Hans"
@@ -865,6 +870,7 @@ function sanitizePatch(input) {
865
870
  if (typeof input.enabled === "boolean") next.enabled = input.enabled;
866
871
  if (typeof input.aiEnabled === "boolean") next.aiEnabled = input.aiEnabled;
867
872
  if (typeof input.bingEnabled === "boolean") next.bingEnabled = input.bingEnabled;
873
+ if (typeof input.thinkEnabled === "boolean") next.thinkEnabled = input.thinkEnabled;
868
874
  if (typeof input.concurrency === "number" && Number.isFinite(input.concurrency)) {
869
875
  next.concurrency = Math.min(Math.max(Math.round(input.concurrency), 1), MAX_CONCURRENCY);
870
876
  }
@@ -877,6 +883,12 @@ function sanitizePatch(input) {
877
883
  AI_TIMEOUT_MAX
878
884
  );
879
885
  }
886
+ if (typeof input.thinkTimeoutMs === "number" && Number.isFinite(input.thinkTimeoutMs)) {
887
+ next.thinkTimeoutMs = Math.min(
888
+ Math.max(Math.round(input.thinkTimeoutMs), THINK_TIMEOUT_MIN),
889
+ THINK_TIMEOUT_MAX
890
+ );
891
+ }
880
892
  if (typeof input.baseUrl === "string") next.baseUrl = input.baseUrl.trim();
881
893
  if (typeof input.model === "string") next.model = input.model.trim();
882
894
  if (typeof input.targetLang === "string" && input.targetLang.trim()) {
@@ -912,13 +924,18 @@ async function migrateLegacyConfigFile(settings, legacyPath) {
912
924
  return false;
913
925
  }
914
926
  const patch = sanitizePatch(record);
915
- if (Object.keys(patch).length === 0) {
927
+ const ops = Object.entries(patch).map(([field, value]) => ({
928
+ op: "set",
929
+ path: [field],
930
+ value
931
+ }));
932
+ if (ops.length === 0) {
916
933
  await fs.unlink(legacyPath).catch(() => {
917
934
  });
918
935
  return false;
919
936
  }
920
937
  try {
921
- await settings.update(SETTINGS_NAMESPACE, patch);
938
+ await settings.mutate(SETTINGS_NAMESPACE, ops, descriptor?.revision);
922
939
  } catch (err) {
923
940
  console.warn("[dsh-chat-translate] Legacy config migration failed; will retry on next boot:", err);
924
941
  return false;
@@ -1010,117 +1027,180 @@ function createMaskTokenFormat() {
1010
1027
  const id = randomTokenId();
1011
1028
  return {
1012
1029
  id,
1013
- token: (index) => `__DSHMASKx${id}_${index}__`
1030
+ token: (index) => `\u27E6${id}${index}\u27E7`
1014
1031
  };
1015
1032
  }
1016
- var MASK_TOKEN_PATTERN_SOURCE = "_*\\s*DSH\\s*_*\\s*MASK\\s*(?:_?\\s*(\\d+)|_*[xX]?[\\s._-]*([a-z]{2,8})\\s*_+\\s*(\\d+))(?:_{0,2}(?=[^\\w]|$))?";
1033
+ var MASK_TOKEN_PATTERN_SOURCE = "\u27E6([a-z]{4})(\\d+)\u27E7";
1034
+ var MASK_TOKEN_PREFIX_PATTERN_SOURCE = "\u27E6\\s*([a-z]{4})(\\d+)";
1035
+ var LEGACY_MASK_PATTERN_SOURCE = "_{1,2}DSH\\s*_*\\s*MASKx?\\s*(?:_?\\s*\\d+|_*([a-z]{2,8})\\s*_+\\s*(\\d+))_{0,2}";
1036
+ var LEGACY_MASK_TOKEN_PATTERN_SOURCE = LEGACY_MASK_PATTERN_SOURCE;
1017
1037
  function matchMaskToken(text, start, id) {
1018
- const anchored = new RegExp(`^(?:${MASK_TOKEN_PATTERN_SOURCE})`, "i").exec(text.slice(start));
1038
+ const complete = new RegExp(`^(?:${MASK_TOKEN_PATTERN_SOURCE})`, "iu").exec(text.slice(start));
1039
+ const truncated = complete ? null : new RegExp(`^(?:${MASK_TOKEN_PREFIX_PATTERN_SOURCE})`, "iu").exec(text.slice(start));
1040
+ const anchored = complete ?? truncated;
1019
1041
  if (!anchored) return null;
1020
- const index = Number.parseInt(anchored[1] ?? anchored[3] ?? "", 10);
1042
+ if (anchored[1].toLowerCase() !== id.toLowerCase()) return null;
1043
+ const index = Number.parseInt(anchored[2], 10);
1021
1044
  if (Number.isNaN(index)) return null;
1022
- const tokenId = anchored[2];
1023
- const acceptsId = tokenId === void 0 || tokenId.toLowerCase() === id.toLowerCase();
1024
- return { index, acceptsId, length: anchored[0].length };
1045
+ return { index, length: anchored[0].length, truncated: complete === null };
1025
1046
  }
1026
1047
  function hasMaskResidue(text) {
1027
1048
  if (!text) return false;
1028
- return new RegExp(MASK_TOKEN_PATTERN_SOURCE, "i").test(text);
1049
+ return new RegExp(MASK_TOKEN_PATTERN_SOURCE, "iu").test(text);
1029
1050
  }
1030
- function maskResidues(text) {
1031
- if (!text) return [];
1032
- return text.match(new RegExp(MASK_TOKEN_PATTERN_SOURCE, "gi")) ?? [];
1051
+ function hasLegacyMaskResidue(text) {
1052
+ if (!text) return false;
1053
+ return new RegExp(LEGACY_MASK_PATTERN_SOURCE, "iu").test(text);
1033
1054
  }
1034
- function hasNewMaskResidue(originalText, translatedText) {
1035
- const residues = maskResidues(translatedText);
1036
- if (residues.length === 0) return false;
1037
- const presentInSource = /* @__PURE__ */ new Map();
1038
- for (const fragment of maskResidues(originalText)) {
1039
- const key = fragment.toLowerCase();
1040
- presentInSource.set(key, (presentInSource.get(key) ?? 0) + 1);
1041
- }
1042
- for (const fragment of residues) {
1043
- const key = fragment.toLowerCase();
1044
- const remaining = presentInSource.get(key) ?? 0;
1045
- if (remaining === 0) return true;
1046
- presentInSource.set(key, remaining - 1);
1047
- }
1048
- return false;
1055
+ function hasAnyMaskResidue(text) {
1056
+ return hasMaskResidue(text) || hasLegacyMaskResidue(text);
1057
+ }
1058
+ function findLegacyMaskTokens(text) {
1059
+ if (!text) return [];
1060
+ return text.match(new RegExp(LEGACY_MASK_TOKEN_PATTERN_SOURCE, "giu")) ?? [];
1049
1061
  }
1050
1062
 
1051
1063
  // src/server/pipeline/masking.ts
1052
- var MASK_MARKER = /DSH\s*_?\s*MASK/i;
1053
1064
  var MAX_UNMASK_PASSES = 1e4;
1065
+ var ASCII_WORD = /[A-Za-z0-9_]/;
1066
+ var INSERTED_SPACE = /^ | $/;
1067
+ var MaskRestoreError = class extends Error {
1068
+ expectedCount;
1069
+ foundCount;
1070
+ constructor(message, expectedCount, foundCount) {
1071
+ super(message);
1072
+ this.name = "MaskRestoreError";
1073
+ this.expectedCount = expectedCount;
1074
+ this.foundCount = foundCount;
1075
+ }
1076
+ };
1054
1077
  var ContentMaskingPipeline = class {
1055
1078
  mask(text) {
1056
1079
  if (!text || typeof text !== "string") {
1057
1080
  return {
1058
1081
  maskedText: text,
1059
- unmask: (t) => t
1082
+ unmask: (t) => t,
1083
+ legacyFragments: []
1060
1084
  };
1061
1085
  }
1062
1086
  const masks = [];
1087
+ const legacyIndexes = /* @__PURE__ */ new Set();
1088
+ const inserted = [];
1063
1089
  const format = createMaskTokenFormat();
1064
- const addMask = (match) => {
1065
- if (MASK_MARKER.test(match)) return match;
1066
- const idx = masks.length;
1090
+ const addFragment = (match, before, after, onPlace) => {
1091
+ if (hasMaskResidue(match)) return match;
1092
+ const index = masks.length;
1067
1093
  masks.push(match);
1068
- return format.token(idx);
1094
+ const leading = before !== void 0 && ASCII_WORD.test(before);
1095
+ const trailing = after !== void 0 && ASCII_WORD.test(after);
1096
+ inserted.push({ leading, trailing });
1097
+ onPlace?.(index);
1098
+ return `${leading ? " " : ""}${format.token(index)}${trailing ? " " : ""}`;
1069
1099
  };
1070
- let processed = text;
1071
- processed = processed.replace(/(?:```|~~~)[\s\S]*?(?:```|~~~)/g, (m) => addMask(m));
1072
- processed = processed.replace(/`[^`\n]+`/g, (m) => addMask(m));
1073
- processed = processed.replace(/https?:\/\/[^\s)\];,;"'<>]+/g, (m) => addMask(m));
1074
- processed = processed.replace(
1075
- /(?:\/[\w.\-\\\/]+|(?<=[\w.\-])\.\.?[\\\/][\w.\-\\\/]+|(?<=^|[\s([{"'`])[a-zA-Z]:[\\\/][\w.\-\\\/]+|(?<![^\s([{"'`])\b(?:[\w.\-]+\/)+[\w.\-]+\.[a-zA-Z0-9]+\b|(?<![^\s([{"'`])\b[\w.\-]+\.(?:ts|tsx|js|jsx|json|ya?ml|md|py|go|rs|c|cpp|h|hpp|css|scss|html|sh|bash|mjs|cjs|toml|lock|log|env|svg|png|jpe?g|gif|tar|gz|zip|xml|sql)\b)/g,
1076
- (m) => addMask(m)
1100
+ const wrapRule = (input, pattern2, onPlace) => input.replace(pattern2, (...args) => {
1101
+ const match = args[0];
1102
+ const offset = args[args.length - 2];
1103
+ const whole = args[args.length - 1];
1104
+ return addFragment(match, whole[offset - 1], whole[offset + match.length], onPlace);
1105
+ });
1106
+ let processed = wrapRule(
1107
+ text,
1108
+ new RegExp(LEGACY_MASK_TOKEN_PATTERN_SOURCE, "giu"),
1109
+ (index) => legacyIndexes.add(index)
1110
+ );
1111
+ processed = wrapRule(processed, /(?:```|~~~)[\s\S]*?(?:```|~~~)/g);
1112
+ processed = wrapRule(processed, /`[^`\n]+`/g);
1113
+ processed = wrapRule(processed, /https?:\/\/[^\s)\];,;"'<>]+/g);
1114
+ processed = wrapRule(
1115
+ processed,
1116
+ /(?:\/[\w.\-\\\/]+|(?<=[\w.\-])\.\.?[\\\/][\w.\-\\\/]+|(?<=^|[\s([{"'`])[a-zA-Z]:[\\\/][\w.\-\\\/]+|(?<![^\s([{"'`])\b(?:[\w.\-]+\/)+[\w.\-]+\.[a-zA-Z0-9]+\b|(?<![^\s([{"'`])\b[\w.\-]+\.(?:ts|tsx|js|jsx|json|ya?ml|md|py|go|rs|c|cpp|h|hpp|css|scss|html|sh|bash|mjs|cjs|toml|lock|log|env|svg|png|jpe?g|gif|tar|gz|zip|xml|sql)\b)/g
1077
1117
  );
1078
- processed = processed.replace(
1079
- /(?<=^|[\s(\[{"'])((?:--[a-zA-Z0-9_\-]+(?:=[^\s"'<>]+)?)|(?:-[a-zA-Z0-9]+))(?=[\s)\]}",:;!?]|$)/g,
1080
- (m) => addMask(m)
1118
+ processed = wrapRule(
1119
+ processed,
1120
+ /(?<=^|[\s(\[{"'])((?:--[a-zA-Z0-9_\-]+(?:=[^\s"'<>]+)?)|(?:-[a-zA-Z0-9]+))(?=[\s)\]}",:;!?]|$)/g
1081
1121
  );
1082
1122
  const unmask = (translatedText) => {
1083
- if (!translatedText || masks.length === 0) {
1123
+ if (!translatedText) return translatedText;
1124
+ if (masks.length === 0) {
1125
+ if (hasAnyMaskResidue(translatedText)) {
1126
+ throw new MaskRestoreError(
1127
+ "translation carries a mask token while the source had none",
1128
+ 0,
1129
+ 0
1130
+ );
1131
+ }
1084
1132
  return translatedText;
1085
1133
  }
1086
- return replaceMaskTokens(
1087
- translatedText,
1088
- format.id,
1089
- (index) => index >= 0 && index < masks.length ? masks[index] : void 0
1090
- );
1134
+ return replaceMaskTokens(translatedText, format.id, masks, inserted, legacyIndexes);
1091
1135
  };
1092
1136
  return {
1093
1137
  maskedText: processed,
1094
- unmask
1138
+ unmask,
1139
+ legacyFragments: [...legacyIndexes].map((index) => masks[index]).filter((f) => f !== void 0)
1095
1140
  };
1096
1141
  }
1097
1142
  };
1098
- function replaceMaskTokens(text, id, resolve3) {
1099
- const pattern2 = new RegExp(MASK_TOKEN_PATTERN_SOURCE, "i");
1143
+ function replaceMaskTokens(text, id, masks, inserted, legacyIndexes = /* @__PURE__ */ new Set()) {
1144
+ const complete = new RegExp(MASK_TOKEN_PATTERN_SOURCE, "giu");
1145
+ const prefix = new RegExp(MASK_TOKEN_PREFIX_PATTERN_SOURCE, "giu");
1146
+ const seen = /* @__PURE__ */ new Set();
1100
1147
  let out = "";
1101
1148
  let cursor = 0;
1102
1149
  let passes = 0;
1103
- while (cursor < text.length && passes++ < MAX_UNMASK_PASSES) {
1104
- const candidate = pattern2.exec(text.slice(cursor));
1105
- if (!candidate) break;
1106
- const start = cursor + candidate.index;
1150
+ let whole = complete.exec(text);
1151
+ let prefixCandidate = prefix.exec(text);
1152
+ const nextStart = (wholeMatch, prefixMatch) => Math.min(
1153
+ wholeMatch ? wholeMatch.index : Number.POSITIVE_INFINITY,
1154
+ prefixMatch ? prefixMatch.index : Number.POSITIVE_INFINITY
1155
+ );
1156
+ let start = nextStart(whole, prefixCandidate);
1157
+ while (start !== Number.POSITIVE_INFINITY && passes++ < MAX_UNMASK_PASSES) {
1158
+ const isPrefix = prefixCandidate !== null && prefixCandidate.index === start && whole?.index !== start;
1159
+ const raw = isPrefix ? prefixCandidate[0] : whole[0];
1107
1160
  const match = matchMaskToken(text, start, id);
1108
- const length = match?.length ?? candidate[0].length;
1109
- if (length <= 0) {
1110
- cursor = start + 1;
1111
- continue;
1161
+ if (!match) {
1162
+ out += text.slice(cursor, start) + raw;
1163
+ cursor = start + raw.length;
1164
+ } else {
1165
+ if (match.index < 0 || match.index >= masks.length || seen.has(match.index)) {
1166
+ throw new MaskRestoreError(
1167
+ `translation does not reproduce every protected fragment (bad index ${match.index})`,
1168
+ masks.length,
1169
+ seen.size
1170
+ );
1171
+ }
1172
+ seen.add(match.index);
1173
+ const spacing = isPrefix ? void 0 : inserted?.[match.index];
1174
+ let before = text.slice(cursor, start);
1175
+ if (spacing?.leading) before = before.replace(INSERTED_SPACE, "");
1176
+ if (spacing?.trailing) out = out.replace(INSERTED_SPACE, "");
1177
+ out += before + masks[match.index];
1178
+ cursor = start + raw.length;
1112
1179
  }
1113
- const replacement = match?.acceptsId ? resolve3(match.index) : void 0;
1114
- out += text.slice(cursor, start) + (replacement ?? text.slice(start, start + length));
1115
- cursor = start + length;
1180
+ while (whole && whole.index < cursor) whole = complete.exec(text);
1181
+ while (prefixCandidate && prefixCandidate.index < cursor) prefixCandidate = prefix.exec(text);
1182
+ start = nextStart(whole, prefixCandidate);
1183
+ }
1184
+ out += text.slice(cursor);
1185
+ const documented = /* @__PURE__ */ new Set();
1186
+ for (const index of legacyIndexes) {
1187
+ const fragment = masks[index];
1188
+ if (fragment !== void 0) documented.add(fragment.toLowerCase());
1189
+ }
1190
+ const residue = (out.match(new RegExp(LEGACY_MASK_TOKEN_PATTERN_SOURCE, "giu")) ?? []).filter(
1191
+ (fragment) => !documented.has(fragment.toLowerCase())
1192
+ );
1193
+ if (seen.size !== masks.length || hasMaskResidue(out) || residue.length > 0) {
1194
+ throw new MaskRestoreError(
1195
+ `translation does not reproduce every protected fragment (expected ${masks.length}, found ${seen.size})`,
1196
+ masks.length,
1197
+ seen.size
1198
+ );
1116
1199
  }
1117
- return out + text.slice(cursor);
1200
+ return out;
1118
1201
  }
1119
1202
  function isMaskLeak(translatedText) {
1120
- return hasMaskResidue(translatedText);
1121
- }
1122
- function isMaskLeakAgainst(originalText, translatedText) {
1123
- return hasNewMaskResidue(originalText, translatedText);
1203
+ return hasAnyMaskResidue(translatedText);
1124
1204
  }
1125
1205
 
1126
1206
  // src/server/cache.ts
@@ -1133,12 +1213,19 @@ var LruDiskCache = class {
1133
1213
  dirty = false;
1134
1214
  /** Legacy root-level cache file (<=1.1); moved under the plugin subdir. */
1135
1215
  legacyPath;
1136
- constructor(maxEntries = 1e3) {
1216
+ /** Only the original title cache carries a pre-1.2 root-level predecessor. */
1217
+ hasLegacyPredecessor;
1218
+ constructor(maxEntries = 1e3, fileName = "cache.json") {
1137
1219
  this.maxEntries = maxEntries;
1138
- this.filePath = dshHomePath("dsh-chat-translate", "cache.json");
1220
+ this.filePath = dshHomePath("dsh-chat-translate", fileName);
1139
1221
  this.legacyPath = dshHomePath("dsh-chat-translate-cache.json");
1222
+ this.hasLegacyPredecessor = fileName === "cache.json";
1140
1223
  }
1141
1224
  async init() {
1225
+ if (!this.hasLegacyPredecessor) {
1226
+ await this.loadFile(this.filePath);
1227
+ return;
1228
+ }
1142
1229
  let readPath = this.filePath;
1143
1230
  try {
1144
1231
  await fs2.access(this.filePath);
@@ -1153,6 +1240,14 @@ var LruDiskCache = class {
1153
1240
  readPath = this.legacyPath;
1154
1241
  }
1155
1242
  }
1243
+ await this.loadFile(readPath);
1244
+ if (readPath !== this.filePath) {
1245
+ await fs2.unlink(this.legacyPath).catch(() => {
1246
+ });
1247
+ }
1248
+ }
1249
+ /** Load one cache document into memory, dropping poisoned or malformed entries. */
1250
+ async loadFile(readPath) {
1156
1251
  try {
1157
1252
  const content = await fs2.readFile(readPath, "utf-8");
1158
1253
  const obj = JSON.parse(content);
@@ -1170,10 +1265,6 @@ var LruDiskCache = class {
1170
1265
  }
1171
1266
  } catch {
1172
1267
  }
1173
- if (readPath !== this.filePath) {
1174
- await fs2.unlink(this.legacyPath).catch(() => {
1175
- });
1176
- }
1177
1268
  }
1178
1269
  get(key) {
1179
1270
  const entry = this.cache.get(key);
@@ -1421,6 +1512,11 @@ var LANG_HINTS = {
1421
1512
  pt: "Portuguese",
1422
1513
  it: "Italian"
1423
1514
  };
1515
+ function buildSystemPrompt(langName, mode) {
1516
+ 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.`;
1517
+ if (mode === "plain") return base;
1518
+ 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.`;
1519
+ }
1424
1520
  var OpenAiCompatibleAdapter = class {
1425
1521
  id = "openai";
1426
1522
  name = "OpenAI \u517C\u5BB9 (Chat Completions)";
@@ -1433,7 +1529,7 @@ var OpenAiCompatibleAdapter = class {
1433
1529
  config.aiEnabled && config.baseUrl?.trim() && config.model?.trim() && this.credentials.getApiKey()
1434
1530
  );
1435
1531
  }
1436
- async translate(text, signal, config) {
1532
+ async translate(text, signal, config, options) {
1437
1533
  const apiKey = this.credentials.getApiKey();
1438
1534
  if (!apiKey) {
1439
1535
  throw new Error(`TRANSLATE_API_KEY is not configured in ~/.dsh/.credentials.yaml`);
@@ -1453,11 +1549,9 @@ var OpenAiCompatibleAdapter = class {
1453
1549
  body: JSON.stringify({
1454
1550
  model,
1455
1551
  temperature: 0,
1552
+ ...options?.maxTokens === void 0 ? {} : { max_tokens: options.maxTokens },
1456
1553
  messages: [
1457
- {
1458
- role: "system",
1459
- content: `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 parts of the message are replaced by opaque placeholder markers (an uppercase token starting with "DSH" surrounded by double underscores). Copy every such marker into your output character-for-character, unchanged and in place. Never invent a marker that is not present in the message and never remove one.`
1460
- },
1554
+ { role: "system", content: buildSystemPrompt(langName, options?.mode ?? "plain") },
1461
1555
  { role: "user", content: text }
1462
1556
  ]
1463
1557
  }),
@@ -1482,10 +1576,138 @@ var OpenAiCompatibleAdapter = class {
1482
1576
  }
1483
1577
  };
1484
1578
 
1579
+ // src/server/pipeline/think.ts
1580
+ var THINK_MAX_INPUT_TOKENS = 4096;
1581
+ var THINK_MAX_OUTPUT_TOKENS = 8192;
1582
+ var CJK_CHAR = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/;
1583
+ function estimateTokens(text) {
1584
+ let cjk = 0;
1585
+ for (const char of text) {
1586
+ if (CJK_CHAR.test(char)) cjk += 1;
1587
+ }
1588
+ return cjk + Math.ceil((text.length - cjk) / 3);
1589
+ }
1590
+ function splitKeepingSeparator(text, pattern2) {
1591
+ const parts = text.split(pattern2);
1592
+ const chunks = [];
1593
+ for (let index = 0; index < parts.length; index += 2) {
1594
+ const content = parts[index] ?? "";
1595
+ const separator = parts[index + 1] ?? "";
1596
+ if (content === "" && separator === "") continue;
1597
+ chunks.push(content + separator);
1598
+ }
1599
+ return chunks;
1600
+ }
1601
+ function sentenceChunks(text) {
1602
+ const chunks = text.match(/[^.!?。!?\n]+[.!?。!?]*[ \t]*\n?/g);
1603
+ return chunks === null ? [text] : chunks.filter((chunk) => chunk.length > 0);
1604
+ }
1605
+ function hardSlice(text, maxTokens) {
1606
+ const out = [];
1607
+ let current = "";
1608
+ let tokens = 0;
1609
+ for (const char of text) {
1610
+ const cost = CJK_CHAR.test(char) ? 1 : 1 / 3;
1611
+ if (current !== "" && tokens + cost > maxTokens) {
1612
+ out.push(current);
1613
+ current = "";
1614
+ tokens = 0;
1615
+ }
1616
+ current += char;
1617
+ tokens += cost;
1618
+ }
1619
+ if (current !== "") out.push(current);
1620
+ return out;
1621
+ }
1622
+ function splitOversizedBlock(text, maxTokens = THINK_MAX_INPUT_TOKENS) {
1623
+ if (estimateTokens(text) <= maxTokens) return [text];
1624
+ let chunks = splitKeepingSeparator(text, /(\n{2,})/);
1625
+ if (chunks.length <= 1) chunks = splitKeepingSeparator(text, /(\n)/);
1626
+ if (chunks.length <= 1) chunks = sentenceChunks(text);
1627
+ const out = [];
1628
+ let current = "";
1629
+ for (const chunk of chunks) {
1630
+ if (estimateTokens(chunk) > maxTokens) {
1631
+ if (current !== "") {
1632
+ out.push(current);
1633
+ current = "";
1634
+ }
1635
+ out.push(...hardSlice(chunk, maxTokens));
1636
+ continue;
1637
+ }
1638
+ if (current !== "" && estimateTokens(current + chunk) > maxTokens) {
1639
+ out.push(current);
1640
+ current = "";
1641
+ }
1642
+ current += chunk;
1643
+ }
1644
+ if (current !== "") out.push(current);
1645
+ return out;
1646
+ }
1647
+ function packPieces(pieces, maxTokens = THINK_MAX_INPUT_TOKENS) {
1648
+ const batches = [];
1649
+ let current = [];
1650
+ let tokens = 0;
1651
+ for (const piece of pieces) {
1652
+ const cost = estimateTokens(piece.text);
1653
+ if (current.length > 0 && tokens + cost > maxTokens) {
1654
+ batches.push(current);
1655
+ current = [];
1656
+ tokens = 0;
1657
+ }
1658
+ current.push(piece);
1659
+ tokens += cost;
1660
+ }
1661
+ if (current.length > 0) batches.push(current);
1662
+ return batches;
1663
+ }
1664
+ function createThinkBatchFormat() {
1665
+ const id = randomTokenId();
1666
+ return {
1667
+ id,
1668
+ token: (index) => `\u27EA${id}${index}\u27EB`
1669
+ };
1670
+ }
1671
+ var BATCH_TOKEN_PATTERN_SOURCE = "\u27EA([a-z]{4})(\\d+)\u27EB";
1672
+ function buildBatchPayload(pieces, format) {
1673
+ return pieces.map((text, index) => `${format.token(index)}
1674
+ ${text}`).join("\n\n");
1675
+ }
1676
+ function splitBatchTranslation(translated, format, count) {
1677
+ const pattern2 = new RegExp(BATCH_TOKEN_PATTERN_SOURCE, "gu");
1678
+ const found = [];
1679
+ let match;
1680
+ while ((match = pattern2.exec(translated)) !== null) {
1681
+ if (match[1].toLowerCase() !== format.id.toLowerCase()) return null;
1682
+ const index = Number.parseInt(match[2] ?? "", 10);
1683
+ if (Number.isNaN(index)) return null;
1684
+ found.push({ index, start: match.index, end: match.index + match[0].length });
1685
+ }
1686
+ if (found.length !== count) return null;
1687
+ const parts = [];
1688
+ for (let index = 0; index < count; index++) {
1689
+ const entry = found[index];
1690
+ if (entry.index !== index) return null;
1691
+ const next = found[index + 1];
1692
+ const body = translated.slice(entry.end, next === void 0 ? translated.length : next.start).trim();
1693
+ if (body === "") return null;
1694
+ parts.push(body);
1695
+ }
1696
+ return parts;
1697
+ }
1698
+ function hasThinkBatchResidue(text) {
1699
+ return new RegExp(BATCH_TOKEN_PATTERN_SOURCE, "u").test(text);
1700
+ }
1701
+
1485
1702
  // src/server/dispatcher.ts
1703
+ function thinkPieceKey(piece) {
1704
+ return `${piece.block}:${piece.index}`;
1705
+ }
1486
1706
  var TranslationDispatcher = class {
1487
1707
  configManager;
1488
1708
  cache;
1709
+ /** 思考正文译文的独立缓存池,与工具标题的池互不挤占。 */
1710
+ thinkCache;
1489
1711
  credentials;
1490
1712
  masking = new ContentMaskingPipeline();
1491
1713
  adapters = /* @__PURE__ */ new Map();
@@ -1493,9 +1715,12 @@ var TranslationDispatcher = class {
1493
1715
  inFlightMap = /* @__PURE__ */ new Map();
1494
1716
  activeCount = 0;
1495
1717
  queue = [];
1496
- constructor(configManager, cache, credentials) {
1718
+ /** 思考链请求的串行队列尾,保证同时最多一个在途请求。 */
1719
+ thinkTail = Promise.resolve();
1720
+ constructor(configManager, cache, credentials, thinkCache = new LruDiskCache(THINK_CACHE_ENTRIES, "think-cache.json")) {
1497
1721
  this.configManager = configManager;
1498
1722
  this.cache = cache;
1723
+ this.thinkCache = thinkCache;
1499
1724
  this.credentials = credentials ?? { getApiKey: () => "" };
1500
1725
  this.registerAdapter(new OpenAiCompatibleAdapter(this.credentials));
1501
1726
  this.registerAdapter(new BingWebAdapter());
@@ -1558,7 +1783,8 @@ var TranslationDispatcher = class {
1558
1783
  return inFlight;
1559
1784
  }
1560
1785
  }
1561
- const { maskedText, unmask } = this.masking.mask(text);
1786
+ const { maskedText, unmask, legacyFragments } = this.masking.mask(text);
1787
+ const documentedLegacy = new Set(legacyFragments.map((fragment) => fragment.toLowerCase()));
1562
1788
  const taskPromise = this.enqueueTask(async () => {
1563
1789
  const currentConfig = this.configManager.getConfig();
1564
1790
  const channels = this.computeChannels(currentConfig);
@@ -1580,10 +1806,13 @@ var TranslationDispatcher = class {
1580
1806
  const cleaned = translatedMasked?.trim();
1581
1807
  if (cleaned && cleaned.length > 0) {
1582
1808
  const finalTranslated = unmask(cleaned);
1583
- if (isMaskLeakAgainst(text, finalTranslated)) {
1809
+ const legacyLeftovers = findLegacyMaskTokens(finalTranslated).filter(
1810
+ (fragment) => !documentedLegacy.has(fragment.toLowerCase())
1811
+ );
1812
+ if (hasMaskResidue(finalTranslated) || legacyLeftovers.length > 0) {
1584
1813
  this.recordFailure(chId);
1585
1814
  console.warn(
1586
- `[dsh-chat-translate] channel ${chId} leaked a mask placeholder, discarding its result | text: ${text.slice(0, 60)}`
1815
+ `[dsh-chat-translate] channel ${chId} left a mask placeholder in the translation, discarding its result | text: ${text.slice(0, 60)}`
1587
1816
  );
1588
1817
  continue;
1589
1818
  }
@@ -1602,8 +1831,9 @@ var TranslationDispatcher = class {
1602
1831
  );
1603
1832
  } catch (err) {
1604
1833
  this.recordFailure(chId);
1834
+ const detail = err instanceof MaskRestoreError ? ` (protected fragments expected ${err.expectedCount}, restored ${err.foundCount})` : "";
1605
1835
  console.warn(
1606
- `[dsh-chat-translate] channel ${chId} failed: ${describeError(err)} | text: ${text.slice(0, 60)}`
1836
+ `[dsh-chat-translate] channel ${chId} failed: ${describeError(err)}${detail} | text: ${text.slice(0, 60)}`
1607
1837
  );
1608
1838
  }
1609
1839
  }
@@ -1620,6 +1850,176 @@ var TranslationDispatcher = class {
1620
1850
  }
1621
1851
  }
1622
1852
  }
1853
+ /**
1854
+ * 翻译思考正文的块。整条链路与工具标题翻译分离:只走 AI 通道、独立串行
1855
+ * 队列、独立超时、独立缓存池,Bing 从不参与。
1856
+ *
1857
+ * 每个块先按输入上限切成片段,再各自掩码、相邻片段打包成一个请求;整批
1858
+ * 失败时退回逐片段单发,仍失败的片段保留原文。块内任一片段失败即整块不译,
1859
+ * 避免半中半英的段落。
1860
+ */
1861
+ async translateThinkBlocks(blocks) {
1862
+ const results = blocks.map((original) => ({
1863
+ original,
1864
+ translated: original,
1865
+ ok: false,
1866
+ cached: false,
1867
+ channel: "none"
1868
+ }));
1869
+ const config = this.configManager.getConfig();
1870
+ if (!config.enabled || !config.thinkEnabled) return results;
1871
+ const adapter = this.adapters.get("openai");
1872
+ if (!adapter || !adapter.isAvailable(config) || this.isChannelCoolingDown("openai")) {
1873
+ return results;
1874
+ }
1875
+ const pieces = [];
1876
+ for (let block = 0; block < blocks.length; block++) {
1877
+ const text = (blocks[block] ?? "").trim();
1878
+ if (!text) continue;
1879
+ const cached = this.thinkCache.get(text.toLowerCase());
1880
+ if (cached) {
1881
+ results[block] = {
1882
+ original: blocks[block],
1883
+ translated: cached,
1884
+ ok: true,
1885
+ cached: true,
1886
+ channel: "cache"
1887
+ };
1888
+ continue;
1889
+ }
1890
+ for (const [index, part] of splitOversizedBlock(text).entries()) {
1891
+ const mask = this.masking.mask(part);
1892
+ pieces.push({
1893
+ block,
1894
+ index,
1895
+ text: mask.maskedText,
1896
+ mask,
1897
+ legacy: new Set(mask.legacyFragments.map((fragment) => fragment.toLowerCase()))
1898
+ });
1899
+ }
1900
+ }
1901
+ const translated = /* @__PURE__ */ new Map();
1902
+ for (const batch of packPieces(pieces)) {
1903
+ const outcome = await this.runThinkSerial(
1904
+ () => this.translateThinkBatch(adapter, batch, config)
1905
+ );
1906
+ for (const [key, value] of outcome) translated.set(key, value);
1907
+ }
1908
+ const totals = /* @__PURE__ */ new Map();
1909
+ for (const piece of pieces) totals.set(piece.block, (totals.get(piece.block) ?? 0) + 1);
1910
+ for (const [block, total] of totals) {
1911
+ const parts = [];
1912
+ let complete = true;
1913
+ for (let index = 0; index < total; index++) {
1914
+ const part = translated.get(`${block}:${index}`);
1915
+ if (part === void 0) {
1916
+ complete = false;
1917
+ break;
1918
+ }
1919
+ parts.push(part);
1920
+ }
1921
+ if (!complete) continue;
1922
+ const original = blocks[block];
1923
+ const finalText = parts.join("");
1924
+ this.thinkCache.set(original.trim().toLowerCase(), finalText);
1925
+ results[block] = { original, translated: finalText, ok: true, cached: false, channel: "openai" };
1926
+ }
1927
+ return results;
1928
+ }
1929
+ /**
1930
+ * 一整批一次请求;失败则该批逐片段单发重试一次,仍失败的片段直接放弃
1931
+ * (保留原文),不在界面上留提示。
1932
+ */
1933
+ async translateThinkBatch(adapter, batch, config) {
1934
+ try {
1935
+ return await this.requestThinkBatch(adapter, batch, config);
1936
+ } catch (err) {
1937
+ console.warn(
1938
+ `[dsh-chat-translate] think batch of ${batch.length} failed, retrying per block: ${describeError(err)}`
1939
+ );
1940
+ }
1941
+ const out = /* @__PURE__ */ new Map();
1942
+ for (const piece of batch) {
1943
+ try {
1944
+ for (const [key, value] of await this.requestThinkBatch(adapter, [piece], config)) {
1945
+ out.set(key, value);
1946
+ }
1947
+ } catch (err) {
1948
+ console.warn(
1949
+ `[dsh-chat-translate] think block ${piece.block} #${piece.index} failed, keeping the original: ${describeError(err)}`
1950
+ );
1951
+ }
1952
+ }
1953
+ return out;
1954
+ }
1955
+ /** 发出一次思考链请求并把结果还原成每个片段的最终译文。 */
1956
+ async requestThinkBatch(adapter, batch, config) {
1957
+ const timeout = config.thinkTimeoutMs || 6e5;
1958
+ const abortCtrl = new AbortController();
1959
+ const timer = setTimeout(() => abortCtrl.abort(), timeout);
1960
+ let answers;
1961
+ try {
1962
+ if (batch.length === 1) {
1963
+ answers = [
1964
+ await adapter.translate(batch[0].text, abortCtrl.signal, config, {
1965
+ maxTokens: THINK_MAX_OUTPUT_TOKENS,
1966
+ mode: "plain"
1967
+ })
1968
+ ];
1969
+ } else {
1970
+ const format = createThinkBatchFormat();
1971
+ const answer = await adapter.translate(
1972
+ buildBatchPayload(batch.map((piece) => piece.text), format),
1973
+ abortCtrl.signal,
1974
+ config,
1975
+ { maxTokens: THINK_MAX_OUTPUT_TOKENS, mode: "blocks" }
1976
+ );
1977
+ const parts = splitBatchTranslation(answer, format, batch.length);
1978
+ if (parts === null) {
1979
+ throw new Error("think block markers did not survive the translation");
1980
+ }
1981
+ answers = parts;
1982
+ }
1983
+ } finally {
1984
+ clearTimeout(timer);
1985
+ }
1986
+ const out = /* @__PURE__ */ new Map();
1987
+ batch.forEach((piece, index) => {
1988
+ const answer = (answers[index] ?? "").trim();
1989
+ if (!answer) {
1990
+ throw new Error(`think block ${piece.block} #${piece.index} came back empty`);
1991
+ }
1992
+ if (hasThinkBatchResidue(answer)) {
1993
+ throw new Error("think translation left a block marker behind");
1994
+ }
1995
+ const finalText = piece.mask.unmask(answer);
1996
+ const leftovers = findLegacyMaskTokens(finalText).filter(
1997
+ (fragment) => !piece.legacy.has(fragment.toLowerCase())
1998
+ );
1999
+ if (hasMaskResidue(finalText) || leftovers.length > 0) {
2000
+ throw new Error("think translation left a mask placeholder behind");
2001
+ }
2002
+ out.set(thinkPieceKey(piece), finalText);
2003
+ });
2004
+ return out;
2005
+ }
2006
+ /** 串行执行器:同时最多一个在途请求,与工具标题的并发池完全独立。 */
2007
+ runThinkSerial(task) {
2008
+ const next = this.thinkTail.then(task, task);
2009
+ this.thinkTail = next.then(
2010
+ () => void 0,
2011
+ () => void 0
2012
+ );
2013
+ return next;
2014
+ }
2015
+ /**
2016
+ * 只读判断某通道是否还在熔断冷却期。思考链翻译只借用这个判断避开已经
2017
+ * 出问题的服务,不改动熔断状态本身(那是工具标题通道的记账)。
2018
+ */
2019
+ isChannelCoolingDown(channelId) {
2020
+ const state = this.circuitStates.get(channelId);
2021
+ return state !== void 0 && state.state === "open" && Date.now() < state.openUntil;
2022
+ }
1623
2023
  async testChannel(channelId) {
1624
2024
  const adapter = this.adapters.get(channelId);
1625
2025
  const config = this.configManager.getConfig();
@@ -1736,50 +2136,33 @@ var TranslationDispatcher = class {
1736
2136
  };
1737
2137
 
1738
2138
  // src/server/router.ts
1739
- var MAX_BODY_BYTES = 1024 * 1024;
1740
- function sendJson(res, status, body) {
1741
- const json = JSON.stringify(body);
1742
- res.writeHead(status, {
1743
- "Content-Type": "application/json; charset=utf-8",
1744
- "Content-Length": Buffer.byteLength(json)
2139
+ var TRANSLATE_ROUTE_PATH = "/api/dsh-chat-translate/translate";
2140
+ var TEST_CHANNEL_ROUTE_PATH = "/api/dsh-chat-translate/test-channel";
2141
+ var THINK_ROUTE_PATH = "/api/dsh-chat-translate/translate-think";
2142
+ function sendJson(status, body) {
2143
+ return new Response(JSON.stringify(body), {
2144
+ status,
2145
+ headers: { "Content-Type": "application/json; charset=utf-8" }
1745
2146
  });
1746
- res.end(json);
1747
2147
  }
1748
- function readBody(req) {
1749
- return new Promise((resolve3, reject) => {
1750
- const chunks = [];
1751
- let totalLength = 0;
1752
- req.on("data", (chunk) => {
1753
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1754
- totalLength += buf.length;
1755
- if (totalLength > MAX_BODY_BYTES) {
1756
- if (typeof req.destroy === "function") {
1757
- req.destroy();
1758
- }
1759
- reject(new Error("Request body exceeded maximum allowed size (1MB)"));
1760
- return;
1761
- }
1762
- chunks.push(buf);
1763
- });
1764
- req.on("end", () => resolve3(Buffer.concat(chunks).toString("utf-8")));
1765
- req.on("error", reject);
1766
- });
2148
+ async function readJson(request) {
2149
+ try {
2150
+ return { ok: true, value: await request.json() };
2151
+ } catch {
2152
+ return { ok: false, response: sendJson(400, { ok: false, error: "Invalid JSON body" }) };
2153
+ }
1767
2154
  }
1768
- function createHttpHandler(configManager, dispatcher) {
1769
- return async (req, res) => {
1770
- const url = new URL(req.url || "/", "http://localhost");
1771
- const pathParts = url.pathname.split("/").filter(Boolean);
1772
- const endpoint = pathParts[2] || "";
1773
- try {
1774
- if (endpoint === "translate" && req.method === "POST") {
1775
- const raw = await readBody(req);
1776
- let parsed;
1777
- try {
1778
- parsed = JSON.parse(raw || "{}");
1779
- } catch {
1780
- sendJson(res, 400, { ok: false, error: "Invalid JSON body" });
1781
- return;
1782
- }
2155
+ function createFetchRoutes(dispatcher, ready = Promise.resolve()) {
2156
+ const translate = {
2157
+ path: TRANSLATE_ROUTE_PATH,
2158
+ methods: ["POST"],
2159
+ requestBody: "buffered",
2160
+ fetch: async (request) => {
2161
+ await ready;
2162
+ try {
2163
+ const body = await readJson(request);
2164
+ if (!body.ok) return body.response;
2165
+ const parsed = body.value;
1783
2166
  const rawTexts = parsed.texts !== void 0 ? parsed.texts : parsed.text;
1784
2167
  let texts = [];
1785
2168
  if (Array.isArray(rawTexts)) {
@@ -1789,45 +2172,68 @@ function createHttpHandler(configManager, dispatcher) {
1789
2172
  }
1790
2173
  const forceRefresh = Boolean(parsed.forceRefresh);
1791
2174
  if (texts.length === 0) {
1792
- sendJson(res, 200, { ok: true, results: [] });
1793
- return;
2175
+ return sendJson(200, { ok: true, results: [] });
1794
2176
  }
1795
2177
  const results = await dispatcher.translateBatch(texts, forceRefresh);
1796
- sendJson(res, 200, { ok: true, results });
1797
- return;
2178
+ return sendJson(200, { ok: true, results });
2179
+ } catch (err) {
2180
+ return sendJson(500, { ok: false, error: describeError(err) });
1798
2181
  }
1799
- if (endpoint === "test-channel" && req.method === "POST") {
1800
- const raw = await readBody(req);
1801
- let parsed;
1802
- try {
1803
- parsed = JSON.parse(raw || "{}");
1804
- } catch {
1805
- sendJson(res, 400, { ok: false, error: "Invalid JSON body" });
1806
- return;
2182
+ }
2183
+ };
2184
+ const think = {
2185
+ path: THINK_ROUTE_PATH,
2186
+ methods: ["POST"],
2187
+ requestBody: "buffered",
2188
+ fetch: async (request) => {
2189
+ await ready;
2190
+ try {
2191
+ const body = await readJson(request);
2192
+ if (!body.ok) return body.response;
2193
+ const rawBlocks = body.value?.blocks;
2194
+ const blocks = Array.isArray(rawBlocks) ? rawBlocks.filter((block) => typeof block === "string") : [];
2195
+ if (blocks.length === 0) {
2196
+ return sendJson(200, { ok: true, results: [] });
1807
2197
  }
1808
- const channelId = typeof parsed.channel === "string" ? parsed.channel : "";
2198
+ const results = await dispatcher.translateThinkBlocks(blocks);
2199
+ return sendJson(200, { ok: true, results });
2200
+ } catch (err) {
2201
+ return sendJson(500, { ok: false, error: describeError(err) });
2202
+ }
2203
+ }
2204
+ };
2205
+ const testChannel = {
2206
+ path: TEST_CHANNEL_ROUTE_PATH,
2207
+ methods: ["POST"],
2208
+ requestBody: "buffered",
2209
+ fetch: async (request) => {
2210
+ await ready;
2211
+ try {
2212
+ const body = await readJson(request);
2213
+ if (!body.ok) return body.response;
2214
+ const channelId = typeof body.value.channel === "string" ? body.value.channel : "";
1809
2215
  const result = await dispatcher.testChannel(channelId);
1810
- sendJson(res, 200, result);
1811
- return;
2216
+ return sendJson(200, result);
2217
+ } catch (err) {
2218
+ return sendJson(500, { ok: false, error: describeError(err) });
1812
2219
  }
1813
- sendJson(res, 404, { ok: false, error: "Endpoint not found" });
1814
- } catch (err) {
1815
- const status = err?.message?.includes("exceeded maximum allowed size") ? 413 : 500;
1816
- sendJson(res, status, { ok: false, error: describeError(err) });
1817
2220
  }
1818
2221
  };
2222
+ return [translate, think, testChannel];
1819
2223
  }
1820
2224
 
1821
2225
  // src/index.ts
1822
2226
  var name = "dsh-chat-translate";
1823
- var inject = ["webServer", "settings", "credentials"];
2227
+ var inject = ["settings", "credentials"];
1824
2228
  var CONFIG_SCHEMA = Schema.object({
1825
2229
  enabled: Schema.boolean().default(DEFAULT_CONFIG.enabled),
1826
2230
  concurrency: Schema.number().min(1).max(MAX_CONCURRENCY).default(DEFAULT_CONFIG.concurrency),
1827
2231
  timeoutMs: Schema.number().min(500).max(1e4).default(DEFAULT_CONFIG.timeoutMs),
1828
2232
  aiTimeoutMs: Schema.number().min(AI_TIMEOUT_MIN).max(AI_TIMEOUT_MAX).default(DEFAULT_CONFIG.aiTimeoutMs),
2233
+ thinkTimeoutMs: Schema.number().min(THINK_TIMEOUT_MIN).max(THINK_TIMEOUT_MAX).default(DEFAULT_CONFIG.thinkTimeoutMs),
1829
2234
  aiEnabled: Schema.boolean().default(DEFAULT_CONFIG.aiEnabled),
1830
2235
  bingEnabled: Schema.boolean().default(DEFAULT_CONFIG.bingEnabled),
2236
+ thinkEnabled: Schema.boolean().default(DEFAULT_CONFIG.thinkEnabled),
1831
2237
  baseUrl: Schema.string().default(DEFAULT_CONFIG.baseUrl),
1832
2238
  model: Schema.string().default(DEFAULT_CONFIG.model),
1833
2239
  targetLang: Schema.string().default(DEFAULT_CONFIG.targetLang)
@@ -1836,11 +2242,13 @@ function apply(ctx) {
1836
2242
  const credentials = new CredentialsReader(ctx.credentials);
1837
2243
  const configManager = new ConfigManager(ctx.settings.register(SETTINGS_NAMESPACE, CONFIG_SCHEMA), credentials);
1838
2244
  const cache = new LruDiskCache(1e3);
1839
- const dispatcher = new TranslationDispatcher(configManager, cache, credentials);
2245
+ const thinkCache = new LruDiskCache(THINK_CACHE_ENTRIES, "think-cache.json");
2246
+ const dispatcher = new TranslationDispatcher(configManager, cache, credentials, thinkCache);
1840
2247
  const legacyConfigPath = dshHomePath("dsh-chat-translate-config.json");
1841
2248
  const initPromise = Promise.all([
1842
2249
  credentials.init(),
1843
2250
  cache.init(),
2251
+ thinkCache.init(),
1844
2252
  migrateLegacyConfigFile(ctx.settings, legacyConfigPath)
1845
2253
  ]).catch((err) => {
1846
2254
  console.warn("[dsh-chat-translate] Initialization error:", err);
@@ -1850,32 +2258,16 @@ function apply(ctx) {
1850
2258
  void credentials.refresh();
1851
2259
  }
1852
2260
  });
1853
- const webServer = ctx.webServer || (ctx.get ? ctx.get("webServer") : null);
1854
- if (webServer && typeof webServer.register === "function") {
1855
- const rawHandler = createHttpHandler(configManager, dispatcher);
1856
- const handler = async (req, res) => {
1857
- await initPromise;
1858
- return rawHandler(req, res);
1859
- };
1860
- ctx.effect(
1861
- () => {
1862
- const unregister = webServer.register({
1863
- kind: "prefix",
1864
- path: "/api/dsh-chat-translate",
1865
- handler
1866
- });
1867
- return () => {
1868
- if (typeof unregister === "function") {
1869
- unregister();
1870
- }
1871
- cache.dispose().catch((err) => {
1872
- console.warn("[dsh-chat-translate] Dispose cache error:", err);
1873
- });
1874
- };
1875
- },
1876
- "dsh-chat-translate: translation API routes"
2261
+ ctx.inject(["connection"], (connectionCtx) => {
2262
+ const disposers = createFetchRoutes(dispatcher, initPromise).map(
2263
+ (route) => connectionCtx.connection.fetch.register(route)
1877
2264
  );
1878
- }
2265
+ return () => {
2266
+ void Promise.all(disposers.map((dispose) => dispose())).then(() => Promise.all([cache.dispose(), thinkCache.dispose()])).catch((err) => {
2267
+ console.warn("[dsh-chat-translate] Dispose translation routes error:", err);
2268
+ });
2269
+ };
2270
+ });
1879
2271
  }
1880
2272
  export {
1881
2273
  apply,