@lynn123411/dsh-chat-translate 1.1.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/LICENSE +21 -0
- package/README.md +35 -0
- package/cordis.patch.yml +6 -0
- package/dsh.plugin.json +13 -0
- package/lib/client.js +1565 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +1069 -0
- package/lib/index.js.map +7 -0
- package/lib/types/client/index.d.ts +21 -0
- package/lib/types/client/settings/store.d.ts +53 -0
- package/lib/types/client/settings/styles.d.ts +1 -0
- package/lib/types/client/settings/ui.d.ts +3 -0
- package/lib/types/client/translate/api.d.ts +24 -0
- package/lib/types/client/translate/client-cache.d.ts +14 -0
- package/lib/types/client/translate/lazy.d.ts +12 -0
- package/lib/types/client/translate/mount.d.ts +40 -0
- package/lib/types/client/translate/observer.d.ts +25 -0
- package/lib/types/client/translate/viewport-observer.d.ts +36 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/server/adapters/base.d.ts +1 -0
- package/lib/types/server/adapters/bing.d.ts +15 -0
- package/lib/types/server/adapters/openai.d.ts +10 -0
- package/lib/types/server/cache.d.ts +14 -0
- package/lib/types/server/config.d.ts +24 -0
- package/lib/types/server/credentials.d.ts +39 -0
- package/lib/types/server/dispatcher.d.ts +40 -0
- package/lib/types/server/pipeline/masking.d.ts +7 -0
- package/lib/types/server/router.d.ts +5 -0
- package/lib/types/server/types.d.ts +40 -0
- package/package.json +79 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
// src/server/config.ts
|
|
2
|
+
import * as fs2 from "node:fs/promises";
|
|
3
|
+
import * as path2 from "node:path";
|
|
4
|
+
import * as os2 from "node:os";
|
|
5
|
+
|
|
6
|
+
// src/server/credentials.ts
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as fsp from "node:fs/promises";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
var TRANSLATE_API_KEY_REF = "TRANSLATE_API_KEY";
|
|
12
|
+
function parseRefs(yaml) {
|
|
13
|
+
const refs = {};
|
|
14
|
+
let inRefs = false;
|
|
15
|
+
for (const line of yaml.split(/\r?\n/)) {
|
|
16
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
19
|
+
if (indent === 0) {
|
|
20
|
+
inRefs = trimmed === "refs:" || trimmed.startsWith("refs:");
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!inRefs) continue;
|
|
24
|
+
const m = /^([A-Za-z0-9_.\-]+):\s*(.*)$/.exec(trimmed);
|
|
25
|
+
if (!m) continue;
|
|
26
|
+
let value = m[2].trim();
|
|
27
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
28
|
+
const q = value[0];
|
|
29
|
+
let close = -1;
|
|
30
|
+
for (let i = 1; i < value.length; i++) {
|
|
31
|
+
if (q === '"' && value[i] === "\\") {
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (value[i] === q) {
|
|
36
|
+
close = i;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (close > 0) {
|
|
41
|
+
let inner = value.slice(1, close);
|
|
42
|
+
if (q === '"') inner = inner.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
43
|
+
value = inner;
|
|
44
|
+
} else {
|
|
45
|
+
value = value.slice(1);
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
const hashIdx = value.indexOf(" #");
|
|
49
|
+
if (hashIdx >= 0) value = value.slice(0, hashIdx).trim();
|
|
50
|
+
}
|
|
51
|
+
if (value) refs[m[1]] = value;
|
|
52
|
+
}
|
|
53
|
+
return refs;
|
|
54
|
+
}
|
|
55
|
+
var KEY_CACHE_TTL_MS = 1e3;
|
|
56
|
+
var CredentialsReader = class {
|
|
57
|
+
filePath;
|
|
58
|
+
cachedKey = "";
|
|
59
|
+
cachedAt = 0;
|
|
60
|
+
constructor() {
|
|
61
|
+
const dshHome = process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
|
|
62
|
+
this.filePath = path.join(dshHome, ".credentials.yaml");
|
|
63
|
+
}
|
|
64
|
+
/** Read the file fresh on every call so a key added at runtime takes effect immediately. */
|
|
65
|
+
readRefs() {
|
|
66
|
+
try {
|
|
67
|
+
const content = fs.readFileSync(this.filePath, "utf-8");
|
|
68
|
+
return parseRefs(content);
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
getApiKey() {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
if (now - this.cachedAt < KEY_CACHE_TTL_MS) {
|
|
76
|
+
return this.cachedKey;
|
|
77
|
+
}
|
|
78
|
+
this.cachedKey = (this.readRefs()[TRANSLATE_API_KEY_REF] || "").trim();
|
|
79
|
+
this.cachedAt = now;
|
|
80
|
+
return this.cachedKey;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Write (or clear) the TRANSLATE_API_KEY ref, preserving every other line of
|
|
84
|
+
* the file (other refs, records section, comments). The file stays the single
|
|
85
|
+
* source of truth for credentials and keeps 0600 permissions. An empty key
|
|
86
|
+
* removes the ref entirely.
|
|
87
|
+
*/
|
|
88
|
+
async setApiKey(apiKey) {
|
|
89
|
+
const normalized = apiKey.trim();
|
|
90
|
+
let lines;
|
|
91
|
+
try {
|
|
92
|
+
lines = fs.readFileSync(this.filePath, "utf-8").split(/\r?\n/);
|
|
93
|
+
} catch {
|
|
94
|
+
lines = ["version: 1", "refs:", "records: {}"];
|
|
95
|
+
}
|
|
96
|
+
const refsStart = lines.findIndex((l) => l.trim() === "refs:" || l.trim().startsWith("refs:"));
|
|
97
|
+
let replaced = false;
|
|
98
|
+
if (refsStart >= 0) {
|
|
99
|
+
for (let i = refsStart + 1; i < lines.length; i++) {
|
|
100
|
+
const indent = lines[i].match(/^\s*/)?.[0].length ?? 0;
|
|
101
|
+
if (indent === 0) break;
|
|
102
|
+
if (/^TRANSLATE_API_KEY\s*:/.test(lines[i].trim())) {
|
|
103
|
+
if (normalized) {
|
|
104
|
+
lines[i] = ` TRANSLATE_API_KEY: "${escapeYaml(normalized)}"`;
|
|
105
|
+
} else {
|
|
106
|
+
lines.splice(i, 1);
|
|
107
|
+
}
|
|
108
|
+
replaced = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!replaced && normalized) {
|
|
114
|
+
if (refsStart >= 0) {
|
|
115
|
+
let insertAt = lines.length;
|
|
116
|
+
for (let i = refsStart + 1; i < lines.length; i++) {
|
|
117
|
+
const indent = lines[i].match(/^\s*/)?.[0].length ?? 0;
|
|
118
|
+
if (indent === 0 && lines[i].trim()) {
|
|
119
|
+
insertAt = i;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
lines.splice(insertAt, 0, ` TRANSLATE_API_KEY: "${escapeYaml(normalized)}"`);
|
|
124
|
+
} else {
|
|
125
|
+
lines.splice(1, 0, "refs:", ` TRANSLATE_API_KEY: "${escapeYaml(normalized)}"`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
129
|
+
const tmpPath = `${this.filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
130
|
+
await fsp.writeFile(tmpPath, lines.join("\n"), "utf-8");
|
|
131
|
+
await fsp.chmod(tmpPath, 384);
|
|
132
|
+
await fsp.rename(tmpPath, this.filePath);
|
|
133
|
+
this.cachedKey = "";
|
|
134
|
+
this.cachedAt = 0;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
function escapeYaml(value) {
|
|
138
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/server/config.ts
|
|
142
|
+
var MAX_CONCURRENCY = 100;
|
|
143
|
+
var AI_TIMEOUT_MIN = 500;
|
|
144
|
+
var AI_TIMEOUT_MAX = 12e4;
|
|
145
|
+
var DEFAULT_CONFIG = {
|
|
146
|
+
enabled: true,
|
|
147
|
+
concurrency: 3,
|
|
148
|
+
timeoutMs: 2e3,
|
|
149
|
+
aiTimeoutMs: 3e4,
|
|
150
|
+
aiEnabled: true,
|
|
151
|
+
bingEnabled: true,
|
|
152
|
+
baseUrl: "",
|
|
153
|
+
model: "",
|
|
154
|
+
targetLang: "zh-Hans"
|
|
155
|
+
};
|
|
156
|
+
var ConfigManager = class {
|
|
157
|
+
config = { ...DEFAULT_CONFIG };
|
|
158
|
+
configPath;
|
|
159
|
+
credentials;
|
|
160
|
+
listeners = /* @__PURE__ */ new Set();
|
|
161
|
+
constructor(credentials) {
|
|
162
|
+
this.credentials = credentials ?? new CredentialsReader();
|
|
163
|
+
const dshHome = process.env.DSH_HOME || path2.join(os2.homedir(), ".dsh");
|
|
164
|
+
this.configPath = path2.join(dshHome, "dsh-chat-translate-config.json");
|
|
165
|
+
}
|
|
166
|
+
async init() {
|
|
167
|
+
try {
|
|
168
|
+
const data = await fs2.readFile(this.configPath, "utf-8");
|
|
169
|
+
const parsed = JSON.parse(data);
|
|
170
|
+
this.config = {
|
|
171
|
+
...DEFAULT_CONFIG,
|
|
172
|
+
...parsed
|
|
173
|
+
};
|
|
174
|
+
} catch {
|
|
175
|
+
this.config = { ...DEFAULT_CONFIG };
|
|
176
|
+
}
|
|
177
|
+
if (!Number.isFinite(this.config.concurrency) || this.config.concurrency < 1) {
|
|
178
|
+
this.config.concurrency = DEFAULT_CONFIG.concurrency;
|
|
179
|
+
} else {
|
|
180
|
+
this.config.concurrency = Math.min(Math.max(Math.round(this.config.concurrency), 1), MAX_CONCURRENCY);
|
|
181
|
+
}
|
|
182
|
+
if (!Number.isFinite(this.config.timeoutMs) || this.config.timeoutMs < 500) {
|
|
183
|
+
this.config.timeoutMs = DEFAULT_CONFIG.timeoutMs;
|
|
184
|
+
} else {
|
|
185
|
+
this.config.timeoutMs = Math.min(Math.max(Math.round(this.config.timeoutMs), 500), 1e4);
|
|
186
|
+
}
|
|
187
|
+
if (!Number.isFinite(this.config.aiTimeoutMs) || this.config.aiTimeoutMs < AI_TIMEOUT_MIN) {
|
|
188
|
+
this.config.aiTimeoutMs = DEFAULT_CONFIG.aiTimeoutMs;
|
|
189
|
+
} else {
|
|
190
|
+
this.config.aiTimeoutMs = Math.min(
|
|
191
|
+
Math.max(Math.round(this.config.aiTimeoutMs), AI_TIMEOUT_MIN),
|
|
192
|
+
AI_TIMEOUT_MAX
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (typeof this.config.enabled !== "boolean") this.config.enabled = DEFAULT_CONFIG.enabled;
|
|
196
|
+
if (typeof this.config.aiEnabled !== "boolean") this.config.aiEnabled = DEFAULT_CONFIG.aiEnabled;
|
|
197
|
+
if (typeof this.config.bingEnabled !== "boolean") this.config.bingEnabled = DEFAULT_CONFIG.bingEnabled;
|
|
198
|
+
if (typeof this.config.baseUrl !== "string") this.config.baseUrl = DEFAULT_CONFIG.baseUrl;
|
|
199
|
+
if (typeof this.config.model !== "string") this.config.model = DEFAULT_CONFIG.model;
|
|
200
|
+
if (!this.config.targetLang || typeof this.config.targetLang !== "string") {
|
|
201
|
+
this.config.targetLang = DEFAULT_CONFIG.targetLang;
|
|
202
|
+
}
|
|
203
|
+
delete this.config.channels;
|
|
204
|
+
}
|
|
205
|
+
getConfig() {
|
|
206
|
+
return { ...this.config };
|
|
207
|
+
}
|
|
208
|
+
/** Whether the AI channel has every required piece: baseUrl, model and key. */
|
|
209
|
+
isAiConfigured() {
|
|
210
|
+
return Boolean(
|
|
211
|
+
this.config.baseUrl.trim() && this.config.model.trim() && this.credentials.getApiKey()
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
getMaskedConfig() {
|
|
215
|
+
return {
|
|
216
|
+
enabled: this.config.enabled,
|
|
217
|
+
concurrency: this.config.concurrency,
|
|
218
|
+
timeoutMs: this.config.timeoutMs,
|
|
219
|
+
aiTimeoutMs: this.config.aiTimeoutMs,
|
|
220
|
+
aiEnabled: this.config.aiEnabled,
|
|
221
|
+
bingEnabled: this.config.bingEnabled,
|
|
222
|
+
baseUrl: this.config.baseUrl,
|
|
223
|
+
model: this.config.model,
|
|
224
|
+
targetLang: this.config.targetLang || "zh-Hans",
|
|
225
|
+
aiConfigured: this.isAiConfigured()
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
onConfigChange(listener) {
|
|
229
|
+
this.listeners.add(listener);
|
|
230
|
+
return () => {
|
|
231
|
+
this.listeners.delete(listener);
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
notifyListeners() {
|
|
235
|
+
const snapshot = this.getConfig();
|
|
236
|
+
for (const listener of this.listeners) {
|
|
237
|
+
try {
|
|
238
|
+
listener(snapshot);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.warn("[dsh-chat-translate] Config listener error:", err);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async updateConfig(partial) {
|
|
245
|
+
const next = {
|
|
246
|
+
...this.config,
|
|
247
|
+
...partial
|
|
248
|
+
};
|
|
249
|
+
delete next.channels;
|
|
250
|
+
if (typeof partial.concurrency === "number" && Number.isFinite(partial.concurrency)) {
|
|
251
|
+
next.concurrency = Math.min(Math.max(Math.round(partial.concurrency), 1), MAX_CONCURRENCY);
|
|
252
|
+
} else {
|
|
253
|
+
next.concurrency = this.config.concurrency;
|
|
254
|
+
}
|
|
255
|
+
if (typeof partial.timeoutMs === "number" && Number.isFinite(partial.timeoutMs)) {
|
|
256
|
+
next.timeoutMs = Math.min(Math.max(Math.round(partial.timeoutMs), 500), 1e4);
|
|
257
|
+
} else {
|
|
258
|
+
next.timeoutMs = this.config.timeoutMs;
|
|
259
|
+
}
|
|
260
|
+
if (typeof partial.aiTimeoutMs === "number" && Number.isFinite(partial.aiTimeoutMs)) {
|
|
261
|
+
next.aiTimeoutMs = Math.min(
|
|
262
|
+
Math.max(Math.round(partial.aiTimeoutMs), AI_TIMEOUT_MIN),
|
|
263
|
+
AI_TIMEOUT_MAX
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
next.aiTimeoutMs = this.config.aiTimeoutMs;
|
|
267
|
+
}
|
|
268
|
+
if (typeof partial.enabled === "boolean") next.enabled = partial.enabled;
|
|
269
|
+
if (typeof partial.aiEnabled === "boolean") next.aiEnabled = partial.aiEnabled;
|
|
270
|
+
if (typeof partial.bingEnabled === "boolean") next.bingEnabled = partial.bingEnabled;
|
|
271
|
+
if (typeof partial.baseUrl === "string") next.baseUrl = partial.baseUrl.trim();
|
|
272
|
+
if (typeof partial.model === "string") next.model = partial.model.trim();
|
|
273
|
+
if (typeof partial.targetLang === "string" && partial.targetLang.trim()) {
|
|
274
|
+
next.targetLang = partial.targetLang.trim();
|
|
275
|
+
}
|
|
276
|
+
this.config = next;
|
|
277
|
+
await this.save();
|
|
278
|
+
this.notifyListeners();
|
|
279
|
+
return this.getConfig();
|
|
280
|
+
}
|
|
281
|
+
async save() {
|
|
282
|
+
const tmpPath = `${this.configPath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
283
|
+
try {
|
|
284
|
+
await fs2.mkdir(path2.dirname(this.configPath), { recursive: true });
|
|
285
|
+
await fs2.writeFile(tmpPath, JSON.stringify(this.config, null, 2), "utf-8");
|
|
286
|
+
await fs2.rename(tmpPath, this.configPath);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.warn("[dsh-chat-translate] Failed to save config file atomically:", err);
|
|
289
|
+
try {
|
|
290
|
+
await fs2.unlink(tmpPath);
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// src/server/cache.ts
|
|
298
|
+
import * as fs3 from "node:fs/promises";
|
|
299
|
+
import * as path3 from "node:path";
|
|
300
|
+
import * as os3 from "node:os";
|
|
301
|
+
var TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
302
|
+
var LruDiskCache = class {
|
|
303
|
+
cache = /* @__PURE__ */ new Map();
|
|
304
|
+
maxEntries;
|
|
305
|
+
filePath;
|
|
306
|
+
saveTimer = null;
|
|
307
|
+
dirty = false;
|
|
308
|
+
constructor(maxEntries = 1e3) {
|
|
309
|
+
this.maxEntries = maxEntries;
|
|
310
|
+
const dshHome = process.env.DSH_HOME || path3.join(os3.homedir(), ".dsh");
|
|
311
|
+
this.filePath = path3.join(dshHome, "dsh-chat-translate-cache.json");
|
|
312
|
+
}
|
|
313
|
+
async init() {
|
|
314
|
+
try {
|
|
315
|
+
const content = await fs3.readFile(this.filePath, "utf-8");
|
|
316
|
+
const obj = JSON.parse(content);
|
|
317
|
+
if (obj && typeof obj === "object") {
|
|
318
|
+
for (const [k, raw] of Object.entries(obj)) {
|
|
319
|
+
if (typeof raw === "string") {
|
|
320
|
+
this.cache.set(k, { t: 0, v: raw });
|
|
321
|
+
} else if (raw && typeof raw === "object" && typeof raw.v === "string") {
|
|
322
|
+
const entry = raw;
|
|
323
|
+
if (typeof entry.t === "number" && Number.isFinite(entry.t)) {
|
|
324
|
+
this.cache.set(k, entry);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
get(key) {
|
|
333
|
+
const entry = this.cache.get(key);
|
|
334
|
+
if (entry === void 0) return void 0;
|
|
335
|
+
if (entry.t > 0 && Date.now() - entry.t > TTL_MS) {
|
|
336
|
+
this.cache.delete(key);
|
|
337
|
+
return void 0;
|
|
338
|
+
}
|
|
339
|
+
this.cache.delete(key);
|
|
340
|
+
this.cache.set(key, entry);
|
|
341
|
+
return entry.v;
|
|
342
|
+
}
|
|
343
|
+
set(key, value) {
|
|
344
|
+
if (this.cache.has(key)) {
|
|
345
|
+
this.cache.delete(key);
|
|
346
|
+
} else if (this.cache.size >= this.maxEntries) {
|
|
347
|
+
const oldestKey = this.cache.keys().next().value;
|
|
348
|
+
if (oldestKey !== void 0) {
|
|
349
|
+
this.cache.delete(oldestKey);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
this.cache.set(key, { t: Date.now(), v: value });
|
|
353
|
+
this.dirty = true;
|
|
354
|
+
this.scheduleSave();
|
|
355
|
+
}
|
|
356
|
+
scheduleSave() {
|
|
357
|
+
if (this.saveTimer) return;
|
|
358
|
+
this.saveTimer = setTimeout(() => {
|
|
359
|
+
this.saveTimer = null;
|
|
360
|
+
if (this.dirty) {
|
|
361
|
+
this.dirty = false;
|
|
362
|
+
this.flush().catch((err) => {
|
|
363
|
+
console.warn("[dsh-chat-translate] Failed to flush cache to disk:", err);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}, 5e3);
|
|
367
|
+
}
|
|
368
|
+
async flush() {
|
|
369
|
+
if (this.saveTimer) {
|
|
370
|
+
clearTimeout(this.saveTimer);
|
|
371
|
+
this.saveTimer = null;
|
|
372
|
+
}
|
|
373
|
+
this.dirty = false;
|
|
374
|
+
const tmpPath = `${this.filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
375
|
+
try {
|
|
376
|
+
const obj = {};
|
|
377
|
+
for (const [k, v] of this.cache.entries()) {
|
|
378
|
+
obj[k] = v;
|
|
379
|
+
}
|
|
380
|
+
await fs3.mkdir(path3.dirname(this.filePath), { recursive: true });
|
|
381
|
+
await fs3.writeFile(tmpPath, JSON.stringify(obj, null, 2), "utf-8");
|
|
382
|
+
await fs3.rename(tmpPath, this.filePath);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
console.warn("[dsh-chat-translate] Failed to write cache file atomically:", err);
|
|
385
|
+
try {
|
|
386
|
+
await fs3.unlink(tmpPath);
|
|
387
|
+
} catch {
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async dispose() {
|
|
392
|
+
if (this.saveTimer) {
|
|
393
|
+
clearTimeout(this.saveTimer);
|
|
394
|
+
this.saveTimer = null;
|
|
395
|
+
}
|
|
396
|
+
if (this.dirty) {
|
|
397
|
+
await this.flush();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// src/server/adapters/bing.ts
|
|
403
|
+
var TRANSLATOR_URL = "https://cn.bing.com/translator";
|
|
404
|
+
var TRANSLATE_URL = "https://cn.bing.com/ttranslatev3?isVertical=1&&IG={IG}&IID=translator.5025.1";
|
|
405
|
+
var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
406
|
+
var IG_RES = [
|
|
407
|
+
/_IG="([a-zA-Z0-9]+)"/,
|
|
408
|
+
/,IG:"([a-zA-Z0-9]+)"/,
|
|
409
|
+
/IG:"([a-zA-Z0-9]+)"/,
|
|
410
|
+
/"IG":"([a-zA-Z0-9]+)"/
|
|
411
|
+
];
|
|
412
|
+
var ABUSE_RES = [
|
|
413
|
+
/params_AbusePreventionHelper\s*=\s*\[\s*(\d+)\s*,\s*"([^"]+)"/,
|
|
414
|
+
/var\s+params_AbusePreventionHelper\s*=\s*\[\s*(\d+)\s*,\s*"([^"]+)"/
|
|
415
|
+
];
|
|
416
|
+
var cachedTokens = null;
|
|
417
|
+
var tokensFetchedAt = 0;
|
|
418
|
+
var TOKEN_TTL_MS = 15 * 60 * 1e3;
|
|
419
|
+
var inFlightTokenPromise = null;
|
|
420
|
+
function parseTokens(html) {
|
|
421
|
+
let ig;
|
|
422
|
+
for (const re of IG_RES) {
|
|
423
|
+
const m = re.exec(html);
|
|
424
|
+
if (m && m[1]) {
|
|
425
|
+
ig = m[1];
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
let key;
|
|
430
|
+
let token;
|
|
431
|
+
for (const re of ABUSE_RES) {
|
|
432
|
+
const m = re.exec(html);
|
|
433
|
+
if (m && m[1] && m[2]) {
|
|
434
|
+
key = m[1];
|
|
435
|
+
token = m[2];
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (!ig || !key || !token) {
|
|
440
|
+
throw new Error(`Bing translator page: missing tokens (ig: ${!!ig}, key: ${!!key}, token: ${!!token})`);
|
|
441
|
+
}
|
|
442
|
+
return { ig, key, token };
|
|
443
|
+
}
|
|
444
|
+
async function fetchTokens(signal, forceRefresh = false) {
|
|
445
|
+
if (!forceRefresh && cachedTokens && Date.now() - tokensFetchedAt < TOKEN_TTL_MS) {
|
|
446
|
+
return cachedTokens;
|
|
447
|
+
}
|
|
448
|
+
if (inFlightTokenPromise) {
|
|
449
|
+
return inFlightTokenPromise;
|
|
450
|
+
}
|
|
451
|
+
inFlightTokenPromise = (async () => {
|
|
452
|
+
try {
|
|
453
|
+
const response = await fetch(TRANSLATOR_URL, {
|
|
454
|
+
headers: {
|
|
455
|
+
"User-Agent": UA,
|
|
456
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
|
457
|
+
},
|
|
458
|
+
signal
|
|
459
|
+
});
|
|
460
|
+
if (!response.ok) {
|
|
461
|
+
throw new Error(`Bing translator page responded with status ${response.status}`);
|
|
462
|
+
}
|
|
463
|
+
const html = await response.text();
|
|
464
|
+
const tokens = parseTokens(html);
|
|
465
|
+
cachedTokens = tokens;
|
|
466
|
+
tokensFetchedAt = Date.now();
|
|
467
|
+
return tokens;
|
|
468
|
+
} finally {
|
|
469
|
+
inFlightTokenPromise = null;
|
|
470
|
+
}
|
|
471
|
+
})();
|
|
472
|
+
return inFlightTokenPromise;
|
|
473
|
+
}
|
|
474
|
+
var BingWebAdapter = class {
|
|
475
|
+
id = "bing";
|
|
476
|
+
name = "\u5FAE\u8F6F Bing \u7F51\u9875\u7FFB\u8BD1 (\u514DKey\u76F4\u8FDE)";
|
|
477
|
+
isAvailable(_config) {
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
async translate(text, signal, config) {
|
|
481
|
+
const targetLang = config.targetLang || "zh-Hans";
|
|
482
|
+
return this.executeTranslate(text, signal, targetLang, false);
|
|
483
|
+
}
|
|
484
|
+
async executeTranslate(text, signal, targetLang, isRetry) {
|
|
485
|
+
const tokens = await fetchTokens(signal, isRetry);
|
|
486
|
+
const body = new URLSearchParams({
|
|
487
|
+
fromLang: "auto-detect",
|
|
488
|
+
text,
|
|
489
|
+
to: targetLang,
|
|
490
|
+
key: tokens.key,
|
|
491
|
+
token: tokens.token,
|
|
492
|
+
tryFetchingGenderDebiasedTranslations: "true"
|
|
493
|
+
});
|
|
494
|
+
const response = await fetch(TRANSLATE_URL.replace("{IG}", tokens.ig), {
|
|
495
|
+
method: "POST",
|
|
496
|
+
headers: {
|
|
497
|
+
"User-Agent": UA,
|
|
498
|
+
Referer: "https://cn.bing.com/translator/",
|
|
499
|
+
Origin: "https://cn.bing.com",
|
|
500
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
501
|
+
},
|
|
502
|
+
body,
|
|
503
|
+
signal
|
|
504
|
+
});
|
|
505
|
+
if (!response.ok) {
|
|
506
|
+
cachedTokens = null;
|
|
507
|
+
if (!isRetry && (response.status === 400 || response.status === 401 || response.status === 403)) {
|
|
508
|
+
return this.executeTranslate(text, signal, targetLang, true);
|
|
509
|
+
}
|
|
510
|
+
throw new Error(`Bing translate responded with status ${response.status}`);
|
|
511
|
+
}
|
|
512
|
+
const json = await response.json();
|
|
513
|
+
const translated = json?.[0]?.translations?.[0]?.text?.trim();
|
|
514
|
+
if (!translated) {
|
|
515
|
+
cachedTokens = null;
|
|
516
|
+
if (!isRetry) {
|
|
517
|
+
return this.executeTranslate(text, signal, targetLang, true);
|
|
518
|
+
}
|
|
519
|
+
throw new Error("Bing translate returned an empty result");
|
|
520
|
+
}
|
|
521
|
+
return translated;
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// src/server/adapters/openai.ts
|
|
526
|
+
var LANG_HINTS = {
|
|
527
|
+
"zh-hans": "Simplified Chinese",
|
|
528
|
+
"zh-cn": "Simplified Chinese",
|
|
529
|
+
"zh": "Simplified Chinese",
|
|
530
|
+
"zh-tw": "Traditional Chinese",
|
|
531
|
+
"zh-hant": "Traditional Chinese",
|
|
532
|
+
en: "English",
|
|
533
|
+
ja: "Japanese",
|
|
534
|
+
ko: "Korean",
|
|
535
|
+
fr: "French",
|
|
536
|
+
de: "German",
|
|
537
|
+
es: "Spanish",
|
|
538
|
+
ru: "Russian",
|
|
539
|
+
pt: "Portuguese",
|
|
540
|
+
it: "Italian"
|
|
541
|
+
};
|
|
542
|
+
var OpenAiCompatibleAdapter = class {
|
|
543
|
+
id = "openai";
|
|
544
|
+
name = "OpenAI \u517C\u5BB9 (Chat Completions)";
|
|
545
|
+
credentials;
|
|
546
|
+
constructor(credentials) {
|
|
547
|
+
this.credentials = credentials;
|
|
548
|
+
}
|
|
549
|
+
isAvailable(config) {
|
|
550
|
+
return Boolean(
|
|
551
|
+
config.aiEnabled && config.baseUrl?.trim() && config.model?.trim() && this.credentials.getApiKey()
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
async translate(text, signal, config) {
|
|
555
|
+
const apiKey = this.credentials.getApiKey();
|
|
556
|
+
if (!apiKey) {
|
|
557
|
+
throw new Error(`TRANSLATE_API_KEY is not configured in ~/.dsh/.credentials.yaml`);
|
|
558
|
+
}
|
|
559
|
+
const baseUrl = (config.baseUrl || "").trim().replace(/\/+$/, "");
|
|
560
|
+
const model = (config.model || "").trim();
|
|
561
|
+
if (!baseUrl || !model) {
|
|
562
|
+
throw new Error("OpenAI channel: baseUrl or model is not configured");
|
|
563
|
+
}
|
|
564
|
+
const langName = LANG_HINTS[(config.targetLang || "zh-Hans").toLowerCase()] || config.targetLang || "Simplified Chinese";
|
|
565
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
566
|
+
method: "POST",
|
|
567
|
+
headers: {
|
|
568
|
+
"Content-Type": "application/json",
|
|
569
|
+
Authorization: `Bearer ${apiKey}`
|
|
570
|
+
},
|
|
571
|
+
body: JSON.stringify({
|
|
572
|
+
model,
|
|
573
|
+
temperature: 0,
|
|
574
|
+
messages: [
|
|
575
|
+
{
|
|
576
|
+
role: "system",
|
|
577
|
+
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. Preserve every placeholder like __DSH_MASK_0__ exactly as-is.`
|
|
578
|
+
},
|
|
579
|
+
{ role: "user", content: text }
|
|
580
|
+
]
|
|
581
|
+
}),
|
|
582
|
+
signal
|
|
583
|
+
});
|
|
584
|
+
if (!response.ok) {
|
|
585
|
+
let detail = "";
|
|
586
|
+
try {
|
|
587
|
+
const errBody = await response.json();
|
|
588
|
+
detail = errBody?.error?.message || errBody?.message || "";
|
|
589
|
+
} catch {
|
|
590
|
+
}
|
|
591
|
+
throw new Error(`OpenAI-compatible API responded with ${response.status}${detail ? `: ${detail}` : ""}`);
|
|
592
|
+
}
|
|
593
|
+
const data = await response.json();
|
|
594
|
+
const content = data?.choices?.[0]?.message?.content;
|
|
595
|
+
const translated = typeof content === "string" ? content.trim() : "";
|
|
596
|
+
if (!translated) {
|
|
597
|
+
throw new Error("OpenAI-compatible API returned empty content");
|
|
598
|
+
}
|
|
599
|
+
return translated;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// src/server/pipeline/masking.ts
|
|
604
|
+
var ContentMaskingPipeline = class {
|
|
605
|
+
mask(text) {
|
|
606
|
+
if (!text || typeof text !== "string") {
|
|
607
|
+
return {
|
|
608
|
+
maskedText: text,
|
|
609
|
+
unmask: (t) => t
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const masks = [];
|
|
613
|
+
const addMask = (match) => {
|
|
614
|
+
const idx = masks.length;
|
|
615
|
+
masks.push(match);
|
|
616
|
+
return `__DSH_MASK_${idx}__`;
|
|
617
|
+
};
|
|
618
|
+
let processed = text;
|
|
619
|
+
processed = processed.replace(/(?:```|~~~)[\s\S]*?(?:```|~~~)/g, (m) => addMask(m));
|
|
620
|
+
processed = processed.replace(/`[^`\n]+`/g, (m) => addMask(m));
|
|
621
|
+
processed = processed.replace(/https?:\/\/[^\s)\];,;"'<>]+/g, (m) => addMask(m));
|
|
622
|
+
processed = processed.replace(
|
|
623
|
+
/(?:(?:\/|[a-zA-Z]:[\\\/]|\.\.?[\\\/])[\w.\-\\\/]+|\b(?:[\w.\-]+\/)+[\w.\-]+\.[a-zA-Z0-9]+\b|\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,
|
|
624
|
+
(m) => addMask(m)
|
|
625
|
+
);
|
|
626
|
+
processed = processed.replace(
|
|
627
|
+
/(?<=^|[\s(\[{"'])((?:--[a-zA-Z0-9_\-]+(?:=[^\s"'<>]+)?)|(?:-[a-zA-Z0-9]+))(?=[\s)\]}",:;!?]|$)/g,
|
|
628
|
+
(m) => addMask(m)
|
|
629
|
+
);
|
|
630
|
+
const unmask = (translatedText) => {
|
|
631
|
+
if (!translatedText || masks.length === 0) {
|
|
632
|
+
return translatedText;
|
|
633
|
+
}
|
|
634
|
+
return translatedText.replace(
|
|
635
|
+
/__\s*DSH\s*_\s*MASK\s*_\s*(\d+)\s*__/gi,
|
|
636
|
+
(_fullMatch, indexStr) => {
|
|
637
|
+
const idx = parseInt(indexStr, 10);
|
|
638
|
+
if (!Number.isNaN(idx) && idx >= 0 && idx < masks.length) {
|
|
639
|
+
return masks[idx];
|
|
640
|
+
}
|
|
641
|
+
return _fullMatch;
|
|
642
|
+
}
|
|
643
|
+
);
|
|
644
|
+
};
|
|
645
|
+
return {
|
|
646
|
+
maskedText: processed,
|
|
647
|
+
unmask
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
// src/server/dispatcher.ts
|
|
653
|
+
var TranslationDispatcher = class {
|
|
654
|
+
configManager;
|
|
655
|
+
cache;
|
|
656
|
+
credentials;
|
|
657
|
+
masking = new ContentMaskingPipeline();
|
|
658
|
+
adapters = /* @__PURE__ */ new Map();
|
|
659
|
+
circuitStates = /* @__PURE__ */ new Map();
|
|
660
|
+
inFlightMap = /* @__PURE__ */ new Map();
|
|
661
|
+
activeCount = 0;
|
|
662
|
+
queue = [];
|
|
663
|
+
constructor(configManager, cache, credentials) {
|
|
664
|
+
this.configManager = configManager;
|
|
665
|
+
this.cache = cache;
|
|
666
|
+
this.credentials = credentials ?? configManager.credentials ?? { getApiKey: () => "" };
|
|
667
|
+
this.registerAdapter(new OpenAiCompatibleAdapter(this.credentials));
|
|
668
|
+
this.registerAdapter(new BingWebAdapter());
|
|
669
|
+
this.configManager.onConfigChange(() => {
|
|
670
|
+
this.processNext();
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
registerAdapter(adapter) {
|
|
674
|
+
this.adapters.set(adapter.id, adapter);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Decide which channels are active for the current config, in priority order.
|
|
678
|
+
*
|
|
679
|
+
* Truth table (user contract):
|
|
680
|
+
* - AI on + configured + Bing on -> [openai, bing] (AI first, Bing fallback)
|
|
681
|
+
* - AI on + NOT configured + Bing on -> [bing]
|
|
682
|
+
* - AI on + NOT configured + Bing off -> [] (no translation)
|
|
683
|
+
* - AI off + Bing on -> [bing]
|
|
684
|
+
* - AI off + Bing off -> [] (no translation)
|
|
685
|
+
*/
|
|
686
|
+
computeChannels(config) {
|
|
687
|
+
const channels = [];
|
|
688
|
+
for (const [id, adapter] of this.adapters) {
|
|
689
|
+
if (id === "openai") {
|
|
690
|
+
if (config.aiEnabled && config.baseUrl?.trim() && config.model?.trim() && this.credentials.getApiKey()) {
|
|
691
|
+
channels.push(id);
|
|
692
|
+
}
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
if (id === "bing") {
|
|
696
|
+
if (config.bingEnabled) channels.push(id);
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if (adapter.isAvailable(config)) channels.push(id);
|
|
700
|
+
}
|
|
701
|
+
return channels;
|
|
702
|
+
}
|
|
703
|
+
async translateBatch(texts, forceRefresh = false) {
|
|
704
|
+
return Promise.all(texts.map((t) => this.translateOne(t, forceRefresh)));
|
|
705
|
+
}
|
|
706
|
+
async translateOne(rawText, forceRefresh = false) {
|
|
707
|
+
const text = rawText.trim();
|
|
708
|
+
if (!text) {
|
|
709
|
+
return { original: rawText, translated: rawText, channel: "none", cached: true };
|
|
710
|
+
}
|
|
711
|
+
const config = this.configManager.getConfig();
|
|
712
|
+
if (!config.enabled) {
|
|
713
|
+
return { original: rawText, translated: rawText, channel: "disabled", cached: true };
|
|
714
|
+
}
|
|
715
|
+
const cacheKey = text.toLowerCase();
|
|
716
|
+
if (!forceRefresh) {
|
|
717
|
+
const cached = this.cache.get(cacheKey);
|
|
718
|
+
if (cached) {
|
|
719
|
+
return { original: rawText, translated: cached, channel: "cache", cached: true };
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (!forceRefresh) {
|
|
723
|
+
const inFlight = this.inFlightMap.get(cacheKey);
|
|
724
|
+
if (inFlight) {
|
|
725
|
+
return inFlight;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const { maskedText, unmask } = this.masking.mask(text);
|
|
729
|
+
const taskPromise = this.enqueueTask(async () => {
|
|
730
|
+
const currentConfig = this.configManager.getConfig();
|
|
731
|
+
const channels = this.computeChannels(currentConfig);
|
|
732
|
+
for (const chId of channels) {
|
|
733
|
+
const adapter = this.adapters.get(chId);
|
|
734
|
+
if (!adapter || !adapter.isAvailable(currentConfig) || this.isCircuitOpen(chId)) {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
const timeout = chId === "openai" ? currentConfig.aiTimeoutMs || 3e4 : currentConfig.timeoutMs || 2e3;
|
|
739
|
+
const abortCtrl = new AbortController();
|
|
740
|
+
const timer = setTimeout(() => abortCtrl.abort(), timeout);
|
|
741
|
+
let translatedMasked = "";
|
|
742
|
+
try {
|
|
743
|
+
translatedMasked = await adapter.translate(maskedText, abortCtrl.signal, currentConfig);
|
|
744
|
+
} finally {
|
|
745
|
+
clearTimeout(timer);
|
|
746
|
+
}
|
|
747
|
+
const cleaned = translatedMasked?.trim();
|
|
748
|
+
if (cleaned && cleaned.length > 0) {
|
|
749
|
+
const finalTranslated = unmask(cleaned);
|
|
750
|
+
this.recordSuccess(chId);
|
|
751
|
+
this.cache.set(cacheKey, finalTranslated);
|
|
752
|
+
return {
|
|
753
|
+
original: rawText,
|
|
754
|
+
translated: finalTranslated,
|
|
755
|
+
channel: chId,
|
|
756
|
+
cached: false
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
this.recordFailure(chId);
|
|
760
|
+
console.warn(
|
|
761
|
+
`[dsh-chat-translate] channel ${chId} returned an empty translation | text: ${text.slice(0, 60)}`
|
|
762
|
+
);
|
|
763
|
+
} catch (err) {
|
|
764
|
+
this.recordFailure(chId);
|
|
765
|
+
console.warn(
|
|
766
|
+
`[dsh-chat-translate] channel ${chId} failed: ${err?.message || String(err)} | text: ${text.slice(0, 60)}`
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return { original: rawText, translated: rawText, channel: "fallback", cached: false };
|
|
771
|
+
});
|
|
772
|
+
if (!forceRefresh) {
|
|
773
|
+
this.inFlightMap.set(cacheKey, taskPromise);
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
return await taskPromise;
|
|
777
|
+
} finally {
|
|
778
|
+
if (!forceRefresh) {
|
|
779
|
+
this.inFlightMap.delete(cacheKey);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
async testChannel(channelId) {
|
|
784
|
+
const adapter = this.adapters.get(channelId);
|
|
785
|
+
const config = this.configManager.getConfig();
|
|
786
|
+
if (!adapter) {
|
|
787
|
+
return { ok: false, latencyMs: 0, error: `Channel ${channelId} not found` };
|
|
788
|
+
}
|
|
789
|
+
if (!adapter.isAvailable(config)) {
|
|
790
|
+
return { ok: false, latencyMs: 0, error: `Channel ${channelId} is not configured or disabled` };
|
|
791
|
+
}
|
|
792
|
+
const testText = "List files in current directory";
|
|
793
|
+
const start = Date.now();
|
|
794
|
+
try {
|
|
795
|
+
const timeout = channelId === "openai" ? Math.min(config.aiTimeoutMs || 3e4, 3e4) : 4e3;
|
|
796
|
+
const abortCtrl = new AbortController();
|
|
797
|
+
const timer = setTimeout(() => abortCtrl.abort(), timeout);
|
|
798
|
+
let res = "";
|
|
799
|
+
try {
|
|
800
|
+
res = await adapter.translate(testText, abortCtrl.signal, config);
|
|
801
|
+
} finally {
|
|
802
|
+
clearTimeout(timer);
|
|
803
|
+
}
|
|
804
|
+
const latencyMs = Date.now() - start;
|
|
805
|
+
if (res && res.trim()) {
|
|
806
|
+
return { ok: true, latencyMs };
|
|
807
|
+
}
|
|
808
|
+
return { ok: false, latencyMs, error: "Empty translation returned" };
|
|
809
|
+
} catch (err) {
|
|
810
|
+
return { ok: false, latencyMs: Date.now() - start, error: err?.message || String(err) };
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
enqueueTask(task) {
|
|
814
|
+
return new Promise((resolve, reject) => {
|
|
815
|
+
const exec = async () => {
|
|
816
|
+
this.activeCount++;
|
|
817
|
+
try {
|
|
818
|
+
const result = await task();
|
|
819
|
+
resolve(result);
|
|
820
|
+
} catch (err) {
|
|
821
|
+
reject(err);
|
|
822
|
+
} finally {
|
|
823
|
+
this.activeCount--;
|
|
824
|
+
this.processNext();
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
const maxConcurrency = Math.min(
|
|
828
|
+
Math.max(this.configManager.getConfig().concurrency || 3, 1),
|
|
829
|
+
MAX_CONCURRENCY
|
|
830
|
+
);
|
|
831
|
+
if (this.activeCount < maxConcurrency) {
|
|
832
|
+
exec();
|
|
833
|
+
} else {
|
|
834
|
+
this.queue.push(exec);
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
processNext() {
|
|
839
|
+
const maxConcurrency = Math.min(
|
|
840
|
+
Math.max(this.configManager.getConfig().concurrency || 3, 1),
|
|
841
|
+
MAX_CONCURRENCY
|
|
842
|
+
);
|
|
843
|
+
while (this.queue.length > 0 && this.activeCount < maxConcurrency) {
|
|
844
|
+
const next = this.queue.shift();
|
|
845
|
+
if (next) {
|
|
846
|
+
next();
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
isCircuitOpen(channelId) {
|
|
851
|
+
let state = this.circuitStates.get(channelId);
|
|
852
|
+
if (!state) return false;
|
|
853
|
+
if (state.state === "open") {
|
|
854
|
+
if (Date.now() >= state.openUntil) {
|
|
855
|
+
state.state = "half-open";
|
|
856
|
+
state.probeInFlight = true;
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
if (state.state === "half-open") {
|
|
862
|
+
if (state.probeInFlight) return true;
|
|
863
|
+
state.probeInFlight = true;
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
recordSuccess(channelId) {
|
|
869
|
+
const state = this.circuitStates.get(channelId);
|
|
870
|
+
if (state) {
|
|
871
|
+
state.state = "closed";
|
|
872
|
+
state.failureCount = 0;
|
|
873
|
+
state.openUntil = 0;
|
|
874
|
+
state.probeInFlight = false;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
recordFailure(channelId) {
|
|
878
|
+
let state = this.circuitStates.get(channelId);
|
|
879
|
+
if (!state) {
|
|
880
|
+
state = { state: "closed", failureCount: 0, openUntil: 0, probeInFlight: false };
|
|
881
|
+
this.circuitStates.set(channelId, state);
|
|
882
|
+
}
|
|
883
|
+
if (state.state === "half-open") {
|
|
884
|
+
state.state = "open";
|
|
885
|
+
state.failureCount = 3;
|
|
886
|
+
state.openUntil = Date.now() + 3e4;
|
|
887
|
+
state.probeInFlight = false;
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
state.failureCount++;
|
|
891
|
+
if (state.failureCount >= 3) {
|
|
892
|
+
state.state = "open";
|
|
893
|
+
state.openUntil = Date.now() + 3e4;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// src/server/router.ts
|
|
899
|
+
var MAX_BODY_BYTES = 1024 * 1024;
|
|
900
|
+
function sendJson(res, status, body) {
|
|
901
|
+
const json = JSON.stringify(body);
|
|
902
|
+
res.writeHead(status, {
|
|
903
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
904
|
+
"Content-Length": Buffer.byteLength(json)
|
|
905
|
+
});
|
|
906
|
+
res.end(json);
|
|
907
|
+
}
|
|
908
|
+
function readBody(req) {
|
|
909
|
+
return new Promise((resolve, reject) => {
|
|
910
|
+
const chunks = [];
|
|
911
|
+
let totalLength = 0;
|
|
912
|
+
req.on("data", (chunk) => {
|
|
913
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
914
|
+
totalLength += buf.length;
|
|
915
|
+
if (totalLength > MAX_BODY_BYTES) {
|
|
916
|
+
if (typeof req.destroy === "function") {
|
|
917
|
+
req.destroy();
|
|
918
|
+
}
|
|
919
|
+
reject(new Error("Request body exceeded maximum allowed size (1MB)"));
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
chunks.push(buf);
|
|
923
|
+
});
|
|
924
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
925
|
+
req.on("error", reject);
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
function createHttpHandler(configManager, dispatcher, credentials) {
|
|
929
|
+
return async (req, res) => {
|
|
930
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
931
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
932
|
+
const endpoint = pathParts[2] || "";
|
|
933
|
+
try {
|
|
934
|
+
if (endpoint === "translate" && req.method === "POST") {
|
|
935
|
+
const raw = await readBody(req);
|
|
936
|
+
let parsed;
|
|
937
|
+
try {
|
|
938
|
+
parsed = JSON.parse(raw || "{}");
|
|
939
|
+
} catch {
|
|
940
|
+
sendJson(res, 400, { ok: false, error: "Invalid JSON body" });
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
const rawTexts = parsed.texts !== void 0 ? parsed.texts : parsed.text;
|
|
944
|
+
let texts = [];
|
|
945
|
+
if (Array.isArray(rawTexts)) {
|
|
946
|
+
texts = rawTexts.filter((t) => typeof t === "string");
|
|
947
|
+
} else if (typeof rawTexts === "string") {
|
|
948
|
+
texts = [rawTexts];
|
|
949
|
+
}
|
|
950
|
+
const forceRefresh = Boolean(parsed.forceRefresh);
|
|
951
|
+
if (texts.length === 0) {
|
|
952
|
+
sendJson(res, 200, { ok: true, results: [] });
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const results = await dispatcher.translateBatch(texts, forceRefresh);
|
|
956
|
+
sendJson(res, 200, { ok: true, results });
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (endpoint === "config") {
|
|
960
|
+
if (req.method === "GET") {
|
|
961
|
+
sendJson(res, 200, { ok: true, config: configManager.getMaskedConfig() });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
if (req.method === "POST") {
|
|
965
|
+
const raw = await readBody(req);
|
|
966
|
+
let updates;
|
|
967
|
+
try {
|
|
968
|
+
updates = JSON.parse(raw || "{}");
|
|
969
|
+
} catch {
|
|
970
|
+
sendJson(res, 400, { ok: false, error: "Invalid JSON body" });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (typeof updates !== "object" || updates === null || Array.isArray(updates)) {
|
|
974
|
+
sendJson(res, 400, { ok: false, error: "Invalid config payload" });
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
await configManager.updateConfig(updates);
|
|
978
|
+
sendJson(res, 200, { ok: true, config: configManager.getMaskedConfig() });
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (endpoint === "credentials" && req.method === "POST") {
|
|
983
|
+
if (!credentials) {
|
|
984
|
+
sendJson(res, 500, { ok: false, error: "Credentials reader unavailable" });
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
const raw = await readBody(req);
|
|
988
|
+
let parsed;
|
|
989
|
+
try {
|
|
990
|
+
parsed = JSON.parse(raw || "{}");
|
|
991
|
+
} catch {
|
|
992
|
+
sendJson(res, 400, { ok: false, error: "Invalid JSON body" });
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : "";
|
|
996
|
+
try {
|
|
997
|
+
await credentials.setApiKey(apiKey);
|
|
998
|
+
sendJson(res, 200, { ok: true, configured: Boolean(credentials.getApiKey()) });
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
sendJson(res, 500, { ok: false, error: err?.message || String(err) });
|
|
1001
|
+
}
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (endpoint === "test-channel" && req.method === "POST") {
|
|
1005
|
+
const raw = await readBody(req);
|
|
1006
|
+
let parsed;
|
|
1007
|
+
try {
|
|
1008
|
+
parsed = JSON.parse(raw || "{}");
|
|
1009
|
+
} catch {
|
|
1010
|
+
sendJson(res, 400, { ok: false, error: "Invalid JSON body" });
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const channelId = typeof parsed.channel === "string" ? parsed.channel : "";
|
|
1014
|
+
const result = await dispatcher.testChannel(channelId);
|
|
1015
|
+
sendJson(res, 200, result);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
sendJson(res, 404, { ok: false, error: "Endpoint not found" });
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
const status = err?.message?.includes("exceeded maximum allowed size") ? 413 : 500;
|
|
1021
|
+
sendJson(res, status, { ok: false, error: err?.message || String(err) });
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/index.ts
|
|
1027
|
+
var name = "dsh-chat-translate";
|
|
1028
|
+
var inject = ["webServer"];
|
|
1029
|
+
function apply(ctx) {
|
|
1030
|
+
const credentials = new CredentialsReader();
|
|
1031
|
+
const configManager = new ConfigManager(credentials);
|
|
1032
|
+
const cache = new LruDiskCache(1e3);
|
|
1033
|
+
const dispatcher = new TranslationDispatcher(configManager, cache, credentials);
|
|
1034
|
+
const initPromise = Promise.all([configManager.init(), cache.init()]).catch((err) => {
|
|
1035
|
+
console.warn("[dsh-chat-translate] Initialization error:", err);
|
|
1036
|
+
});
|
|
1037
|
+
const webServer = ctx.webServer || (ctx.get ? ctx.get("webServer") : null);
|
|
1038
|
+
if (webServer && typeof webServer.register === "function") {
|
|
1039
|
+
const rawHandler = createHttpHandler(configManager, dispatcher, credentials);
|
|
1040
|
+
const handler = async (req, res) => {
|
|
1041
|
+
await initPromise;
|
|
1042
|
+
return rawHandler(req, res);
|
|
1043
|
+
};
|
|
1044
|
+
ctx.effect(
|
|
1045
|
+
() => {
|
|
1046
|
+
const unregister = webServer.register({
|
|
1047
|
+
kind: "prefix",
|
|
1048
|
+
path: "/api/dsh-chat-translate",
|
|
1049
|
+
handler
|
|
1050
|
+
});
|
|
1051
|
+
return () => {
|
|
1052
|
+
if (typeof unregister === "function") {
|
|
1053
|
+
unregister();
|
|
1054
|
+
}
|
|
1055
|
+
cache.dispose().catch((err) => {
|
|
1056
|
+
console.warn("[dsh-chat-translate] Dispose cache error:", err);
|
|
1057
|
+
});
|
|
1058
|
+
};
|
|
1059
|
+
},
|
|
1060
|
+
"dsh-chat-translate: translation API routes"
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
export {
|
|
1065
|
+
apply,
|
|
1066
|
+
inject,
|
|
1067
|
+
name
|
|
1068
|
+
};
|
|
1069
|
+
//# sourceMappingURL=index.js.map
|