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/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
+ }