dsh-think-translate 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +1096 -0
- package/lib/index.js +733 -0
- package/package.json +37 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
// dsh-think-translate host half: same-origin translation proxy with a pluggable
|
|
2
|
+
// provider chain. The browser calls POST /_xlate/translate (no CORS involved);
|
|
3
|
+
// this side owns provider adapters, API keys (local config file), caching,
|
|
4
|
+
// and the system-proxy-aware curl transport.
|
|
5
|
+
//
|
|
6
|
+
// Providers: google (gtx via Node CONNECT tunnel / curl), bing (ttranslatev3
|
|
7
|
+
// via curl), openai-compatible (any /chat/completions endpoint — usually the
|
|
8
|
+
// local Ollama).
|
|
9
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
10
|
+
import { dirname } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import http from 'node:http'
|
|
13
|
+
import https from 'node:https'
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-think-translate'
|
|
16
|
+
|
|
17
|
+
// Hard dependencies: without this declaration apply() ran before the web
|
|
18
|
+
// server row mounted, ctx.get('webServer') returned undefined, and the whole
|
|
19
|
+
// plugin silently registered nothing (no error, no routes).
|
|
20
|
+
export const inject = ['webServer', 'subprocess', 'timer']
|
|
21
|
+
|
|
22
|
+
const CONFIG_PATH = fileURLToPath(new URL('./config.json', import.meta.url))
|
|
23
|
+
|
|
24
|
+
// Defaults: install-and-go with google/bing (browser/system-proxy direct,
|
|
25
|
+
// nothing to configure). The local model provider stays disabled until the
|
|
26
|
+
// user downloads a model from the settings panel — the pull flow then
|
|
27
|
+
// auto-enables it (2026-08-24).
|
|
28
|
+
const DEFAULT_CONFIG = {
|
|
29
|
+
priority: 'google',
|
|
30
|
+
providers: {
|
|
31
|
+
google: { enabled: true },
|
|
32
|
+
bing: { enabled: true },
|
|
33
|
+
openai: { enabled: false, baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b-instruct', apiKey: 'ollama-local' },
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------- transport
|
|
38
|
+
|
|
39
|
+
let subprocessRef = null
|
|
40
|
+
let timerRef = null
|
|
41
|
+
|
|
42
|
+
async function workdir() {
|
|
43
|
+
return process.cwd() || '.'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function runProgram(exeName, args, timeoutMs) {
|
|
47
|
+
const sub = subprocessRef
|
|
48
|
+
if (!sub) throw new Error('subprocess unavailable')
|
|
49
|
+
const exe = await sub.resolveExecutable(exeName)
|
|
50
|
+
const child = sub.spawn({
|
|
51
|
+
argv: [exe].concat(args),
|
|
52
|
+
cwd: await workdir(),
|
|
53
|
+
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
54
|
+
graceMs: 2000,
|
|
55
|
+
})
|
|
56
|
+
const readP = (async function () {
|
|
57
|
+
const chunks = []
|
|
58
|
+
if (child.stdout) {
|
|
59
|
+
for await (const c of child.stdout) chunks.push(c)
|
|
60
|
+
}
|
|
61
|
+
return chunks
|
|
62
|
+
})()
|
|
63
|
+
const timeoutP = timerRef.timeout(timeoutMs).then(function () { return 'timeout' })
|
|
64
|
+
const doneP = child.done.then(function () { return 'done' })
|
|
65
|
+
const winner = await Promise.race([doneP, timeoutP])
|
|
66
|
+
if (winner === 'timeout') {
|
|
67
|
+
child.terminate()
|
|
68
|
+
await readP.catch(function () { return [] })
|
|
69
|
+
throw new Error(exeName + ' timeout')
|
|
70
|
+
}
|
|
71
|
+
const chunks = await readP
|
|
72
|
+
const outcome = await child.done
|
|
73
|
+
let total = 0
|
|
74
|
+
for (const c of chunks) total += c.byteLength
|
|
75
|
+
const bytes = new Uint8Array(total)
|
|
76
|
+
let off = 0
|
|
77
|
+
for (const c of chunks) { bytes.set(c, off); off += c.byteLength }
|
|
78
|
+
const out = new TextDecoder().decode(bytes)
|
|
79
|
+
if (outcome.exitCode !== 0) throw new Error(exeName + ' exit ' + outcome.exitCode + ': ' + out.slice(0, 200))
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let proxyCache = { value: null, at: 0 }
|
|
84
|
+
|
|
85
|
+
async function getProxy() {
|
|
86
|
+
const now = Date.now()
|
|
87
|
+
if (proxyCache.at !== 0 && now - proxyCache.at < 10 * 60 * 1000) return proxyCache.value
|
|
88
|
+
let value = null
|
|
89
|
+
try {
|
|
90
|
+
const out = await runProgram('reg', ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'], 8000)
|
|
91
|
+
const en = out.match(/ProxyEnable\s+REG_DWORD\s+(\S+)/)
|
|
92
|
+
const sv = out.match(/ProxyServer\s+REG_SZ\s+(\S+)/)
|
|
93
|
+
if (en && en[1] && en[1].toLowerCase() !== '0x0' && sv && sv[1]) {
|
|
94
|
+
let p = sv[1]
|
|
95
|
+
if (p.indexOf(';') !== -1) {
|
|
96
|
+
const mH = p.match(/https?=([^;]+)/i)
|
|
97
|
+
const mS = p.match(/socks=([^;]+)/i)
|
|
98
|
+
p = mH ? mH[1] : (mS ? mS[1] : '')
|
|
99
|
+
}
|
|
100
|
+
if (p) {
|
|
101
|
+
if (/^socks/i.test(p)) value = 'socks5://' + p.replace(/^socks=?/i, '')
|
|
102
|
+
else if (/^\d/.test(p)) value = 'http://' + p
|
|
103
|
+
else value = p.indexOf('://') === -1 ? 'http://' + p : p
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch (e) {
|
|
107
|
+
value = null
|
|
108
|
+
}
|
|
109
|
+
proxyCache = { value: value, at: now }
|
|
110
|
+
return value
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function curlArgs(extra) {
|
|
114
|
+
const args = ['-sS', '--max-time', '25']
|
|
115
|
+
// Loopback targets (local LLM endpoints) must bypass the system proxy.
|
|
116
|
+
const last = extra.length ? String(extra[extra.length - 1]) : ''
|
|
117
|
+
const mh = last.match(/^https?:\/\/([^\/?#]+)/i)
|
|
118
|
+
const host = mh ? mh[1].replace(/^\[/, '').split(':')[0].toLowerCase() : ''
|
|
119
|
+
const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]' || host === '::ffff:127.0.0.1'
|
|
120
|
+
if (!isLocal) {
|
|
121
|
+
const px = await getProxy()
|
|
122
|
+
if (px) args.push('-x', px)
|
|
123
|
+
}
|
|
124
|
+
for (const a of extra) args.push(a)
|
|
125
|
+
return args
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function curlGet(url) {
|
|
129
|
+
return runProgram('curl', await curlArgs(['-L', url]), 30000)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function curlPostForm(url, form) {
|
|
133
|
+
const extra = ['-X', 'POST', '-H', 'Content-Type: application/x-www-form-urlencoded', '--data-binary', form, url]
|
|
134
|
+
return runProgram('curl', await curlArgs(extra), 30000)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function curlPostJson(url, headerPairs, body) {
|
|
138
|
+
const extra = ['-X', 'POST']
|
|
139
|
+
for (const h of headerPairs) extra.push('-H', h)
|
|
140
|
+
extra.push('--data-binary', body, url)
|
|
141
|
+
return runProgram('curl', await curlArgs(extra), 40000)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// HTTPS GET through an HTTP proxy via a CONNECT tunnel, using Node's own TLS
|
|
145
|
+
// stack. curl is blocked by google gtx's anti-bot (TLS-fingerprint based:
|
|
146
|
+
// curl gets a "Sorry..." HTML page while .NET/Node/real browsers pass), so the
|
|
147
|
+
// google provider must not go through curl. SOCKS proxies are not supported
|
|
148
|
+
// here — callers keep the curl path for those.
|
|
149
|
+
function httpsViaProxy(urlStr, proxyHost, proxyPort, timeoutMs) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const url = new URL(urlStr)
|
|
152
|
+
const connectReq = http.request({
|
|
153
|
+
host: proxyHost,
|
|
154
|
+
port: proxyPort,
|
|
155
|
+
method: 'CONNECT',
|
|
156
|
+
path: url.host + ':443',
|
|
157
|
+
headers: { Host: url.host + ':443', 'Proxy-Connection': 'Keep-Alive' },
|
|
158
|
+
timeout: timeoutMs,
|
|
159
|
+
})
|
|
160
|
+
connectReq.on('connect', (res, socket) => {
|
|
161
|
+
if (res.statusCode !== 200) {
|
|
162
|
+
socket.destroy()
|
|
163
|
+
reject(new Error('proxy CONNECT ' + res.statusCode))
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const req2 = https.request({
|
|
167
|
+
host: url.host,
|
|
168
|
+
path: url.pathname + url.search,
|
|
169
|
+
method: 'GET',
|
|
170
|
+
headers: {
|
|
171
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
|
172
|
+
'Accept': 'application/json,text/plain,*/*',
|
|
173
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
174
|
+
},
|
|
175
|
+
socket,
|
|
176
|
+
servername: url.host,
|
|
177
|
+
timeout: timeoutMs,
|
|
178
|
+
}, (res2) => {
|
|
179
|
+
const chunks = []
|
|
180
|
+
res2.on('data', (c) => chunks.push(c))
|
|
181
|
+
res2.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
182
|
+
})
|
|
183
|
+
req2.on('error', reject)
|
|
184
|
+
req2.on('timeout', () => req2.destroy(new Error('https timeout')))
|
|
185
|
+
req2.end()
|
|
186
|
+
})
|
|
187
|
+
connectReq.on('error', reject)
|
|
188
|
+
connectReq.on('timeout', () => connectReq.destroy(new Error('proxy connect timeout')))
|
|
189
|
+
connectReq.end()
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------- providers
|
|
194
|
+
|
|
195
|
+
let bingCache = null
|
|
196
|
+
|
|
197
|
+
async function viaGoogle(text, to) {
|
|
198
|
+
const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=' + encodeURIComponent(to) + '&dt=t&q=' + encodeURIComponent(text)
|
|
199
|
+
const px = await getProxy()
|
|
200
|
+
let raw = null
|
|
201
|
+
if (px && /^http/i.test(px)) {
|
|
202
|
+
// curl is fingerprint-blocked by google ("Sorry..." page); Node's TLS
|
|
203
|
+
// stack passes. Only plain HTTP proxies support CONNECT here; SOCKS falls
|
|
204
|
+
// back to curl.
|
|
205
|
+
try {
|
|
206
|
+
const p = new URL(px)
|
|
207
|
+
raw = await httpsViaProxy(url, p.hostname, Number(p.port) || 80, 30000)
|
|
208
|
+
} catch (e) {
|
|
209
|
+
raw = null
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (raw === null) raw = await curlGet(url)
|
|
213
|
+
const arr = JSON.parse(raw)
|
|
214
|
+
const segs = arr && arr[0]
|
|
215
|
+
if (!Array.isArray(segs)) throw new Error('gtx unexpected shape')
|
|
216
|
+
let out = ''
|
|
217
|
+
for (const seg of segs) {
|
|
218
|
+
if (Array.isArray(seg) && typeof seg[0] === 'string') out += seg[0]
|
|
219
|
+
}
|
|
220
|
+
if (!out.trim()) throw new Error('gtx empty translation')
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function bingSession() {
|
|
225
|
+
if (bingCache !== null && Date.now() < bingCache.exp) return bingCache
|
|
226
|
+
const html = await curlGet('https://www.bing.com/translator')
|
|
227
|
+
const ig = html.match(/IG:"([^"]+)"/)
|
|
228
|
+
const ab = html.match(/params_AbusePreventionHelper\s*=\s*\[([^\]]*)\]/)
|
|
229
|
+
if (!ig || !ab) throw new Error('bing page shape changed')
|
|
230
|
+
const parts = ab[1].split(',').map(function (s) { return s.trim().replace(/^"/, '').replace(/"$/, '') })
|
|
231
|
+
if (parts.length < 2 || !parts[0] || !parts[1]) throw new Error('bing params empty')
|
|
232
|
+
bingCache = { ig: ig[1], key: parts[0], token: parts[1], exp: Date.now() + 50 * 60 * 1000 }
|
|
233
|
+
return bingCache
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function viaBing(text, to) {
|
|
237
|
+
const s = await bingSession()
|
|
238
|
+
const form = 'fromLang=auto&text=' + encodeURIComponent(text) + '&to=' + encodeURIComponent(to) + '&token=' + encodeURIComponent(s.token) + '&key=' + encodeURIComponent(s.key)
|
|
239
|
+
const raw = await curlPostForm('https://www.bing.com/ttranslatev3?isIntl=1&IG=' + encodeURIComponent(s.ig) + '&IID=SERP', form)
|
|
240
|
+
const arr = JSON.parse(raw)
|
|
241
|
+
const tr = Array.isArray(arr) && arr[0] && arr[0].translations && arr[0].translations[0]
|
|
242
|
+
if (!tr || typeof tr.text !== 'string' || !tr.text.trim()) throw new Error('bing empty translation')
|
|
243
|
+
return tr.text
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The vocabulary-hint block fixes the local qwen family's habit of leaving
|
|
247
|
+
// common Chinese words untranslated (这个/方案/关键/针对...). Giving explicit
|
|
248
|
+
// mappings (zh -> ja) turns near-miss output into clean translations; the
|
|
249
|
+
// "never leave source words" rule applies to every target language.
|
|
250
|
+
const OPENAI_SYSTEM = 'You are a translation engine. Translate the user text into the requested target language. Output ONLY the translation — no explanations, no quotes, no trailing commentary. Never keep a source-language word untranslated in the output. Common mappings (Chinese -> Japanese): 这个 -> この; 那个 -> その; 方案 -> 案/プラン; 关键 -> 要点/鍵; 针对 -> 対して; 进行 -> 行う; 通过 -> 通じて; 以及 -> および; 对于 -> に対して; 需要 -> 必要がある; 我们 -> 私たち; 可以 -> できる; 问题 -> 問題; 情况 -> 状況; 内容 -> 内容; 相关 -> 関連; 以下 -> 以下; 上述 -> 前述; 当前 -> 現在; 整体 -> 全体; 主要 -> 主に; 具体 -> 具体的に; 方面 -> 面/点; 部分 -> 部分; 实现 -> 実装/実現; 使用 -> 使用する; 提供 -> 提供する; 支持 -> サポートする; 处理 -> 処理する; 解决 -> 解決する; 提高 -> 向上させる; 降低 -> 低下させる; 增加 -> 増加させる; 减少 -> 減らす. Keep code blocks, commands, file paths, URLs, and identifiers verbatim.'
|
|
251
|
+
|
|
252
|
+
async function viaOpenAI(text, to, cfg) {
|
|
253
|
+
if (!cfg.apiKey) throw new Error('openai: missing apiKey')
|
|
254
|
+
let url = String(cfg.baseURL || '').replace(/\/+$/, '')
|
|
255
|
+
if (!url) throw new Error('openai: missing baseURL')
|
|
256
|
+
if (!url.endsWith('/chat/completions')) url += '/chat/completions'
|
|
257
|
+
const body = JSON.stringify({
|
|
258
|
+
model: cfg.model || 'gpt-4o-mini',
|
|
259
|
+
messages: [
|
|
260
|
+
{ role: 'system', content: OPENAI_SYSTEM },
|
|
261
|
+
{ role: 'user', content: 'Target language: ' + to + '\n\n' + text },
|
|
262
|
+
],
|
|
263
|
+
stream: false,
|
|
264
|
+
})
|
|
265
|
+
const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|::1)([:/]|$)/i.test(url)
|
|
266
|
+
let raw
|
|
267
|
+
if (isLocal) {
|
|
268
|
+
// Loopback endpoints (Ollama etc.): use Node fetch. curl carries the JSON
|
|
269
|
+
// body through the Windows command line, where CJK text is decoded with
|
|
270
|
+
// the ANSI code page and corrupted before the server ever sees it
|
|
271
|
+
// (2026-08-24: zh->ja via host chain returned garbage/off-topic output
|
|
272
|
+
// while direct fetch worked). Loopback bypasses the system proxy anyway,
|
|
273
|
+
// so fetch is both correct and proxy-safe here.
|
|
274
|
+
const res = await fetch(url, {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers: {
|
|
277
|
+
'Authorization': 'Bearer ' + cfg.apiKey,
|
|
278
|
+
'Content-Type': 'application/json',
|
|
279
|
+
},
|
|
280
|
+
body,
|
|
281
|
+
})
|
|
282
|
+
if (!res.ok) throw new Error('openai HTTP ' + res.status)
|
|
283
|
+
raw = await res.text()
|
|
284
|
+
} else {
|
|
285
|
+
raw = await curlPostJson(url, [
|
|
286
|
+
'Authorization: Bearer ' + cfg.apiKey,
|
|
287
|
+
'Content-Type: application/json',
|
|
288
|
+
], body)
|
|
289
|
+
}
|
|
290
|
+
const j = JSON.parse(raw)
|
|
291
|
+
const msg = j && j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content
|
|
292
|
+
if (typeof msg !== 'string' || !msg.trim()) throw new Error('openai empty output: ' + JSON.stringify(j).slice(0, 200))
|
|
293
|
+
return msg.trim()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const ADAPTERS = {
|
|
297
|
+
google: viaGoogle,
|
|
298
|
+
bing: viaBing,
|
|
299
|
+
openai: viaOpenAI,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------- config
|
|
303
|
+
|
|
304
|
+
let configCache = null
|
|
305
|
+
|
|
306
|
+
async function loadConfig() {
|
|
307
|
+
if (configCache !== null) return configCache
|
|
308
|
+
try {
|
|
309
|
+
const raw = await readFile(CONFIG_PATH, 'utf8')
|
|
310
|
+
const parsed = JSON.parse(raw)
|
|
311
|
+
configCache = deepMerge(structuredCloneSafe(DEFAULT_CONFIG), parsed)
|
|
312
|
+
} catch (e) {
|
|
313
|
+
configCache = structuredCloneSafe(DEFAULT_CONFIG)
|
|
314
|
+
await saveConfig(configCache).catch(function () {})
|
|
315
|
+
}
|
|
316
|
+
return configCache
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function saveConfig(cfg) {
|
|
320
|
+
await mkdir(dirname(CONFIG_PATH), { recursive: true })
|
|
321
|
+
await writeFile(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8')
|
|
322
|
+
configCache = cfg
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function structuredCloneSafe(v) { return JSON.parse(JSON.stringify(v)) }
|
|
326
|
+
|
|
327
|
+
function deepMerge(base, patch) {
|
|
328
|
+
for (const k of Object.keys(patch || {})) {
|
|
329
|
+
const pv = patch[k]
|
|
330
|
+
if (pv && typeof pv === 'object' && !Array.isArray(pv) && base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
|
|
331
|
+
deepMerge(base[k], pv)
|
|
332
|
+
} else {
|
|
333
|
+
base[k] = pv
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return base
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------- pipeline
|
|
340
|
+
|
|
341
|
+
const cache = new Map()
|
|
342
|
+
const CACHE_MAX = 600
|
|
343
|
+
const inflight = new Map()
|
|
344
|
+
const state = { lastError: null, lastProvider: null, ok: 0, fail: 0 }
|
|
345
|
+
|
|
346
|
+
function fnv(s) {
|
|
347
|
+
let h = 0x811c9dc5
|
|
348
|
+
for (let i = 0; i < s.length; i++) {
|
|
349
|
+
h ^= s.charCodeAt(i)
|
|
350
|
+
h = Math.imul(h, 0x01000193) >>> 0
|
|
351
|
+
}
|
|
352
|
+
return h.toString(36) + '.' + s.length.toString(36)
|
|
353
|
+
}
|
|
354
|
+
function cacheGet(k) {
|
|
355
|
+
if (!cache.has(k)) return undefined
|
|
356
|
+
const v = cache.get(k)
|
|
357
|
+
cache.delete(k)
|
|
358
|
+
cache.set(k, v)
|
|
359
|
+
return v
|
|
360
|
+
}
|
|
361
|
+
function cacheSet(k, v) {
|
|
362
|
+
cache.set(k, v)
|
|
363
|
+
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isCjk(code) {
|
|
367
|
+
return (code >= 0x3400 && code <= 0x4dbf) || (code >= 0x4e00 && code <= 0x9fff)
|
|
368
|
+
|| (code >= 0xf900 && code <= 0xfaff) || (code >= 0x20000 && code <= 0x2ffff)
|
|
369
|
+
|| (code >= 0x3000 && code <= 0x303f) || (code >= 0xff00 && code <= 0xffef)
|
|
370
|
+
|| (code >= 0x3040 && code <= 0x30ff) || (code >= 0x31f0 && code <= 0x31ff)
|
|
371
|
+
}
|
|
372
|
+
function isLatin(code) { return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) }
|
|
373
|
+
function cjkRatio(text) {
|
|
374
|
+
let c = 0, l = 0
|
|
375
|
+
for (const ch of text) {
|
|
376
|
+
const code = ch.codePointAt(0) || 0
|
|
377
|
+
if (isCjk(code)) c++
|
|
378
|
+
else if (isLatin(code)) l++
|
|
379
|
+
}
|
|
380
|
+
const total = c + l
|
|
381
|
+
return total === 0 ? 0 : c / total
|
|
382
|
+
}
|
|
383
|
+
function isCodeOnlyLine(line) {
|
|
384
|
+
return /^(?:[$>#]\s*)?(?:(?:npm|pnpm|yarn|bun|npx|node|git|cd|curl|pwsh|reg)\b|(?:const|let|var|import|export|function|class|return|if|for|while|async|await)\b|[\[{(]|['"]?[\w.-]+['"]?\s*(?:[:=]|=>)|[\w$.]+\s*\(|(?:[~./][\w@~./-]*|[\w.-]+\/[\w@~./-]+)(?::\d+)?$)/.test(line)
|
|
385
|
+
}
|
|
386
|
+
function hasProse(text) {
|
|
387
|
+
const outside = text
|
|
388
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
389
|
+
.replace(/`[^`\n]*`/g, ' ')
|
|
390
|
+
.replace(/https?:\/\/\S+/g, ' ')
|
|
391
|
+
const lines = outside.split('\n').map(function (l) { return l.trim() }).filter(Boolean)
|
|
392
|
+
if (lines.length === 0) return false
|
|
393
|
+
let hasLetters = false
|
|
394
|
+
for (const ch of outside) {
|
|
395
|
+
const code = ch.codePointAt(0) || 0
|
|
396
|
+
if (isLatin(code) || (code >= 0x3400 && code <= 0x9fff)) { hasLetters = true; break }
|
|
397
|
+
}
|
|
398
|
+
if (!hasLetters) return false
|
|
399
|
+
for (const line of lines) if (!isCodeOnlyLine(line)) return true
|
|
400
|
+
return false
|
|
401
|
+
}
|
|
402
|
+
function needsTranslate(text, target) {
|
|
403
|
+
if (!text || !text.trim()) return false
|
|
404
|
+
if (!hasProse(text)) return false
|
|
405
|
+
if (target.indexOf('zh') === 0 && cjkRatio(text) >= 0.3) return false
|
|
406
|
+
return true
|
|
407
|
+
}
|
|
408
|
+
function chunkText(text) {
|
|
409
|
+
if (text.length <= 1200) return [text]
|
|
410
|
+
const sentences = text.match(/[\s\S]*?[.。!?!?;\n]|[\s\S]+$/g) || [text]
|
|
411
|
+
const chunks = []
|
|
412
|
+
let cur = ''
|
|
413
|
+
for (const sn of sentences) {
|
|
414
|
+
if (cur && cur.length + sn.length > 1200) { chunks.push(cur); cur = '' }
|
|
415
|
+
if (sn.length > 1200) {
|
|
416
|
+
for (let i = 0; i < sn.length; i += 1100) chunks.push(sn.slice(i, i + 1100))
|
|
417
|
+
cur = ''
|
|
418
|
+
} else {
|
|
419
|
+
cur += sn
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (cur.trim()) chunks.push(cur)
|
|
423
|
+
return chunks.length ? chunks : [text]
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function translateViaChain(text, target, cfg) {
|
|
427
|
+
const priority = cfg.priority
|
|
428
|
+
const ids = Object.keys(ADAPTERS)
|
|
429
|
+
.filter(function (id) { return cfg.providers[id] && cfg.providers[id].enabled && ADAPTERS[id] })
|
|
430
|
+
ids.sort(function (a, b) { return (a === priority ? -1 : 0) - (b === priority ? -1 : 0) })
|
|
431
|
+
let lastErr = null
|
|
432
|
+
for (const id of ids) {
|
|
433
|
+
try {
|
|
434
|
+
const chunks = chunkText(text)
|
|
435
|
+
const outs = []
|
|
436
|
+
for (const c of chunks) outs.push(await ADAPTERS[id](c, target, cfg.providers[id]))
|
|
437
|
+
state.ok++
|
|
438
|
+
state.lastProvider = id
|
|
439
|
+
state.lastError = null
|
|
440
|
+
return { ok: true, text: outs.join(''), provider: id }
|
|
441
|
+
} catch (e) {
|
|
442
|
+
lastErr = e
|
|
443
|
+
if (id === 'bing') bingCache = null
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
state.fail++
|
|
447
|
+
state.lastError = lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
448
|
+
return { ok: false, text: text, error: state.lastError }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function handleTranslate(body) {
|
|
452
|
+
const text = body && typeof body.text === 'string' ? body.text : ''
|
|
453
|
+
const target = body && typeof body.target === 'string' && body.target ? body.target : 'zh-CN'
|
|
454
|
+
if (!text.trim()) return { ok: true, text: '', skipped: true }
|
|
455
|
+
const key = target + '|' + fnv(text)
|
|
456
|
+
const hit = cacheGet(key)
|
|
457
|
+
if (hit !== undefined) return hit
|
|
458
|
+
const busy = inflight.get(key)
|
|
459
|
+
if (busy !== undefined) return busy
|
|
460
|
+
const job = (async function () {
|
|
461
|
+
if (!needsTranslate(text, target)) {
|
|
462
|
+
const r = { ok: true, text: text, skipped: true }
|
|
463
|
+
cacheSet(key, r)
|
|
464
|
+
return r
|
|
465
|
+
}
|
|
466
|
+
const cfg = await loadConfig()
|
|
467
|
+
const r = await translateViaChain(text, target, cfg)
|
|
468
|
+
if (r.ok) cacheSet(key, r)
|
|
469
|
+
return r
|
|
470
|
+
})()
|
|
471
|
+
inflight.set(key, job)
|
|
472
|
+
try {
|
|
473
|
+
return await job
|
|
474
|
+
} finally {
|
|
475
|
+
inflight.delete(key)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------- http glue
|
|
480
|
+
|
|
481
|
+
function sendJson(res, code, obj) {
|
|
482
|
+
const body = JSON.stringify(obj)
|
|
483
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
484
|
+
res.end(body)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function readBody(req) {
|
|
488
|
+
return new Promise(function (resolve, reject) {
|
|
489
|
+
const chunks = []
|
|
490
|
+
let size = 0
|
|
491
|
+
req.on('data', function (c) {
|
|
492
|
+
size += c.length
|
|
493
|
+
if (size > 2 * 1024 * 1024) { reject(new Error('body too large')); req.destroy(); return }
|
|
494
|
+
chunks.push(c)
|
|
495
|
+
})
|
|
496
|
+
req.on('end', function () {
|
|
497
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) }
|
|
498
|
+
catch (e) { reject(e) }
|
|
499
|
+
})
|
|
500
|
+
req.on('error', reject)
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// List models served by the configured openai-compatible endpoint (usually
|
|
505
|
+
// the local Ollama). Used by the settings panel to offer a model picker.
|
|
506
|
+
async function handleModels() {
|
|
507
|
+
const cfg = await loadConfig()
|
|
508
|
+
const p = cfg.providers && cfg.providers.openai
|
|
509
|
+
const base = p && p.baseURL ? String(p.baseURL).replace(/\/+$/, '') : ''
|
|
510
|
+
if (!base) return { ok: true, models: [], current: p && p.model }
|
|
511
|
+
try {
|
|
512
|
+
const url = base + '/models'
|
|
513
|
+
const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|::1)([:/]|$)/i.test(url)
|
|
514
|
+
let raw
|
|
515
|
+
if (isLocal) {
|
|
516
|
+
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + (p.apiKey || '') } })
|
|
517
|
+
if (!res.ok) throw new Error('models HTTP ' + res.status)
|
|
518
|
+
raw = await res.text()
|
|
519
|
+
} else {
|
|
520
|
+
const extra = ['-H', 'Authorization: Bearer ' + (p.apiKey || ''), url]
|
|
521
|
+
raw = await runProgram('curl', await curlArgs(extra), 30000)
|
|
522
|
+
}
|
|
523
|
+
const j = JSON.parse(raw)
|
|
524
|
+
const list = Array.isArray(j && j.data)
|
|
525
|
+
? j.data.map(function (m) { return m && typeof m.id === 'string' ? m.id : null }).filter(Boolean)
|
|
526
|
+
: []
|
|
527
|
+
return { ok: true, models: list, current: p.model }
|
|
528
|
+
} catch (e) {
|
|
529
|
+
return { ok: true, models: [], current: p.model, error: e instanceof Error ? e.message : String(e) }
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------------------------------------------------------------- model pull
|
|
534
|
+
|
|
535
|
+
// In-flight model downloads, keyed by model name. Started from the settings
|
|
536
|
+
// panel (first local-model selection); the Ollama /api/pull stream is consumed
|
|
537
|
+
// here and snapshotted for the polling status route. On success the openai
|
|
538
|
+
// provider is auto-configured (enabled + model) and priority switches to it.
|
|
539
|
+
const pullJobs = new Map()
|
|
540
|
+
|
|
541
|
+
async function pullModel(model) {
|
|
542
|
+
const clean = String(model || '').trim()
|
|
543
|
+
if (!clean) return { ok: false, error: 'model name required' }
|
|
544
|
+
const live = pullJobs.get(clean)
|
|
545
|
+
if (live && !live.done && !live.error) return { ok: true, model: clean, running: true }
|
|
546
|
+
const job = { model: clean, status: 'starting', digest: null, total: 0, completed: 0, done: false, error: null, at: Date.now() }
|
|
547
|
+
pullJobs.set(clean, job)
|
|
548
|
+
;(async function () {
|
|
549
|
+
try {
|
|
550
|
+
const res = await fetch('http://localhost:11434/api/pull', {
|
|
551
|
+
method: 'POST',
|
|
552
|
+
headers: { 'Content-Type': 'application/json' },
|
|
553
|
+
body: JSON.stringify({ model: clean, stream: true }),
|
|
554
|
+
})
|
|
555
|
+
if (!res.ok) throw new Error('ollama pull HTTP ' + res.status)
|
|
556
|
+
const reader = res.body.getReader()
|
|
557
|
+
const decoder = new TextDecoder()
|
|
558
|
+
let buf = ''
|
|
559
|
+
for (;;) {
|
|
560
|
+
const { done, value } = await reader.read()
|
|
561
|
+
if (done) break
|
|
562
|
+
buf += decoder.decode(value, { stream: true })
|
|
563
|
+
let idx
|
|
564
|
+
while ((idx = buf.indexOf('\n')) !== -1) {
|
|
565
|
+
const line = buf.slice(0, idx).trim()
|
|
566
|
+
buf = buf.slice(idx + 1)
|
|
567
|
+
if (!line) continue
|
|
568
|
+
try {
|
|
569
|
+
const ev = JSON.parse(line)
|
|
570
|
+
if (ev.status) job.status = ev.status
|
|
571
|
+
if (ev.digest) job.digest = ev.digest
|
|
572
|
+
if (typeof ev.total === 'number') job.total = ev.total
|
|
573
|
+
if (typeof ev.completed === 'number') job.completed = ev.completed
|
|
574
|
+
job.at = Date.now()
|
|
575
|
+
if (ev.status === 'success') job.done = true
|
|
576
|
+
} catch (e) { /* ignore a malformed progress line */ }
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (!job.done) throw new Error('pull stream ended before success')
|
|
580
|
+
// Auto-configure: enable the openai provider with the freshly pulled
|
|
581
|
+
// model and make it the priority.
|
|
582
|
+
const cfg = await loadConfig()
|
|
583
|
+
const base = cfg.providers.openai.baseURL || 'http://localhost:11434/v1'
|
|
584
|
+
const key = cfg.providers.openai.apiKey || 'ollama-local'
|
|
585
|
+
cfg.providers.openai = { enabled: true, baseURL: base, model: clean, apiKey: key }
|
|
586
|
+
cfg.priority = 'openai'
|
|
587
|
+
await saveConfig(cfg)
|
|
588
|
+
job.status = 'configured'
|
|
589
|
+
} catch (e) {
|
|
590
|
+
job.error = e instanceof Error ? e.message : String(e)
|
|
591
|
+
job.status = 'error'
|
|
592
|
+
}
|
|
593
|
+
})()
|
|
594
|
+
return { ok: true, model: clean }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function handlePullStatus() {
|
|
598
|
+
// Normalize stuck jobs: if the model is already installed (e.g. the pull
|
|
599
|
+
// stream ended without a success event during sha256 verification), report
|
|
600
|
+
// it as configured so the UI never hangs on "verifying" (2026-08-25).
|
|
601
|
+
let installed = null
|
|
602
|
+
try {
|
|
603
|
+
const res = await fetch('http://localhost:11434/api/tags')
|
|
604
|
+
if (res.ok) {
|
|
605
|
+
const j = await res.json()
|
|
606
|
+
installed = new Set((j.models || []).map(function (m) { return m && m.name }))
|
|
607
|
+
}
|
|
608
|
+
} catch (e) { /* ollama unreachable — leave jobs as-is */ }
|
|
609
|
+
const jobs = []
|
|
610
|
+
for (const job of pullJobs.values()) {
|
|
611
|
+
const isInstalled = installed !== null && installed.has(job.model)
|
|
612
|
+
if (isInstalled && !job.done && !job.error) {
|
|
613
|
+
job.done = true
|
|
614
|
+
job.status = 'configured'
|
|
615
|
+
job.error = null
|
|
616
|
+
}
|
|
617
|
+
jobs.push({
|
|
618
|
+
model: job.model,
|
|
619
|
+
status: job.status,
|
|
620
|
+
digest: job.digest,
|
|
621
|
+
total: job.total,
|
|
622
|
+
completed: job.completed,
|
|
623
|
+
percent: job.total > 0 ? Math.min(100, Math.round((job.completed / job.total) * 100)) : 0,
|
|
624
|
+
done: job.done,
|
|
625
|
+
error: job.error,
|
|
626
|
+
})
|
|
627
|
+
}
|
|
628
|
+
return { ok: true, jobs }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export function apply(ctx) {
|
|
632
|
+
subprocessRef = ctx.get('subprocess')
|
|
633
|
+
timerRef = ctx.get('timer')
|
|
634
|
+
|
|
635
|
+
const webServer = ctx.get('webServer')
|
|
636
|
+
if (webServer === undefined) return
|
|
637
|
+
|
|
638
|
+
// ctx.effect expects a FACTORY returning a disposer. Passing
|
|
639
|
+
// webServer.register(route) directly made Cordis invoke the returned
|
|
640
|
+
// disposer immediately — every route self-removed right after registering,
|
|
641
|
+
// silently (2026-08-22 root cause #3).
|
|
642
|
+
ctx.effect(function () {
|
|
643
|
+
const dispose = webServer.register({
|
|
644
|
+
kind: 'exact',
|
|
645
|
+
path: '/_xlate/translate',
|
|
646
|
+
handler: async function (req, res) {
|
|
647
|
+
try {
|
|
648
|
+
if (req.method !== 'POST') { sendJson(res, 405, { error: 'POST only' }); return }
|
|
649
|
+
const body = await readBody(req)
|
|
650
|
+
sendJson(res, 200, await handleTranslate(body))
|
|
651
|
+
} catch (e) {
|
|
652
|
+
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) })
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
})
|
|
656
|
+
return dispose
|
|
657
|
+
}, 'dsh-think-translate: translate route')
|
|
658
|
+
|
|
659
|
+
ctx.effect(function () {
|
|
660
|
+
const dispose = webServer.register({
|
|
661
|
+
kind: 'exact',
|
|
662
|
+
path: '/_xlate/config',
|
|
663
|
+
handler: async function (req, res) {
|
|
664
|
+
try {
|
|
665
|
+
if (req.method === 'GET') {
|
|
666
|
+
sendJson(res, 200, await loadConfig())
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
if (req.method === 'POST') {
|
|
670
|
+
const patch = await readBody(req)
|
|
671
|
+
const merged = deepMerge(await loadConfig(), patch)
|
|
672
|
+
await saveConfig(merged)
|
|
673
|
+
sendJson(res, 200, merged)
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
sendJson(res, 405, { error: 'GET/POST only' })
|
|
677
|
+
} catch (e) {
|
|
678
|
+
sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) })
|
|
679
|
+
}
|
|
680
|
+
},
|
|
681
|
+
})
|
|
682
|
+
return dispose
|
|
683
|
+
}, 'dsh-think-translate: config route')
|
|
684
|
+
|
|
685
|
+
ctx.effect(function () {
|
|
686
|
+
const dispose = webServer.register({
|
|
687
|
+
kind: 'exact',
|
|
688
|
+
path: '/_xlate/models',
|
|
689
|
+
handler: async function (req, res) {
|
|
690
|
+
try {
|
|
691
|
+
if (req.method !== 'GET') { sendJson(res, 405, { error: 'GET only' }); return }
|
|
692
|
+
sendJson(res, 200, await handleModels())
|
|
693
|
+
} catch (e) {
|
|
694
|
+
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) })
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
})
|
|
698
|
+
return dispose
|
|
699
|
+
}, 'dsh-think-translate: models route')
|
|
700
|
+
|
|
701
|
+
ctx.effect(function () {
|
|
702
|
+
const dispose = webServer.register({
|
|
703
|
+
kind: 'exact',
|
|
704
|
+
path: '/_xlate/model/pull',
|
|
705
|
+
handler: async function (req, res) {
|
|
706
|
+
try {
|
|
707
|
+
if (req.method !== 'POST') { sendJson(res, 405, { error: 'POST only' }); return }
|
|
708
|
+
const body = await readBody(req)
|
|
709
|
+
sendJson(res, 200, await pullModel(body && body.model))
|
|
710
|
+
} catch (e) {
|
|
711
|
+
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) })
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
})
|
|
715
|
+
return dispose
|
|
716
|
+
}, 'dsh-think-translate: model pull route')
|
|
717
|
+
|
|
718
|
+
ctx.effect(function () {
|
|
719
|
+
const dispose = webServer.register({
|
|
720
|
+
kind: 'exact',
|
|
721
|
+
path: '/_xlate/model/pull-status',
|
|
722
|
+
handler: async function (req, res) {
|
|
723
|
+
try {
|
|
724
|
+
if (req.method !== 'GET') { sendJson(res, 405, { error: 'GET only' }); return }
|
|
725
|
+
sendJson(res, 200, await handlePullStatus())
|
|
726
|
+
} catch (e) {
|
|
727
|
+
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) })
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
})
|
|
731
|
+
return dispose
|
|
732
|
+
}, 'dsh-think-translate: model pull-status route')
|
|
733
|
+
}
|