@lynn123411/dsh-chat-translate 3.0.1 → 3.2.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 +7 -7
- package/docs//351/205/215/347/275/256.md +6 -5
- package/lib/client.js +312 -184
- package/lib/client.js.map +4 -4
- package/lib/index.js +369 -335
- package/lib/index.js.map +4 -4
- package/lib/types/client/chat/assistant-step.d.ts +7 -3
- package/lib/types/client/chat/click-guard.d.ts +10 -9
- package/lib/types/client/chat/row-plan.d.ts +26 -8
- package/lib/types/client/chat/styles.d.ts +51 -6
- package/lib/types/client/chat/translate-store.d.ts +16 -16
- package/lib/types/client/locales.d.ts +20 -2
- package/lib/types/client/translate/api.d.ts +1 -8
- package/lib/types/server/cache.d.ts +1 -1
- package/lib/types/server/dispatcher.d.ts +30 -26
- package/lib/types/server/pipeline/blocks.d.ts +7 -11
- package/lib/types/server/pipeline/segments.d.ts +68 -0
- package/lib/types/server/prompt-revision.d.ts +1 -1
- package/lib/types/server/types.d.ts +15 -13
- package/package.json +4 -2
- package/lib/types/server/pipeline/mask-tokens.d.ts +0 -64
- package/lib/types/server/pipeline/masking.d.ts +0 -45
package/lib/index.js
CHANGED
|
@@ -999,168 +999,8 @@ function dshHomePath(...segments) {
|
|
|
999
999
|
return join(resolveDshHome(), ...segments);
|
|
1000
1000
|
}
|
|
1001
1001
|
|
|
1002
|
-
// src/server/pipeline/mask-tokens.ts
|
|
1003
|
-
var RANDOM_LETTERS = "abcdefghijklmnopqrstuvwxyz";
|
|
1004
|
-
function randomTokenId() {
|
|
1005
|
-
let out = "";
|
|
1006
|
-
for (let i = 0; i < 4; i++) {
|
|
1007
|
-
out += RANDOM_LETTERS[Math.floor(Math.random() * RANDOM_LETTERS.length)];
|
|
1008
|
-
}
|
|
1009
|
-
return out;
|
|
1010
|
-
}
|
|
1011
|
-
function createMaskTokenFormat() {
|
|
1012
|
-
const id = randomTokenId();
|
|
1013
|
-
return {
|
|
1014
|
-
id,
|
|
1015
|
-
token: (index) => `\u27E6${id}${index}\u27E7`
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
var MASK_TOKEN_PATTERN_SOURCE = "\u27E6([a-z]{4})(\\d+)\u27E7";
|
|
1019
|
-
var MASK_TOKEN_PREFIX_PATTERN_SOURCE = "\u27E6\\s*([a-z]{4})(\\d+)";
|
|
1020
|
-
function matchMaskToken(text, start, id) {
|
|
1021
|
-
const complete = new RegExp(`^(?:${MASK_TOKEN_PATTERN_SOURCE})`, "iu").exec(text.slice(start));
|
|
1022
|
-
const truncated = complete ? null : new RegExp(`^(?:${MASK_TOKEN_PREFIX_PATTERN_SOURCE})`, "iu").exec(text.slice(start));
|
|
1023
|
-
const anchored = complete ?? truncated;
|
|
1024
|
-
if (!anchored) return null;
|
|
1025
|
-
if (anchored[1].toLowerCase() !== id.toLowerCase()) return null;
|
|
1026
|
-
const index = Number.parseInt(anchored[2], 10);
|
|
1027
|
-
if (Number.isNaN(index)) return null;
|
|
1028
|
-
return { index, length: anchored[0].length, truncated: complete === null };
|
|
1029
|
-
}
|
|
1030
|
-
function hasMaskResidue(text) {
|
|
1031
|
-
if (!text) return false;
|
|
1032
|
-
return new RegExp(MASK_TOKEN_PATTERN_SOURCE, "iu").test(text);
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
// src/server/pipeline/masking.ts
|
|
1036
|
-
var MAX_UNMASK_PASSES = 1e4;
|
|
1037
|
-
var ASCII_WORD = /[A-Za-z0-9_]/;
|
|
1038
|
-
var INSERTED_SPACE = /^ | $/;
|
|
1039
|
-
var MaskRestoreError = class extends Error {
|
|
1040
|
-
expectedCount;
|
|
1041
|
-
foundCount;
|
|
1042
|
-
constructor(message, expectedCount, foundCount) {
|
|
1043
|
-
super(message);
|
|
1044
|
-
this.name = "MaskRestoreError";
|
|
1045
|
-
this.expectedCount = expectedCount;
|
|
1046
|
-
this.foundCount = foundCount;
|
|
1047
|
-
}
|
|
1048
|
-
};
|
|
1049
|
-
var ContentMaskingPipeline = class {
|
|
1050
|
-
mask(text) {
|
|
1051
|
-
if (!text || typeof text !== "string") {
|
|
1052
|
-
return {
|
|
1053
|
-
maskedText: text,
|
|
1054
|
-
unmask: (t) => t
|
|
1055
|
-
};
|
|
1056
|
-
}
|
|
1057
|
-
const masks = [];
|
|
1058
|
-
const inserted = [];
|
|
1059
|
-
const format = createMaskTokenFormat();
|
|
1060
|
-
const addFragment = (match, before, after, onPlace) => {
|
|
1061
|
-
if (hasMaskResidue(match)) return match;
|
|
1062
|
-
const index = masks.length;
|
|
1063
|
-
masks.push(match);
|
|
1064
|
-
const leading = before !== void 0 && ASCII_WORD.test(before);
|
|
1065
|
-
const trailing = after !== void 0 && ASCII_WORD.test(after);
|
|
1066
|
-
inserted.push({ leading, trailing });
|
|
1067
|
-
onPlace?.(index);
|
|
1068
|
-
return `${leading ? " " : ""}${format.token(index)}${trailing ? " " : ""}`;
|
|
1069
|
-
};
|
|
1070
|
-
const wrapRule = (input, pattern2, onPlace) => input.replace(pattern2, (...args) => {
|
|
1071
|
-
const match = args[0];
|
|
1072
|
-
const offset = args[args.length - 2];
|
|
1073
|
-
const whole = args[args.length - 1];
|
|
1074
|
-
return addFragment(match, whole[offset - 1], whole[offset + match.length], onPlace);
|
|
1075
|
-
});
|
|
1076
|
-
let processed = wrapRule(text, /(?:```|~~~)[\s\S]*?(?:```|~~~)/g);
|
|
1077
|
-
processed = wrapRule(processed, /`[^`\n]+`/g);
|
|
1078
|
-
processed = wrapRule(processed, /https?:\/\/[^\s)\];,;"'<>]+/g);
|
|
1079
|
-
processed = wrapRule(
|
|
1080
|
-
processed,
|
|
1081
|
-
/(?:\/[\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
|
|
1082
|
-
);
|
|
1083
|
-
processed = wrapRule(
|
|
1084
|
-
processed,
|
|
1085
|
-
/(?<=^|[\s(\[{"'])((?:--[a-zA-Z0-9_\-]+(?:=[^\s"'<>]+)?)|(?:-[a-zA-Z0-9]+))(?=[\s)\]}",:;!?]|$)/g
|
|
1086
|
-
);
|
|
1087
|
-
const unmask = (translatedText) => {
|
|
1088
|
-
if (!translatedText) return translatedText;
|
|
1089
|
-
if (masks.length === 0) {
|
|
1090
|
-
if (hasMaskResidue(translatedText)) {
|
|
1091
|
-
throw new MaskRestoreError(
|
|
1092
|
-
"translation carries a mask token while the source had none",
|
|
1093
|
-
0,
|
|
1094
|
-
0
|
|
1095
|
-
);
|
|
1096
|
-
}
|
|
1097
|
-
return translatedText;
|
|
1098
|
-
}
|
|
1099
|
-
return replaceMaskTokens(translatedText, format.id, masks, inserted);
|
|
1100
|
-
};
|
|
1101
|
-
return {
|
|
1102
|
-
maskedText: processed,
|
|
1103
|
-
unmask
|
|
1104
|
-
};
|
|
1105
|
-
}
|
|
1106
|
-
};
|
|
1107
|
-
function replaceMaskTokens(text, id, masks, inserted) {
|
|
1108
|
-
const complete = new RegExp(MASK_TOKEN_PATTERN_SOURCE, "giu");
|
|
1109
|
-
const prefix = new RegExp(MASK_TOKEN_PREFIX_PATTERN_SOURCE, "giu");
|
|
1110
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1111
|
-
let out = "";
|
|
1112
|
-
let cursor = 0;
|
|
1113
|
-
let passes = 0;
|
|
1114
|
-
let whole = complete.exec(text);
|
|
1115
|
-
let prefixCandidate = prefix.exec(text);
|
|
1116
|
-
const nextStart = (wholeMatch, prefixMatch) => Math.min(
|
|
1117
|
-
wholeMatch ? wholeMatch.index : Number.POSITIVE_INFINITY,
|
|
1118
|
-
prefixMatch ? prefixMatch.index : Number.POSITIVE_INFINITY
|
|
1119
|
-
);
|
|
1120
|
-
let start = nextStart(whole, prefixCandidate);
|
|
1121
|
-
while (start !== Number.POSITIVE_INFINITY && passes++ < MAX_UNMASK_PASSES) {
|
|
1122
|
-
const isPrefix = prefixCandidate !== null && prefixCandidate.index === start && whole?.index !== start;
|
|
1123
|
-
const raw = isPrefix ? prefixCandidate[0] : whole[0];
|
|
1124
|
-
const match = matchMaskToken(text, start, id);
|
|
1125
|
-
if (!match) {
|
|
1126
|
-
out += text.slice(cursor, start) + raw;
|
|
1127
|
-
cursor = start + raw.length;
|
|
1128
|
-
} else {
|
|
1129
|
-
if (match.index < 0 || match.index >= masks.length || seen.has(match.index)) {
|
|
1130
|
-
throw new MaskRestoreError(
|
|
1131
|
-
`translation does not reproduce every protected fragment (bad index ${match.index})`,
|
|
1132
|
-
masks.length,
|
|
1133
|
-
seen.size
|
|
1134
|
-
);
|
|
1135
|
-
}
|
|
1136
|
-
seen.add(match.index);
|
|
1137
|
-
const spacing = isPrefix ? void 0 : inserted?.[match.index];
|
|
1138
|
-
let before = text.slice(cursor, start);
|
|
1139
|
-
if (spacing?.leading) before = before.replace(INSERTED_SPACE, "");
|
|
1140
|
-
if (spacing?.trailing) out = out.replace(INSERTED_SPACE, "");
|
|
1141
|
-
out += before + masks[match.index];
|
|
1142
|
-
cursor = start + raw.length;
|
|
1143
|
-
}
|
|
1144
|
-
while (whole && whole.index < cursor) whole = complete.exec(text);
|
|
1145
|
-
while (prefixCandidate && prefixCandidate.index < cursor) prefixCandidate = prefix.exec(text);
|
|
1146
|
-
start = nextStart(whole, prefixCandidate);
|
|
1147
|
-
}
|
|
1148
|
-
out += text.slice(cursor);
|
|
1149
|
-
if (seen.size !== masks.length || hasMaskResidue(out)) {
|
|
1150
|
-
throw new MaskRestoreError(
|
|
1151
|
-
`translation does not reproduce every protected fragment (expected ${masks.length}, found ${seen.size})`,
|
|
1152
|
-
masks.length,
|
|
1153
|
-
seen.size
|
|
1154
|
-
);
|
|
1155
|
-
}
|
|
1156
|
-
return out;
|
|
1157
|
-
}
|
|
1158
|
-
function isMaskLeak(translatedText) {
|
|
1159
|
-
return hasMaskResidue(translatedText);
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
1002
|
// src/server/prompt-revision.ts
|
|
1163
|
-
var PROMPT_REVISION = "
|
|
1003
|
+
var PROMPT_REVISION = "r2";
|
|
1164
1004
|
|
|
1165
1005
|
// src/server/cache.ts
|
|
1166
1006
|
var TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
@@ -1186,14 +1026,12 @@ var LruDiskCache = class {
|
|
|
1186
1026
|
} catch {
|
|
1187
1027
|
}
|
|
1188
1028
|
}
|
|
1189
|
-
/** Adopt loaded entries, dropping
|
|
1029
|
+
/** Adopt loaded entries, dropping malformed ones. */
|
|
1190
1030
|
loadEntries(raw) {
|
|
1191
1031
|
for (const [k, entry] of Object.entries(raw)) {
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
this.cache.set(k, value);
|
|
1196
|
-
}
|
|
1032
|
+
const value = entry;
|
|
1033
|
+
if (value && typeof value === "object" && typeof value.v === "string" && typeof value.t === "number" && Number.isFinite(value.t)) {
|
|
1034
|
+
this.cache.set(k, value);
|
|
1197
1035
|
}
|
|
1198
1036
|
}
|
|
1199
1037
|
}
|
|
@@ -1204,19 +1042,11 @@ var LruDiskCache = class {
|
|
|
1204
1042
|
this.cache.delete(key);
|
|
1205
1043
|
return void 0;
|
|
1206
1044
|
}
|
|
1207
|
-
if (isMaskLeak(entry.v)) {
|
|
1208
|
-
this.cache.delete(key);
|
|
1209
|
-
return void 0;
|
|
1210
|
-
}
|
|
1211
1045
|
this.cache.delete(key);
|
|
1212
1046
|
this.cache.set(key, entry);
|
|
1213
1047
|
return entry.v;
|
|
1214
1048
|
}
|
|
1215
1049
|
set(key, value) {
|
|
1216
|
-
if (isMaskLeak(value)) {
|
|
1217
|
-
console.warn("[dsh-chat-translate] refusing to cache a translation with a leaked mask placeholder");
|
|
1218
|
-
return;
|
|
1219
|
-
}
|
|
1220
1050
|
if (this.cache.has(key)) {
|
|
1221
1051
|
this.cache.delete(key);
|
|
1222
1052
|
} else if (this.cache.size >= this.maxEntries) {
|
|
@@ -1307,7 +1137,7 @@ function describeError(err) {
|
|
|
1307
1137
|
// src/server/adapters/openai.ts
|
|
1308
1138
|
var TARGET_LANGUAGE = "Simplified Chinese";
|
|
1309
1139
|
function buildSystemPrompt(mode) {
|
|
1310
|
-
const base = `You are a professional translator and editor. Rewrite the user's message as natural, idiomatic ${TARGET_LANGUAGE} the way a native writer would produce it. This applies to every passage, including passages already written in ${TARGET_LANGUAGE}: smooth out stiff or machine-translated phrasing while keeping the meaning, tone, proper names, numbers and technical terms unchanged. Never drop content and never add content. The message is Markdown that will be re-rendered as Markdown
|
|
1140
|
+
const base = `You are a professional translator and editor. Rewrite the user's message as natural, idiomatic ${TARGET_LANGUAGE} the way a native writer would produce it. This applies to every passage, including passages already written in ${TARGET_LANGUAGE}: smooth out stiff or machine-translated phrasing while keeping the meaning, tone, proper names, numbers and technical terms unchanged. Never drop content and never add content. The message is Markdown that will be re-rendered as Markdown, and its structure is verified line by line: keep the same number of lines, the same indentation, the same heading, quote and list markers, the same table rows and pipe counts, and the same number of links. Inline code contents may stay as they are or be translated where that reads better. For a link [text](target), translate only the text and copy the target inside the parentheses character-for-character. Never add, remove, reorder or nest Markdown syntax of your own. Output ONLY the resulting text \u2014 no explanations, no quotation marks, no extra words, no code fence around the whole answer.`;
|
|
1311
1141
|
if (mode === "plain") return base;
|
|
1312
1142
|
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 rewrite on the following lines. Rewrite each part on its own, keep the parts in the original order, and never merge two parts into one answer.`;
|
|
1313
1143
|
}
|
|
@@ -1368,6 +1198,13 @@ var OpenAiCompatibleAdapter = class {
|
|
|
1368
1198
|
};
|
|
1369
1199
|
|
|
1370
1200
|
// src/server/pipeline/blocks.ts
|
|
1201
|
+
function randomTokenId() {
|
|
1202
|
+
let out = "";
|
|
1203
|
+
for (let i = 0; i < 4; i++) {
|
|
1204
|
+
out += "abcdefghijklmnopqrstuvwxyz"[Math.floor(Math.random() * 26)];
|
|
1205
|
+
}
|
|
1206
|
+
return out;
|
|
1207
|
+
}
|
|
1371
1208
|
var REPLY_MAX_INPUT_TOKENS = 4096;
|
|
1372
1209
|
var REPLY_MAX_OUTPUT_TOKENS = 8192;
|
|
1373
1210
|
var CJK_CHAR = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/;
|
|
@@ -1486,31 +1323,246 @@ function splitBatchTranslation(translated, format, count) {
|
|
|
1486
1323
|
}
|
|
1487
1324
|
return parts;
|
|
1488
1325
|
}
|
|
1489
|
-
function
|
|
1490
|
-
return new RegExp(BATCH_TOKEN_PATTERN_SOURCE, "
|
|
1326
|
+
function stripBatchMarkers(text) {
|
|
1327
|
+
return text.replace(new RegExp(BATCH_TOKEN_PATTERN_SOURCE, "gu"), "");
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/server/pipeline/segments.ts
|
|
1331
|
+
var FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
|
|
1332
|
+
var FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*\r?$/;
|
|
1333
|
+
function closesFence(open, line) {
|
|
1334
|
+
const close = FENCE_CLOSE.exec(line);
|
|
1335
|
+
if (!close) return false;
|
|
1336
|
+
const marker = close[1] ?? "";
|
|
1337
|
+
return marker[0] === open[0] && marker.length >= open.length;
|
|
1338
|
+
}
|
|
1339
|
+
function splitMarkdownSegments(text) {
|
|
1340
|
+
const segments = [];
|
|
1341
|
+
const lines = text.split("\n");
|
|
1342
|
+
let start = 0;
|
|
1343
|
+
let offset = 0;
|
|
1344
|
+
let fence = null;
|
|
1345
|
+
let kind = "prose";
|
|
1346
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1347
|
+
const line = lines[i] ?? "";
|
|
1348
|
+
const last = i === lines.length - 1;
|
|
1349
|
+
const lineEnd = offset + line.length + (last ? 0 : 1);
|
|
1350
|
+
if (fence === null) {
|
|
1351
|
+
const open = FENCE_OPEN.exec(line);
|
|
1352
|
+
if (open) {
|
|
1353
|
+
if (offset > start) segments.push({ kind: "prose", text: text.slice(start, offset) });
|
|
1354
|
+
start = offset;
|
|
1355
|
+
kind = "code";
|
|
1356
|
+
fence = open[1] ?? "```";
|
|
1357
|
+
}
|
|
1358
|
+
} else if (closesFence(fence, line)) {
|
|
1359
|
+
segments.push({ kind: "code", text: text.slice(start, lineEnd) });
|
|
1360
|
+
start = lineEnd;
|
|
1361
|
+
kind = "prose";
|
|
1362
|
+
fence = null;
|
|
1363
|
+
}
|
|
1364
|
+
offset = lineEnd;
|
|
1365
|
+
}
|
|
1366
|
+
if (text.length > start) segments.push({ kind, text: text.slice(start) });
|
|
1367
|
+
return segments;
|
|
1368
|
+
}
|
|
1369
|
+
var LINE_SHAPE_PREFIX = /^[ \t]*(#{1,6}[ \t]|>+[ \t]?|[-*+][ \t]|\d{1,3}[.)][ \t])/;
|
|
1370
|
+
var LINK_SPAN = /\[[^\]]*\]\(((?:[^()]|\([^()]*\))*)\)/g;
|
|
1371
|
+
var LINK_DEF_LINE = /^( {0,3}\[[^\]]+\]:[ \t]*)(\S+)([ \t]*\r?)$/;
|
|
1372
|
+
function markerClass(marker) {
|
|
1373
|
+
if (marker === "") return "";
|
|
1374
|
+
const head = marker[0];
|
|
1375
|
+
if (head === "#") return "h";
|
|
1376
|
+
if (head === ">") return "q";
|
|
1377
|
+
if (head === "-" || head === "*" || head === "+") return "l";
|
|
1378
|
+
return "n";
|
|
1379
|
+
}
|
|
1380
|
+
function lineSignature(line) {
|
|
1381
|
+
const marker = LINE_SHAPE_PREFIX.exec(line)?.[1]?.trim() ?? "";
|
|
1382
|
+
const cls = markerClass(marker);
|
|
1383
|
+
const indent = cls === "" ? (/^[ \t]*/.exec(line) ?? [""])[0].replace(/\t/g, " ").length : "i";
|
|
1384
|
+
const pipes = (line.match(/\|/g) ?? []).length;
|
|
1385
|
+
const links = (line.match(LINK_SPAN) ?? []).length + (LINK_DEF_LINE.test(line) ? 1 : 0);
|
|
1386
|
+
return `${indent}|${cls}|${pipes}|${links}`;
|
|
1387
|
+
}
|
|
1388
|
+
function isBlankLine(line) {
|
|
1389
|
+
return line.trim() === "";
|
|
1390
|
+
}
|
|
1391
|
+
function indentWidth(line) {
|
|
1392
|
+
return (/^[ \t]*/.exec(line) ?? [""])[0].replace(/\t/g, " ").length;
|
|
1393
|
+
}
|
|
1394
|
+
function isStructuralLine(line) {
|
|
1395
|
+
if (isBlankLine(line)) return false;
|
|
1396
|
+
return LINE_SHAPE_PREFIX.test(line) || line.includes("|") || LINK_DEF_LINE.test(line) || indentWidth(line) > 3;
|
|
1397
|
+
}
|
|
1398
|
+
function paragraphBlocks(text) {
|
|
1399
|
+
const blocks = [];
|
|
1400
|
+
let cur = null;
|
|
1401
|
+
for (const line of text.split("\n")) {
|
|
1402
|
+
if (isBlankLine(line)) {
|
|
1403
|
+
if (cur !== null) blocks.push(cur);
|
|
1404
|
+
cur = null;
|
|
1405
|
+
} else {
|
|
1406
|
+
(cur ??= []).push(line);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
if (cur !== null) blocks.push(cur);
|
|
1410
|
+
return blocks;
|
|
1411
|
+
}
|
|
1412
|
+
function blockLinks(block) {
|
|
1413
|
+
let n = 0;
|
|
1414
|
+
for (const line of block) {
|
|
1415
|
+
n += (line.match(LINK_SPAN) ?? []).length + (LINK_DEF_LINE.test(line) ? 1 : 0);
|
|
1416
|
+
}
|
|
1417
|
+
return n;
|
|
1418
|
+
}
|
|
1419
|
+
function spliceLineShape(want, got) {
|
|
1420
|
+
const w = LINE_SHAPE_PREFIX.exec(want);
|
|
1421
|
+
if (w === null) return got;
|
|
1422
|
+
const g = LINE_SHAPE_PREFIX.exec(got);
|
|
1423
|
+
if (g === null || markerClass((w[1] ?? "").trim()) !== markerClass((g[1] ?? "").trim())) {
|
|
1424
|
+
return got;
|
|
1425
|
+
}
|
|
1426
|
+
return `${w[0]}${got.slice(g[0].length)}`;
|
|
1427
|
+
}
|
|
1428
|
+
function repairShape(original, translated) {
|
|
1429
|
+
const wantLines = original.split("\n");
|
|
1430
|
+
const gotLines = translated.split("\n");
|
|
1431
|
+
const wantBlocks = paragraphBlocks(original);
|
|
1432
|
+
let gotBlocks = paragraphBlocks(translated);
|
|
1433
|
+
if (wantBlocks.length !== gotBlocks.length) {
|
|
1434
|
+
const wantFilled = wantLines.filter((line) => !isBlankLine(line));
|
|
1435
|
+
const gotFilled = gotLines.filter((line) => !isBlankLine(line));
|
|
1436
|
+
if (wantFilled.length !== gotFilled.length) {
|
|
1437
|
+
return { text: null, error: `line count changed (${wantLines.length} -> ${gotLines.length})` };
|
|
1438
|
+
}
|
|
1439
|
+
for (let index = 0; index < wantFilled.length; index++) {
|
|
1440
|
+
const sw = lineSignature(wantFilled[index] ?? "");
|
|
1441
|
+
const sg = lineSignature(gotFilled[index] ?? "");
|
|
1442
|
+
if (sw !== sg) {
|
|
1443
|
+
return { text: null, error: `structure changed at line ${index + 1} (${sw} -> ${sg})` };
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
let cursor = 0;
|
|
1447
|
+
gotBlocks = wantBlocks.map((block) => block.map(() => gotFilled[cursor++] ?? ""));
|
|
1448
|
+
}
|
|
1449
|
+
const repaired = [];
|
|
1450
|
+
let hint = 0;
|
|
1451
|
+
for (let index = 0; index < wantBlocks.length; index++) {
|
|
1452
|
+
const wb = wantBlocks[index];
|
|
1453
|
+
const gb = gotBlocks[index] ?? [];
|
|
1454
|
+
while (hint < wantLines.length && wantLines[hint] !== wb[0]) hint++;
|
|
1455
|
+
const start = hint + 1;
|
|
1456
|
+
const wStruct = wb.filter(isStructuralLine);
|
|
1457
|
+
const gStruct = gb.filter(isStructuralLine);
|
|
1458
|
+
if (wStruct.length !== gStruct.length) {
|
|
1459
|
+
return { text: null, error: `structure changed at line ${start} (markers ${wStruct.length} -> ${gStruct.length})` };
|
|
1460
|
+
}
|
|
1461
|
+
if (isStructuralLine(wb[0] ?? "") !== isStructuralLine(gb[0] ?? "")) {
|
|
1462
|
+
return { text: null, error: `structure changed at line ${start} (block start swapped)` };
|
|
1463
|
+
}
|
|
1464
|
+
if (blockLinks(wb) !== blockLinks(gb)) {
|
|
1465
|
+
return { text: null, error: `structure changed at line ${start} (links ${blockLinks(wb)} -> ${blockLinks(gb)})` };
|
|
1466
|
+
}
|
|
1467
|
+
for (let k = 0; k < wStruct.length; k++) {
|
|
1468
|
+
const sw = lineSignature(wStruct[k] ?? "");
|
|
1469
|
+
const sg = lineSignature(gStruct[k] ?? "");
|
|
1470
|
+
if (sw !== sg) {
|
|
1471
|
+
return { text: null, error: `structure changed at line ${start} (${sw} -> ${sg})` };
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
let cursor = 0;
|
|
1475
|
+
repaired.push(
|
|
1476
|
+
gb.map((line) => {
|
|
1477
|
+
if (!isStructuralLine(line)) return line;
|
|
1478
|
+
return spliceLineShape(wStruct[cursor++] ?? line, line);
|
|
1479
|
+
})
|
|
1480
|
+
);
|
|
1481
|
+
hint += wb.length;
|
|
1482
|
+
}
|
|
1483
|
+
const out = [];
|
|
1484
|
+
let bi = 0;
|
|
1485
|
+
let inBlock = false;
|
|
1486
|
+
for (const line of wantLines) {
|
|
1487
|
+
if (isBlankLine(line)) {
|
|
1488
|
+
inBlock = false;
|
|
1489
|
+
out.push(line);
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
if (!inBlock) {
|
|
1493
|
+
out.push(...repaired[bi++] ?? [line]);
|
|
1494
|
+
inBlock = true;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return { text: out.join("\n"), error: null };
|
|
1498
|
+
}
|
|
1499
|
+
function restoreLinkTargets(original, translated) {
|
|
1500
|
+
const urls = [];
|
|
1501
|
+
for (const match of original.matchAll(LINK_SPAN)) urls.push(match[1] ?? "");
|
|
1502
|
+
let out = translated;
|
|
1503
|
+
if (urls.length > 0) {
|
|
1504
|
+
let cursor = 0;
|
|
1505
|
+
out = out.replace(LINK_SPAN, (span) => {
|
|
1506
|
+
const open = span.indexOf("(", span.lastIndexOf("]"));
|
|
1507
|
+
const close = span.lastIndexOf(")");
|
|
1508
|
+
const url = urls[cursor++] ?? "";
|
|
1509
|
+
return `${span.slice(0, open + 1)}${url}${span.slice(close)}`;
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
const defs = [];
|
|
1513
|
+
for (const line of original.split("\n")) {
|
|
1514
|
+
const def = LINK_DEF_LINE.exec(line);
|
|
1515
|
+
if (def) defs.push(def[2] ?? "");
|
|
1516
|
+
}
|
|
1517
|
+
if (defs.length > 0) {
|
|
1518
|
+
let cursor = 0;
|
|
1519
|
+
out = out.split("\n").map((line) => {
|
|
1520
|
+
const def = LINK_DEF_LINE.exec(line);
|
|
1521
|
+
if (!def) return line;
|
|
1522
|
+
return `${def[1] ?? ""}${defs[cursor++] ?? def[2] ?? ""}${def[3] ?? ""}`;
|
|
1523
|
+
}).join("\n");
|
|
1524
|
+
}
|
|
1525
|
+
return out;
|
|
1526
|
+
}
|
|
1527
|
+
function linkCountsMatch(a, b) {
|
|
1528
|
+
return blockLinks(a.split("\n")) === blockLinks(b.split("\n"));
|
|
1491
1529
|
}
|
|
1492
1530
|
|
|
1493
1531
|
// src/server/dispatcher.ts
|
|
1494
1532
|
var CHANNEL_ID = "openai";
|
|
1495
|
-
var ContentRejectedError = class extends Error {
|
|
1496
|
-
};
|
|
1497
1533
|
function replyPieceKey(piece) {
|
|
1498
1534
|
return `${piece.block}:${piece.index}`;
|
|
1499
1535
|
}
|
|
1536
|
+
function recordLedgerFailure(ledger, keys, detail) {
|
|
1537
|
+
for (const key of keys) {
|
|
1538
|
+
if (!ledger.has(key)) ledger.set(key, detail);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
function blockFailureDetail(ledger, block, missing) {
|
|
1542
|
+
for (const index of missing) {
|
|
1543
|
+
const detail = ledger.get(replyPieceKey({ block, index }));
|
|
1544
|
+
if (detail !== void 0) return detail;
|
|
1545
|
+
}
|
|
1546
|
+
return "no translation came back";
|
|
1547
|
+
}
|
|
1548
|
+
function stripEdges(chunk) {
|
|
1549
|
+
const head = /^[ \t\r\n]*/.exec(chunk)[0];
|
|
1550
|
+
if (head.length >= chunk.length) return null;
|
|
1551
|
+
const body = chunk.slice(head.length);
|
|
1552
|
+
const tail = /[ \t\r\n]*$/.exec(body)[0];
|
|
1553
|
+
const core = body.slice(0, body.length - tail.length);
|
|
1554
|
+
return core === "" ? null : { core, head, tail };
|
|
1555
|
+
}
|
|
1500
1556
|
var TranslationDispatcher = class {
|
|
1501
1557
|
configManager;
|
|
1502
1558
|
cache;
|
|
1503
|
-
credentials;
|
|
1504
|
-
masking = new ContentMaskingPipeline();
|
|
1505
1559
|
adapters = /* @__PURE__ */ new Map();
|
|
1506
|
-
circuitStates = /* @__PURE__ */ new Map();
|
|
1507
1560
|
/** 正文请求的串行队列尾,保证同时最多一个在途请求。 */
|
|
1508
1561
|
replyTail = Promise.resolve();
|
|
1509
1562
|
constructor(configManager, cache, credentials) {
|
|
1510
1563
|
this.configManager = configManager;
|
|
1511
1564
|
this.cache = cache;
|
|
1512
|
-
this.
|
|
1513
|
-
this.registerAdapter(new OpenAiCompatibleAdapter(this.credentials));
|
|
1565
|
+
this.registerAdapter(new OpenAiCompatibleAdapter(credentials ?? { getApiKey: () => "" }));
|
|
1514
1566
|
}
|
|
1515
1567
|
registerAdapter(adapter) {
|
|
1516
1568
|
this.adapters.set(adapter.id, adapter);
|
|
@@ -1518,81 +1570,97 @@ var TranslationDispatcher = class {
|
|
|
1518
1570
|
/**
|
|
1519
1571
|
* 翻译回答正文的块:本次唯一的翻译入口。
|
|
1520
1572
|
*
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
1523
|
-
*
|
|
1573
|
+
* 每个块先按 markdown 结构切成段:代码围栏与纯空白段**逐字保留、永不送
|
|
1574
|
+
* 模型**;散文段再按输入上限切片、各自成片段。相邻片段打包成一个请求;整批
|
|
1575
|
+
* 失败时退回逐片段单发。每个片段的译文过 `repairShape`(段落块对齐 + 前缀
|
|
1576
|
+
* 修回 + 空行重排)并通过链接目标回填才算修好。
|
|
1577
|
+
*
|
|
1578
|
+
* **形状问题不否决内容**:修不好的片段自动重掷一次;再修不好就照收模型的
|
|
1579
|
+
* 译文(链接数不等时不回填,其余原样)——原文一键可回,可读的译文优先于
|
|
1580
|
+
* 一根红线。失败只剩通道伤一种:超时、断流、空返回,红实线 + 悬停报细节,
|
|
1581
|
+
* 救活由用户的点击发起。
|
|
1524
1582
|
*
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
1583
|
+
* 调用方传进来的每个块要么整块译出、要么整块保持原文:任一片段缺失都让该块
|
|
1584
|
+
* 作废,避免半中半英的段落;失败块带 `detail` 一句技术细节。每行只跑首跑
|
|
1585
|
+
* 一次,通道恢复后由点击救活。
|
|
1586
|
+
*
|
|
1587
|
+
* 客户端按与宿主同源的 4096 估算 token 切批,所以一个 markdown 块可能跨多次
|
|
1588
|
+
* 调用;每次调用都独立决定成败,不会出现「前一段已挂译文、后一段失败」的
|
|
1589
|
+
* 半截结果。
|
|
1527
1590
|
*/
|
|
1528
1591
|
async translateReplyBlocks(blocks) {
|
|
1529
1592
|
const results = blocks.map((original) => ({
|
|
1530
1593
|
original,
|
|
1531
1594
|
translated: original,
|
|
1532
1595
|
ok: false,
|
|
1533
|
-
cached: false
|
|
1534
|
-
channel: "none"
|
|
1596
|
+
cached: false
|
|
1535
1597
|
}));
|
|
1536
1598
|
const config = this.configManager.getConfig();
|
|
1537
1599
|
if (!config.enabled) return results;
|
|
1538
1600
|
const adapter = this.adapters.get(CHANNEL_ID);
|
|
1539
1601
|
if (!adapter || !adapter.isAvailable(config)) return results;
|
|
1540
|
-
|
|
1541
|
-
try {
|
|
1542
|
-
return await this.serveReplyBlocks(blocks, results, adapter, config);
|
|
1543
|
-
} finally {
|
|
1544
|
-
const state = this.circuitStates.get(CHANNEL_ID);
|
|
1545
|
-
if (state) state.probeInFlight = false;
|
|
1546
|
-
}
|
|
1602
|
+
return this.serveReplyBlocks(blocks, results, adapter, config);
|
|
1547
1603
|
}
|
|
1548
|
-
/**
|
|
1604
|
+
/** 通道可用之后的正文执行段:切段、打包、请求、拼装。 */
|
|
1549
1605
|
async serveReplyBlocks(blocks, results, adapter, config) {
|
|
1550
1606
|
const pieces = [];
|
|
1607
|
+
const partsByBlock = /* @__PURE__ */ new Map();
|
|
1551
1608
|
for (let block = 0; block < blocks.length; block++) {
|
|
1552
|
-
const
|
|
1553
|
-
if (
|
|
1554
|
-
const cached = this.cache.get(
|
|
1609
|
+
const raw = blocks[block] ?? "";
|
|
1610
|
+
if (raw.trim() === "") continue;
|
|
1611
|
+
const cached = this.cache.get(raw.trim().toLowerCase());
|
|
1555
1612
|
if (cached) {
|
|
1556
|
-
results[block] = {
|
|
1557
|
-
original: blocks[block],
|
|
1558
|
-
translated: cached,
|
|
1559
|
-
ok: true,
|
|
1560
|
-
cached: true,
|
|
1561
|
-
channel: "cache"
|
|
1562
|
-
};
|
|
1613
|
+
results[block] = { original: raw, translated: cached, ok: true, cached: true };
|
|
1563
1614
|
continue;
|
|
1564
1615
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
}
|
|
1616
|
+
const parts = [];
|
|
1617
|
+
let count = 0;
|
|
1618
|
+
for (const segment of splitMarkdownSegments(raw)) {
|
|
1619
|
+
const edges = segment.kind === "code" ? null : stripEdges(segment.text);
|
|
1620
|
+
if (edges === null) {
|
|
1621
|
+
parts.push({ verbatim: segment.text });
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
for (const chunk of splitOversizedBlock(segment.text)) {
|
|
1625
|
+
const pieceEdges = stripEdges(chunk);
|
|
1626
|
+
if (pieceEdges === null) {
|
|
1627
|
+
parts.push({ verbatim: chunk });
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
parts.push({ piece: count });
|
|
1631
|
+
pieces.push({ block, index: count, text: pieceEdges.core, head: pieceEdges.head, tail: pieceEdges.tail });
|
|
1632
|
+
count += 1;
|
|
1633
|
+
}
|
|
1573
1634
|
}
|
|
1635
|
+
partsByBlock.set(block, parts);
|
|
1574
1636
|
}
|
|
1637
|
+
const ledger = /* @__PURE__ */ new Map();
|
|
1575
1638
|
const translated = /* @__PURE__ */ new Map();
|
|
1576
1639
|
for (const batch of packPieces(pieces)) {
|
|
1577
1640
|
const outcome = await this.runReplySerial(
|
|
1578
|
-
() => this.translateReplyBatch(adapter, batch, config)
|
|
1641
|
+
() => this.translateReplyBatch(adapter, batch, config, ledger)
|
|
1579
1642
|
);
|
|
1580
1643
|
for (const [key, value] of outcome) translated.set(key, value);
|
|
1581
1644
|
}
|
|
1582
|
-
const
|
|
1583
|
-
for (const piece of pieces) totals.set(piece.block, (totals.get(piece.block) ?? 0) + 1);
|
|
1584
|
-
for (const [block, total] of totals) {
|
|
1645
|
+
for (const block of partsByBlock.keys()) {
|
|
1585
1646
|
const parts = [];
|
|
1586
|
-
|
|
1587
|
-
for (
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
break;
|
|
1647
|
+
const missing = [];
|
|
1648
|
+
for (const part of partsByBlock.get(block) ?? []) {
|
|
1649
|
+
if ("verbatim" in part) {
|
|
1650
|
+
parts.push(part.verbatim);
|
|
1651
|
+
continue;
|
|
1592
1652
|
}
|
|
1593
|
-
|
|
1653
|
+
const value = translated.get(replyPieceKey({ block, index: part.piece }));
|
|
1654
|
+
if (value === void 0) {
|
|
1655
|
+
missing.push(part.piece);
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
parts.push(value);
|
|
1659
|
+
}
|
|
1660
|
+
if (missing.length > 0) {
|
|
1661
|
+
results[block].detail = blockFailureDetail(ledger, block, missing);
|
|
1662
|
+
continue;
|
|
1594
1663
|
}
|
|
1595
|
-
if (!complete) continue;
|
|
1596
1664
|
const original = blocks[block];
|
|
1597
1665
|
const finalText = parts.join("");
|
|
1598
1666
|
this.cache.set(original.trim().toLowerCase(), finalText);
|
|
@@ -1600,57 +1668,80 @@ var TranslationDispatcher = class {
|
|
|
1600
1668
|
original,
|
|
1601
1669
|
translated: finalText,
|
|
1602
1670
|
ok: true,
|
|
1603
|
-
cached: false
|
|
1604
|
-
channel: CHANNEL_ID
|
|
1671
|
+
cached: false
|
|
1605
1672
|
};
|
|
1606
1673
|
}
|
|
1607
1674
|
return results;
|
|
1608
1675
|
}
|
|
1609
1676
|
/**
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1677
|
+
* 一整批一次请求;批内修不好的片段与整批的失败都退回逐片段单发。单发仍
|
|
1678
|
+
* 修不好的**照收**(见 {@link translateReplyBlocks})——只有通道伤才让片段
|
|
1679
|
+
* 缺失,失败的片段不进缓存。
|
|
1613
1680
|
*
|
|
1614
|
-
*
|
|
1615
|
-
*
|
|
1616
|
-
* 坏内容只作废坏内容自己,不把其他行挡在冷却期外。
|
|
1681
|
+
* 每次通道失败都记进片段账本({@link FailureLedger}):批请求的失败摊到
|
|
1682
|
+
* 批内每个片段,单发的失败只记该片段;块级细节由缺失片段的账本合成。
|
|
1617
1683
|
*/
|
|
1618
|
-
async translateReplyBatch(adapter, batch, config) {
|
|
1684
|
+
async translateReplyBatch(adapter, batch, config, ledger) {
|
|
1685
|
+
const out = /* @__PURE__ */ new Map();
|
|
1686
|
+
const stragglers = [];
|
|
1619
1687
|
try {
|
|
1620
|
-
const
|
|
1621
|
-
|
|
1622
|
-
|
|
1688
|
+
const answers = await this.fetchReplyAnswers(adapter, batch, config);
|
|
1689
|
+
batch.forEach((piece, index) => {
|
|
1690
|
+
const answer = answers[index] ?? "";
|
|
1691
|
+
const repair = repairShape(piece.text, answer);
|
|
1692
|
+
if (repair.text !== null) {
|
|
1693
|
+
out.set(replyPieceKey(piece), this.assemble(piece, repair.text, true));
|
|
1694
|
+
} else {
|
|
1695
|
+
stragglers.push(piece);
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1623
1698
|
} catch (err) {
|
|
1624
|
-
|
|
1699
|
+
recordLedgerFailure(ledger, batch.map((piece) => replyPieceKey(piece)), describeError(err));
|
|
1625
1700
|
console.warn(
|
|
1626
1701
|
`[dsh-chat-translate] reply batch of ${batch.length} failed, retrying per block: ${describeError(err)}`
|
|
1627
1702
|
);
|
|
1703
|
+
stragglers.push(...batch);
|
|
1628
1704
|
}
|
|
1629
|
-
const
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1705
|
+
for (const piece of stragglers) {
|
|
1706
|
+
const key = replyPieceKey(piece);
|
|
1707
|
+
if (out.has(key)) continue;
|
|
1708
|
+
let answer = null;
|
|
1709
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1710
|
+
try {
|
|
1711
|
+
answer = (await this.fetchReplyAnswers(adapter, [piece], config))[0] ?? "";
|
|
1712
|
+
} catch (err) {
|
|
1713
|
+
if (answer === null) {
|
|
1714
|
+
recordLedgerFailure(ledger, [key], describeError(err));
|
|
1715
|
+
console.warn(
|
|
1716
|
+
`[dsh-chat-translate] reply block ${piece.block} #${piece.index} failed, keeping the original: ${describeError(err)}`
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
break;
|
|
1634
1720
|
}
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1721
|
+
if (repairShape(piece.text, answer).text !== null) break;
|
|
1722
|
+
}
|
|
1723
|
+
if (answer === null) continue;
|
|
1724
|
+
const repair = repairShape(piece.text, answer);
|
|
1725
|
+
if (repair.text !== null) {
|
|
1726
|
+
out.set(key, this.assemble(piece, repair.text, true));
|
|
1727
|
+
continue;
|
|
1640
1728
|
}
|
|
1729
|
+
console.warn(
|
|
1730
|
+
`[dsh-chat-translate] reply block ${piece.block} #${piece.index} shape kept as the model wrote it: ${repair.error}`
|
|
1731
|
+
);
|
|
1732
|
+
out.set(key, this.assemble(piece, answer, linkCountsMatch(piece.text, answer)));
|
|
1641
1733
|
}
|
|
1642
1734
|
return out;
|
|
1643
1735
|
}
|
|
1644
|
-
/**
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
this.recordSuccess(CHANNEL_ID);
|
|
1648
|
-
} else {
|
|
1649
|
-
this.recordFailure(CHANNEL_ID);
|
|
1650
|
-
}
|
|
1736
|
+
/** 片段的最终拼装:头尾空白 + (可选)链接回填 + 译文。 */
|
|
1737
|
+
assemble(piece, text, restoreLinks) {
|
|
1738
|
+
return piece.head + (restoreLinks ? restoreLinkTargets(piece.text, text) : text) + piece.tail;
|
|
1651
1739
|
}
|
|
1652
|
-
/**
|
|
1653
|
-
|
|
1740
|
+
/**
|
|
1741
|
+
* 发出一次正文请求,返回逐片段的裸答案(打包标记已剥除、首尾已 trim)。
|
|
1742
|
+
* 空返回与打包拆不回都按通道失败抛出——形状对不对在这里不算败因。
|
|
1743
|
+
*/
|
|
1744
|
+
async fetchReplyAnswers(adapter, batch, config) {
|
|
1654
1745
|
const timeout = config.aiTimeoutMs || 6e5;
|
|
1655
1746
|
const abortCtrl = new AbortController();
|
|
1656
1747
|
const timer = setTimeout(() => abortCtrl.abort(), timeout);
|
|
@@ -1666,41 +1757,33 @@ var TranslationDispatcher = class {
|
|
|
1666
1757
|
} else {
|
|
1667
1758
|
const format = createBatchFormat();
|
|
1668
1759
|
const answer = await adapter.translate(
|
|
1669
|
-
buildBatchPayload(
|
|
1760
|
+
buildBatchPayload(
|
|
1761
|
+
batch.map((piece) => piece.text),
|
|
1762
|
+
format
|
|
1763
|
+
),
|
|
1670
1764
|
abortCtrl.signal,
|
|
1671
1765
|
config,
|
|
1672
1766
|
{ maxTokens: REPLY_MAX_OUTPUT_TOKENS, mode: "blocks" }
|
|
1673
1767
|
);
|
|
1674
1768
|
const parts = splitBatchTranslation(answer, format, batch.length);
|
|
1675
1769
|
if (parts === null) {
|
|
1676
|
-
throw new
|
|
1770
|
+
throw new Error("batch markers did not survive the answer");
|
|
1677
1771
|
}
|
|
1678
1772
|
answers = parts;
|
|
1679
1773
|
}
|
|
1774
|
+
} catch (err) {
|
|
1775
|
+
if (abortCtrl.signal.aborted) {
|
|
1776
|
+
throw new Error(`channel timed out after ${timeout}ms`);
|
|
1777
|
+
}
|
|
1778
|
+
throw err;
|
|
1680
1779
|
} finally {
|
|
1681
1780
|
clearTimeout(timer);
|
|
1682
1781
|
}
|
|
1683
|
-
const
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
}
|
|
1689
|
-
if (hasBatchResidue(answer)) {
|
|
1690
|
-
throw new ContentRejectedError("reply translation left a block marker behind");
|
|
1691
|
-
}
|
|
1692
|
-
let finalText;
|
|
1693
|
-
try {
|
|
1694
|
-
finalText = piece.mask.unmask(answer);
|
|
1695
|
-
} catch (unmaskErr) {
|
|
1696
|
-
throw new ContentRejectedError(describeError(unmaskErr));
|
|
1697
|
-
}
|
|
1698
|
-
if (hasMaskResidue(finalText)) {
|
|
1699
|
-
throw new ContentRejectedError("reply translation left a mask placeholder behind");
|
|
1700
|
-
}
|
|
1701
|
-
out.set(replyPieceKey(piece), finalText);
|
|
1702
|
-
});
|
|
1703
|
-
return out;
|
|
1782
|
+
const stripped = answers.map((answer) => stripBatchMarkers(answer).trim());
|
|
1783
|
+
if (stripped.some((answer) => answer === "")) {
|
|
1784
|
+
throw new Error("translation came back empty");
|
|
1785
|
+
}
|
|
1786
|
+
return stripped;
|
|
1704
1787
|
}
|
|
1705
1788
|
/** 串行执行器:同时最多一个在途请求。 */
|
|
1706
1789
|
runReplySerial(task) {
|
|
@@ -1713,10 +1796,7 @@ var TranslationDispatcher = class {
|
|
|
1713
1796
|
}
|
|
1714
1797
|
/**
|
|
1715
1798
|
* 通道探针:设置面板的「测试 AI 通道」按钮走这里。探测文本用一句英文正文,
|
|
1716
|
-
*
|
|
1717
|
-
*
|
|
1718
|
-
* 探针是用户主动发起的连通性检查,因此不写也不读熔断账本:连点几次测试
|
|
1719
|
-
* 不该把正文翻译挡在冷却期外,正文的失败也不该让按钮变哑。
|
|
1799
|
+
* 与真实请求同形;它一次请求都不写正文账本,正文的成败也不影响按钮。
|
|
1720
1800
|
*/
|
|
1721
1801
|
async testChannel(channelId) {
|
|
1722
1802
|
const adapter = this.adapters.get(channelId);
|
|
@@ -1748,52 +1828,6 @@ var TranslationDispatcher = class {
|
|
|
1748
1828
|
return { ok: false, latencyMs: Date.now() - start, error: describeError(err) };
|
|
1749
1829
|
}
|
|
1750
1830
|
}
|
|
1751
|
-
isCircuitOpen(channelId) {
|
|
1752
|
-
let state = this.circuitStates.get(channelId);
|
|
1753
|
-
if (!state) return false;
|
|
1754
|
-
if (state.state === "open") {
|
|
1755
|
-
if (Date.now() >= state.openUntil) {
|
|
1756
|
-
state.state = "half-open";
|
|
1757
|
-
state.probeInFlight = true;
|
|
1758
|
-
return false;
|
|
1759
|
-
}
|
|
1760
|
-
return true;
|
|
1761
|
-
}
|
|
1762
|
-
if (state.state === "half-open") {
|
|
1763
|
-
if (state.probeInFlight) return true;
|
|
1764
|
-
state.probeInFlight = true;
|
|
1765
|
-
return false;
|
|
1766
|
-
}
|
|
1767
|
-
return false;
|
|
1768
|
-
}
|
|
1769
|
-
recordSuccess(channelId) {
|
|
1770
|
-
const state = this.circuitStates.get(channelId);
|
|
1771
|
-
if (state) {
|
|
1772
|
-
state.state = "closed";
|
|
1773
|
-
state.failureCount = 0;
|
|
1774
|
-
state.openUntil = 0;
|
|
1775
|
-
state.probeInFlight = false;
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
recordFailure(channelId) {
|
|
1779
|
-
let state = this.circuitStates.get(channelId);
|
|
1780
|
-
if (!state) {
|
|
1781
|
-
state = { state: "closed", failureCount: 0, openUntil: 0, probeInFlight: false };
|
|
1782
|
-
this.circuitStates.set(channelId, state);
|
|
1783
|
-
}
|
|
1784
|
-
if (state.state === "half-open") {
|
|
1785
|
-
state.state = "open";
|
|
1786
|
-
state.failureCount = 3;
|
|
1787
|
-
state.openUntil = Date.now() + 3e4;
|
|
1788
|
-
state.probeInFlight = false;
|
|
1789
|
-
return;
|
|
1790
|
-
}
|
|
1791
|
-
state.failureCount++;
|
|
1792
|
-
if (state.failureCount >= 3) {
|
|
1793
|
-
state.state = "open";
|
|
1794
|
-
state.openUntil = Date.now() + 3e4;
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
1831
|
};
|
|
1798
1832
|
|
|
1799
1833
|
// src/server/router.ts
|