@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.
- package/README.md +14 -9
- package/docs//351/205/215/347/275/256.md +11 -1
- package/lib/client.js +1527 -489
- package/lib/client.js.map +4 -4
- package/lib/index.js +565 -173
- package/lib/index.js.map +4 -4
- package/lib/types/client/index.d.ts +7 -5
- package/lib/types/client/locales.d.ts +51 -4
- package/lib/types/client/reading-settings-page.d.ts +1 -1
- package/lib/types/client/settings/store.d.ts +17 -4
- package/lib/types/client/settings/ui.d.ts +5 -3
- package/lib/types/client/translate/api.d.ts +13 -0
- package/lib/types/client/translate/client-cache.d.ts +6 -1
- package/lib/types/client/translate/hold.d.ts +135 -0
- package/lib/types/client/translate/lazy.d.ts +8 -0
- package/lib/types/client/translate/mount.d.ts +22 -0
- package/lib/types/client/translate/observer.d.ts +57 -0
- package/lib/types/client/translate/think-button.d.ts +19 -0
- package/lib/types/client/translate/think.d.ts +55 -0
- package/lib/types/client/translate/viewport-observer.d.ts +14 -2
- package/lib/types/index.d.ts +11 -18
- package/lib/types/server/adapters/base.d.ts +1 -1
- package/lib/types/server/adapters/openai.d.ts +2 -2
- package/lib/types/server/cache.d.ts +5 -1
- package/lib/types/server/config.d.ts +35 -7
- package/lib/types/server/dispatcher.d.ts +30 -3
- package/lib/types/server/pipeline/mask-tokens.d.ts +54 -48
- package/lib/types/server/pipeline/masking.d.ts +35 -12
- package/lib/types/server/pipeline/think.d.ts +49 -0
- package/lib/types/server/router.d.ts +24 -3
- package/lib/types/server/types.d.ts +18 -1
- 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.
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
1030
|
+
token: (index) => `\u27E6${id}${index}\u27E7`
|
|
1014
1031
|
};
|
|
1015
1032
|
}
|
|
1016
|
-
var MASK_TOKEN_PATTERN_SOURCE = "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, "
|
|
1049
|
+
return new RegExp(MASK_TOKEN_PATTERN_SOURCE, "iu").test(text);
|
|
1029
1050
|
}
|
|
1030
|
-
function
|
|
1031
|
-
if (!text) return
|
|
1032
|
-
return
|
|
1051
|
+
function hasLegacyMaskResidue(text) {
|
|
1052
|
+
if (!text) return false;
|
|
1053
|
+
return new RegExp(LEGACY_MASK_PATTERN_SOURCE, "iu").test(text);
|
|
1033
1054
|
}
|
|
1034
|
-
function
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
|
1065
|
-
if (
|
|
1066
|
-
const
|
|
1090
|
+
const addFragment = (match, before, after, onPlace) => {
|
|
1091
|
+
if (hasMaskResidue(match)) return match;
|
|
1092
|
+
const index = masks.length;
|
|
1067
1093
|
masks.push(match);
|
|
1068
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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 =
|
|
1079
|
-
|
|
1080
|
-
(
|
|
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
|
|
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,
|
|
1099
|
-
const
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
cursor = start +
|
|
1111
|
-
|
|
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
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
|
1200
|
+
return out;
|
|
1118
1201
|
}
|
|
1119
1202
|
function isMaskLeak(translatedText) {
|
|
1120
|
-
return
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
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
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
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(
|
|
1793
|
-
return;
|
|
2175
|
+
return sendJson(200, { ok: true, results: [] });
|
|
1794
2176
|
}
|
|
1795
2177
|
const results = await dispatcher.translateBatch(texts, forceRefresh);
|
|
1796
|
-
sendJson(
|
|
1797
|
-
|
|
2178
|
+
return sendJson(200, { ok: true, results });
|
|
2179
|
+
} catch (err) {
|
|
2180
|
+
return sendJson(500, { ok: false, error: describeError(err) });
|
|
1798
2181
|
}
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
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
|
|
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(
|
|
1811
|
-
|
|
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 = ["
|
|
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
|
|
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
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
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,
|