dsh-think-translate 1.2.5 → 1.2.8

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.de.md CHANGED
@@ -69,6 +69,10 @@ Am Plugin ist nichts zu konfigurieren: Es deklariert keine Reihenfolge-Abhängig
69
69
 
70
70
  Für DSH **0.2.0-rc.2** verwenden Sie **dsh-think-translate 1.2.5 oder neuer**. Version 1.2.5 behebt den Registrierungskonflikt des Slots `todo_write` und deklariert den Peer-Versionsbereich 0.2. Führen Sie `dsh plugin --profile web add dsh-think-translate@latest` aus und starten Sie DSH neu; eine Versionsausnahme ist nicht nötig.
71
71
 
72
+ **1.2.6** behebt doppelt angezeigte Gedanken und Antworten in DSH 0.2: Der Renderer und die Originaltext-Anzeige bei Fehlern beachten die Gruppen reasoning/response.
73
+
74
+ **1.2.7 lokale Vorbereitung:** Aktiviertes Ollama an erster Stelle oder dessen Test findet vorhandene Installationen und startet/lädt das Modell. Fehlende Software und Modelle werden erst über die Vorbereitungsschaltfläche installiert/heruntergeladen, mit Fortschritt je Phase. Automatische Softwareinstallation unter Windows, sonst offizielle Anleitung. Der aktuelle Anbieter folgt der Priorität aktivierter, geprüfter Anbieter.
75
+
72
76
  ## 🚀 Verwendung
73
77
 
74
78
  1. **Einstellungen → Übersetzung der Gedankenkette** öffnen
package/README.es.md CHANGED
@@ -69,6 +69,10 @@ No hay nada que configurar en el plugin: no declara dependencia de orden con los
69
69
 
70
70
  Para DSH **0.2.0-rc.2**, usa **dsh-think-translate 1.2.5 o posterior**. La versión 1.2.5 corrige el conflicto de registro del slot `todo_write` y declara el rango peer 0.2. Ejecuta `dsh plugin --profile web add dsh-think-translate@latest` y reinicia DSH; no hace falta una excepción de versión.
71
71
 
72
+ **1.2.6** corrige la duplicación del razonamiento y las respuestas en DSH 0.2: el renderizador y su texto original de respaldo respetan los grupos reasoning/response.
73
+
74
+ **1.2.7 preparación local:** Ollama habilitado en primera posición, o su botón de prueba, detecta la instalación existente y arranca/carga el modelo. La instalación y descarga requieren el botón de preparación y muestran progreso por etapas. La instalación automática del software admite Windows; otros sistemas enlazan la guía oficial. El proveedor en uso sigue la prioridad de los proveedores habilitados y verificados.
75
+
72
76
  ## 🚀 Uso
73
77
 
74
78
  1. Abre **Ajustes → Traducción de cadena de pensamiento**
package/README.fr.md CHANGED
@@ -69,6 +69,10 @@ Rien à configurer côté plugin : il ne déclare aucune dépendance d'ordre vis
69
69
 
70
70
  Pour DSH **0.2.0-rc.2**, utilisez **dsh-think-translate 1.2.5 ou une version ultérieure**. La version 1.2.5 corrige le conflit du slot `todo_write` et déclare la plage de versions peer 0.2. Exécutez `dsh plugin --profile web add dsh-think-translate@latest`, puis redémarrez DSH ; aucune exception de version requise.
71
71
 
72
+ **1.2.6** corrige les pensées et réponses affichées en double dans DSH 0.2 : le rendu et le texte original de secours respectent les groupes reasoning/response.
73
+
74
+ **1.2.7 préparation locale :** Ollama activé en première position, ou son test, détecte une installation existante, démarre le service et charge le modèle. Le bouton de préparation autorise les installations et téléchargements manquants, avec progression par étapes. Installation automatique du logiciel sur Windows ; guide officiel ailleurs. Le fournisseur utilisé respecte la priorité des fournisseurs activés et vérifiés.
75
+
72
76
  ## 🚀 Utilisation
73
77
 
74
78
  1. Ouvrez **Réglages → Traduction de chaîne de réflexion**
package/README.ja.md CHANGED
@@ -69,6 +69,10 @@ New-Item -ItemType Junction -Path "$HOME\.dsh\profiles\node_modules\dsh-think-tr
69
69
 
70
70
  DSH **0.2.0-rc.2** では **dsh-think-translate 1.2.5 以降**を使用してください。1.2.5 は `todo_write` の slot 登録競合を修正し、0.2 の peer バージョン範囲を宣言します。`dsh plugin --profile web add dsh-think-translate@latest` を実行して DSH を再起動してください。バージョンの例外設定は不要です。
71
71
 
72
+ **1.2.6** は DSH 0.2 で思考と回答が重複表示される問題を修正します。助手の表示とエラー時の原文表示が reasoning/response の各グループに従います。
73
+
74
+ **1.2.7 ローカル準備:**有効な Ollama を最優先にするかテストすると、既存のインストールを検出し起動・モデルロードします。不足するソフトやモデルは準備ボタンでインストールし、段階別の進捗を表示します。自動インストールは Windows 対応、他の OS は公式ガイドを表示します。使用中の表示は優先順位の高い検証済み提供元を維持します。
75
+
72
76
  ## 🚀 使い方
73
77
 
74
78
  1. **設定 → 思考チェーン翻訳** を開く
package/README.ko.md CHANGED
@@ -69,6 +69,10 @@ New-Item -ItemType Junction -Path "$HOME\.dsh\profiles\node_modules\dsh-think-tr
69
69
 
70
70
  DSH **0.2.0-rc.2**에서는 **dsh-think-translate 1.2.5 이상**을 사용하세요. 1.2.5는 `todo_write` slot 등록 충돌을 수정하고 0.2 peer 버전 범위를 선언합니다. `dsh plugin --profile web add dsh-think-translate@latest`를 실행한 뒤 DSH를 다시 시작하세요. 버전 예외 설정은 필요 없습니다.
71
71
 
72
+ **1.2.6**은 DSH 0.2에서 사고와 답변이 중복 표시되는 문제를 수정합니다. 어시스턴트 렌더링과 오류 시 원문 표시 모두 reasoning/response 그룹을 따릅니다.
73
+
74
+ **1.2.7 로컬 준비:** 활성 Ollama를 첫 순위로 두거나 테스트하면 기존 설치를 찾아 시작하고 모델을 로드합니다. 누락된 소프트웨어와 모델은 준비 버튼을 눌러 설치/다운로드하며 단계별 진행 상황을 표시합니다. 자동 설치는 Windows에서 지원하며 다른 OS는 공식 안내를 제공합니다. 현재 사용 표시는 우선순위가 가장 높은 검증된 제공자를 유지합니다.
75
+
72
76
  ## 🚀 사용법
73
77
 
74
78
  1. **설정 → 사고 체인 번역** 열기
package/README.md CHANGED
@@ -66,6 +66,10 @@ Nothing to configure on the plugin side: it declares no ordering dependency on D
66
66
 
67
67
  For DSH **0.2.0-rc.2**, use **dsh-think-translate 1.2.5 or later**. Version 1.2.5 fixes the `todo_write` slot collision and declares the 0.2 peer range. Run `dsh plugin --profile web add dsh-think-translate@latest`, then restart DSH; no version exemption is needed.
68
68
 
69
+ Version **1.2.6** fixes duplicated thinking and answer text in DSH 0.2: the assistant renderer now respects the separate reasoning/response groups, including its original-text error fallback.
70
+
71
+ **1.2.7 local setup:** enabled Ollama in first priority, or its Test button, finds an existing installation and starts/loads it automatically. Missing software/models require the labelled setup action, with staged download/install/load progress. Automatic software installation supports Windows; other systems link to the official guide. “In use” follows the first enabled, verified provider in priority order; testing a lower row does not override a verified higher row.
72
+
69
73
  ## 🚀 Usage
70
74
 
71
75
  1. Open **Settings → Think Translation**
package/README.ru.md CHANGED
@@ -69,6 +69,10 @@ New-Item -ItemType Junction -Path "$HOME\.dsh\profiles\node_modules\dsh-think-tr
69
69
 
70
70
  Для DSH **0.2.0-rc.2** используйте **dsh-think-translate 1.2.5 или новее**. Версия 1.2.5 исправляет конфликт регистрации slot `todo_write` и объявляет диапазон peer-версий 0.2. Выполните `dsh plugin --profile web add dsh-think-translate@latest`, затем перезапустите DSH; исключение версии не требуется.
71
71
 
72
+ **1.2.6** исправляет двойное отображение рассуждений и ответов в DSH 0.2: основной вывод и исходный текст при ошибке учитывают группы reasoning/response.
73
+
74
+ **1.2.7 локальная подготовка:** включённый Ollama на первом месте или его тест находит существующую установку, запускает сервис и загружает модель. Отсутствующие компоненты устанавливаются после нажатия кнопки подготовки с отображением этапов и прогресса. Автоустановка программы поддерживает Windows; для других ОС доступна официальная инструкция. Текущий провайдер выбирается по приоритету включённых и проверенных провайдеров.
75
+
72
76
  ## 🚀 Использование
73
77
 
74
78
  1. Откройте **Настройки → Перевод цепочки размышлений**
package/README.zh-CN.md CHANGED
@@ -69,6 +69,10 @@ New-Item -ItemType Junction -Path "$HOME\.dsh\profiles\node_modules\dsh-think-tr
69
69
 
70
70
  DSH **0.2.0-rc.2** 请使用 **dsh-think-translate 1.2.5 或更新版本**。1.2.5 修复了 `todo_write` 的 slot 注册冲突,并声明支持 0.2 版本范围。执行 `dsh plugin --profile web add dsh-think-translate@latest` 后重启 DSH,无需添加版本豁免。
71
71
 
72
+ **1.2.6** 修复 DSH 0.2 中思考与回答重复显示的问题:助手渲染器现在遵守 reasoning/response 分组,出错时的原文回退也按组显示。
73
+
74
+ **1.2.7 本地准备:**已启用的 Ollama 排到第一位,或点击其测试按钮时,会自动查找已有安装并启动、加载模型。缺少软件或模型时,点击首次准备入口后才安装/下载,并显示分阶段进度。自动安装软件支持 Windows,其他系统提供官方指南。“当前使用”按优先级选择第一个已启用且验证成功的提供方,测试后面的行不会覆盖已成功的前面一行。
75
+
72
76
  ## 🚀 使用
73
77
 
74
78
  1. 打开 **设置 → 思考链翻译**
package/lib/client.js CHANGED
@@ -23,7 +23,7 @@ window.__ModuleLoader__.load({
23
23
  // there yet (an older process still serving the port), and the contract test
24
24
  // pins PLUGIN_VERSION to package.json so the two can never drift.
25
25
  var PLUGIN_NAME = "dsh-think-translate";
26
- var PLUGIN_VERSION = "1.2.5";
26
+ var PLUGIN_VERSION = "1.2.8";
27
27
  var PLUGIN_REPO = "https://github.com/UncleK/dsh-think-translate";
28
28
  // The official MarkdownText contract changed in 0.1.2-alpha: it now takes a
29
29
  // nested `labels` object and dereferences it without defaults inside its code
@@ -665,7 +665,14 @@ window.__ModuleLoader__.load({
665
665
  var MEM_KEY = "dsh-xlate-cache:";
666
666
  function cacheMemGet(key) {
667
667
  var v = mem.get(key);
668
- if (v !== undefined) return v;
668
+ if (v !== undefined) {
669
+ if (v.ok === false) {
670
+ if (Date.now() < v.retryAt) return v;
671
+ mem.delete(key);
672
+ return undefined;
673
+ }
674
+ return v;
675
+ }
669
676
  try {
670
677
  var lv = localStorage.getItem(MEM_KEY + key);
671
678
  if (lv !== null) {
@@ -705,6 +712,7 @@ window.__ModuleLoader__.load({
705
712
  function cacheMemSet(key, entry) {
706
713
  mem.set(key, entry);
707
714
  if (mem.size > 800) mem.delete(mem.keys().next().value);
715
+ if (entry && !entry.ok) return;
708
716
  try {
709
717
  var stamped = entry && typeof entry === "object" ? Object.assign({ at: Date.now() }, entry) : entry;
710
718
  localStorage.setItem(MEM_KEY + key, JSON.stringify(stamped));
@@ -714,6 +722,17 @@ window.__ModuleLoader__.load({
714
722
  }
715
723
  var pend = new Map();
716
724
  var stats = { ok: 0, fail: 0, lastError: null, lastProvider: null, lastModel: null };
725
+ var providerChecks = {};
726
+ var providerTestSequence = {};
727
+ var statusListeners = new Set();
728
+ function providerFingerprint(p) {
729
+ return fnv(JSON.stringify({ type: p.type, baseURL: p.baseURL, model: p.model, apiKeyEnv: p.apiKeyEnv,
730
+ apiKey: p.source === "dsh" || p.apiKeyEnv ? undefined : p.apiKey }));
731
+ }
732
+ function recordCheck(id, ok, model, signature) {
733
+ providerChecks[id] = { ok: ok, model: model || null, signature: signature };
734
+ statusListeners.forEach(function (notify) { notify(); });
735
+ }
717
736
  // Diagnostic ledger surfaced in the settings panel (2026-08-24): bundle
718
737
  // version, apply execution, per-slot registration outcome, current lang.
719
738
  var diag = { version: "v5-mode", applied: false, lang: "?", slots: {}, lastLangChange: 0 };
@@ -838,14 +857,21 @@ window.__ModuleLoader__.load({
838
857
  }
839
858
 
840
859
  async function hostAttempt(text, target) {
860
+ // The host may first load a local model (up to 120s), then infer or
861
+ // walk its fallback providers. Bound the full route, not each hop.
841
862
  var res = await fetchWithTimeout("/_xlate/translate", {
842
863
  method: "POST",
843
864
  headers: { "Content-Type": "application/json" },
844
865
  body: JSON.stringify({ text: text, target: target })
845
- }, 30000);
866
+ }, 180000);
846
867
  if (!res.ok) throw new Error("route HTTP " + res.status);
847
868
  var j = await res.json();
848
- if (!(j && j.ok)) throw new Error("route ok=false");
869
+ if (j && Array.isArray(j.attempts)) j.attempts.forEach(function (attempt) { recordCheck(attempt.provider, attempt.ok, attempt.model, attempt.signature); });
870
+ if (!(j && j.ok)) {
871
+ var error = new Error(j && j.error || "route ok=false");
872
+ if (j && typeof j.retryAt === "number") error.retryAt = j.retryAt;
873
+ throw error;
874
+ }
849
875
  return j;
850
876
  }
851
877
 
@@ -863,14 +889,13 @@ window.__ModuleLoader__.load({
863
889
  return skipped;
864
890
  }
865
891
  // Host chain with bounded retries: a single transient google/proxy
866
- // blip must not flip the row into its permanent failed state
867
- // (2026-08-22: one blip used to kill translation for the whole block).
892
+ // blip must not flip the row into its permanent failed state.
868
893
  var r = null, lastErr = null;
869
- var backoff = [0, 400, 1500];
894
+ var backoff = [0, 400];
870
895
  for (var i = 0; i < backoff.length; i++) {
871
896
  if (backoff[i] > 0) await sleepMs(backoff[i]);
872
897
  try { r = await hostAttempt(text, target); break; }
873
- catch (e) { r = null; lastErr = e; }
898
+ catch (e) { r = null; lastErr = e; if (e && e.retryAt > Date.now()) break; }
874
899
  }
875
900
  if (!(r && r.ok)) {
876
901
  try {
@@ -881,6 +906,7 @@ window.__ModuleLoader__.load({
881
906
  } catch (e2) { r = null; if (e2 && e2.message) lastErr = e2; }
882
907
  }
883
908
  if (r && r.ok) {
909
+ if (r.providerSignature) recordCheck(r.provider, true, r.model, r.providerSignature);
884
910
  stats.ok++; stats.lastError = null; stats.lastProvider = r.provider; stats.lastModel = r.model || null;
885
911
  memSet(key, r);
886
912
  return r;
@@ -889,7 +915,9 @@ window.__ModuleLoader__.load({
889
915
  } catch (e) {
890
916
  stats.fail++;
891
917
  stats.lastError = e instanceof Error ? e.message : String(e);
892
- return { ok: false, text: text, skipped: false };
918
+ var failedResult = { ok: false, text: text, skipped: false, retryAt: Math.max(Date.now() + 30000, lastErr && lastErr.retryAt || 0) };
919
+ memSet(key, failedResult);
920
+ return failedResult;
893
921
  }
894
922
  })();
895
923
  pend.set(key, p);
@@ -910,21 +938,18 @@ window.__ModuleLoader__.load({
910
938
  useEffect(function () {
911
939
  if (!active || key === null) { setEntry(undefined); return undefined; }
912
940
  var alive = true;
913
- var hit = cacheMemGet(key);
914
- if (hit !== undefined) { setEntry(hit); return undefined; }
915
- requestTranslate(text, s.lang).then(function (r) {
941
+ var retryTimer = null;
942
+ function attempt() {
916
943
  if (!alive) return;
917
- setEntry(r);
918
- if (!(r && r.ok)) {
919
- // One delayed retry for answer text: failures are not cached, so
920
- // this re-issues a real request after the backoff window.
921
- setTimeout(function () {
922
- if (!alive) return;
923
- requestTranslate(text, s.lang).then(function (r2) { if (alive) setEntry(r2); });
924
- }, 3000);
925
- }
926
- });
927
- return function () { alive = false; };
944
+ retryTimer = null;
945
+ requestTranslate(text, s.lang).then(function (r) {
946
+ if (!alive) return;
947
+ setEntry(r);
948
+ if (!(r && r.ok)) retryTimer = setTimeout(attempt, Math.max(1, (r && r.retryAt || Date.now() + 30000) - Date.now()));
949
+ });
950
+ }
951
+ attempt();
952
+ return function () { alive = false; if (retryTimer !== null) clearTimeout(retryTimer); };
928
953
  }, [key, active]);
929
954
  return entry;
930
955
  }
@@ -1013,7 +1038,7 @@ window.__ModuleLoader__.load({
1013
1038
  (async function () {
1014
1039
  while (alive) {
1015
1040
  var cur = tRef.current;
1016
- if (cur.failed || cur.done) return;
1041
+ if (cur.done) return;
1017
1042
  if (cur.busy) { await sleepMs(150); continue; }
1018
1043
  if (cur.retryAt && Date.now() < cur.retryAt) { await sleepMs(300); continue; }
1019
1044
  var latest = stateRef.current.text;
@@ -1047,19 +1072,15 @@ window.__ModuleLoader__.load({
1047
1072
  mutate(function (p) {
1048
1073
  var next = Object.assign({}, p, { busy: false });
1049
1074
  if (r && r.ok) {
1075
+ next.failed = false;
1076
+ next.retryAt = 0;
1050
1077
  if (!r.skipped) next.transText = p.transText + r.text;
1051
1078
  next.segIdx = Math.max(p.segIdx, nextIdx);
1052
1079
  } else {
1053
- var fc = (p.failCount || 0) + 1;
1054
- if (fc >= 3) {
1055
- next.failed = true;
1056
- } else {
1057
- next.failCount = fc;
1058
- next.retryAt = Date.now() + 2500;
1059
- setTimeout(function () {
1060
- mutate(function (q) { return Object.assign({}, q, { retryAt: 0 }); });
1061
- }, 2600);
1062
- }
1080
+ // A negative-cache hit is a cooldown, not another failed
1081
+ // network attempt. Resume automatically after it expires.
1082
+ next.failed = true;
1083
+ next.retryAt = Math.max(Date.now() + 2500, r && r.retryAt || Date.now() + 30000);
1063
1084
  }
1064
1085
  return next;
1065
1086
  });
@@ -1227,6 +1248,7 @@ window.__ModuleLoader__.load({
1227
1248
  function AssistantView(props) {
1228
1249
  var node = props.node;
1229
1250
  var data = node.data;
1251
+ var groupPart = props.groupPart;
1230
1252
  var useTurnData = props.useTurnData;
1231
1253
  var tt = UI_TEXT[useStore().lang] || UI_TEXT.en;
1232
1254
  var tail = useTurnData("turn-tail");
@@ -1247,6 +1269,10 @@ window.__ModuleLoader__.load({
1247
1269
  for (var i = 0; i < blocks.length; i++) {
1248
1270
  var block = blocks[i];
1249
1271
  if (!block) continue;
1272
+ // DSH may mount this step separately for reasoning and response.
1273
+ // Keep original block indexes so streaming-tail detection is unchanged.
1274
+ if (groupPart === "reasoning" && block.kind !== "reasoning") continue;
1275
+ if (groupPart === "response" && block.kind === "reasoning") continue;
1250
1276
  if (block.kind === "text") {
1251
1277
  rendered.push(createElement(TextBlockSafe, { key: "t" + i, text: block.text, streaming: streaming }));
1252
1278
  } else if (block.kind === "reasoning") {
@@ -1263,15 +1289,19 @@ window.__ModuleLoader__.load({
1263
1289
  rendered.push(createElement("pre", { key: "u" + i, className: "xl-pre" }, JSON.stringify(block, null, 2)));
1264
1290
  }
1265
1291
  }
1292
+ var showStopped = interrupted && (groupPart === undefined || groupPart === "response" ||
1293
+ !blocks.some(function (b) { return b && b.kind !== "reasoning" && b.kind !== "tool-call"; }));
1266
1294
  return createElement("div", null, rendered,
1267
- interrupted ? createElement("span", { className: "xl-warn" }, (props.t && props.t("message.stopped")) || tt.stopped) : null);
1295
+ showStopped ? createElement("span", { className: "xl-warn" }, (props.t && props.t("message.stopped")) || tt.stopped) : null);
1268
1296
  }
1269
1297
 
1270
- function originalBlocksText(blocks) {
1298
+ function originalBlocksText(blocks, groupPart) {
1271
1299
  var parts = [];
1272
1300
  for (var i = 0; i < blocks.length; i++) {
1273
1301
  var b = blocks[i];
1274
1302
  if (!b) continue;
1303
+ if (groupPart === "reasoning" && b.kind !== "reasoning") continue;
1304
+ if (groupPart === "response" && b.kind === "reasoning") continue;
1275
1305
  if ((b.kind === "text" || b.kind === "reasoning") && typeof b.text === "string" && b.text.trim()) parts.push(b.text);
1276
1306
  }
1277
1307
  return parts;
@@ -1294,7 +1324,7 @@ window.__ModuleLoader__.load({
1294
1324
  try {
1295
1325
  var node = props && props.node;
1296
1326
  var blocks = node && node.data && Array.isArray(node.data.blocks) ? node.data.blocks : [];
1297
- var parts = originalBlocksText(blocks);
1327
+ var parts = originalBlocksText(blocks, props.groupPart);
1298
1328
  if (parts.length) fb = createElement("div", null, createElement(Markdownish, { text: parts.join("\n\n") }));
1299
1329
  } catch (e) { fb = null; }
1300
1330
  return createElement(XBoundary, { fallback: fb }, createElement(AssistantView, props));
@@ -1479,11 +1509,16 @@ window.__ModuleLoader__.load({
1479
1509
  // 缓存条目 counter kept the old number until some other state moved.
1480
1510
  var tickPair = useState(0);
1481
1511
  var bumpTick = tickPair[1];
1482
- // "当前使用" answers the question the block exists for: which provider and
1483
- // model the last successful translation actually went through.
1484
- var inUse = stats.lastProvider
1485
- ? ((t.labels[stats.lastProvider] || stats.lastProvider) + (stats.lastModel ? " · " + stats.lastModel : ""))
1486
- : t.providerDesc;
1512
+ useEffect(function () { var notify = function () { bumpTick(function (n) { return n + 1; }); }; statusListeners.add(notify); return function () { statusListeners.delete(notify); }; }, []);
1513
+ // A lower-ranked provider test cannot displace a verified higher-ranked one.
1514
+ var currentCfg = draft.cfg || {};
1515
+ var ids = (currentCfg.chain || []).concat(currentCfg.fallback && currentCfg.fallback.enabled ? currentCfg.fallback.chain || [] : []);
1516
+ var current = null;
1517
+ for (var i = 0; i < ids.length; i++) {
1518
+ var p = currentCfg.providers && currentCfg.providers[ids[i]], check = providerChecks[ids[i]];
1519
+ if (p && (p.enabled || p.source === "dsh") && check && check.ok && check.signature === providerFingerprint(p)) { current = { id: ids[i], model: check.model }; break; }
1520
+ }
1521
+ var inUse = current ? ((t.labels[current.id] || current.id) + (current.model ? " · " + current.model : "")) : (LOCAL_EMPTY[s.lang] || LOCAL_EMPTY.en);
1487
1522
  return createElement("div", null,
1488
1523
  // The clear button rides the "当前使用" row, pushed to that row's end:
1489
1524
  // one row per idea (what is in use, then the counters), no stray rows.
@@ -1591,6 +1626,24 @@ window.__ModuleLoader__.load({
1591
1626
  // they belong to the instance that owns the in-flight requests.
1592
1627
  // ------------------------------------------------------------------
1593
1628
  var draft = {};
1629
+ var LOCAL_EMPTY = { "zh-CN": "尚未验证可用提供方", en: "No provider verified yet", ja: "提供元は未確認です", ko: "검증된 제공자가 없습니다", es: "Sin proveedor verificado", fr: "Aucun fournisseur vérifié", de: "Noch kein Anbieter geprüft", ru: "Провайдер пока не проверен" };
1630
+ var LOCAL_PHASES = ["checking", "needs_install", "downloading_ollama", "verifying", "installing", "starting", "needs_model", "downloading_model", "loading_model", "ready", "error", "setup", "retry", "manual"];
1631
+ var LOCAL_LABELS = {
1632
+ "zh-CN": ["检查已有 Ollama", "未找到 Ollama", "下载 Ollama", "验证安装包", "安装 Ollama", "启动 Ollama", "所选模型尚未下载", "下载模型", "加载模型", "本地模型已就绪", "准备失败", "一键安装并准备(含模型下载)", "重试准备", "打开官方安装指南"],
1633
+ en: ["Checking existing Ollama", "Ollama not found", "Downloading Ollama", "Verifying installer", "Installing Ollama", "Starting Ollama", "Selected model is not downloaded", "Downloading model", "Loading model", "Local model is ready", "Preparation failed", "Install and prepare (includes model download)", "Retry preparation", "Official installation guide"],
1634
+ ja: ["既存の Ollama を確認", "Ollama が見つかりません", "Ollama をダウンロード", "インストーラーを検証", "Ollama をインストール", "Ollama を起動", "モデルは未ダウンロード", "モデルをダウンロード", "モデルをロード", "ローカルモデル準備完了", "準備に失敗", "インストールして準備(モデルを含む)", "準備を再試行", "公式インストールガイド"],
1635
+ ko: ["기존 Ollama 확인", "Ollama를 찾을 수 없음", "Ollama 다운로드", "설치 파일 검증", "Ollama 설치", "Ollama 시작", "모델이 다운로드되지 않음", "모델 다운로드", "모델 로드", "로컬 모델 준비 완료", "준비 실패", "설치 및 준비 (모델 다운로드 포함)", "준비 다시 시도", "공식 설치 안내"],
1636
+ es: ["Comprobando Ollama", "Ollama no encontrado", "Descargando Ollama", "Verificando instalador", "Instalando Ollama", "Iniciando Ollama", "Modelo sin descargar", "Descargando modelo", "Cargando modelo", "Modelo local listo", "Preparación fallida", "Instalar y preparar (incluye el modelo)", "Reintentar preparación", "Guía oficial de instalación"],
1637
+ fr: ["Vérification de Ollama", "Ollama introuvable", "Téléchargement de Ollama", "Vérification du programme", "Installation de Ollama", "Démarrage de Ollama", "Modèle non téléchargé", "Téléchargement du modèle", "Chargement du modèle", "Modèle local prêt", "Échec de préparation", "Installer et préparer (modèle inclus)", "Réessayer", "Guide officiel"],
1638
+ de: ["Ollama prüfen", "Ollama nicht gefunden", "Ollama herunterladen", "Installationsdatei prüfen", "Ollama installieren", "Ollama starten", "Modell nicht heruntergeladen", "Modell herunterladen", "Modell laden", "Lokales Modell bereit", "Vorbereitung fehlgeschlagen", "Installieren und vorbereiten (mit Modell)", "Erneut vorbereiten", "Offizielle Installationsanleitung"],
1639
+ ru: ["Проверка Ollama", "Ollama не найден", "Загрузка Ollama", "Проверка установщика", "Установка Ollama", "Запуск Ollama", "Модель не скачана", "Скачивание модели", "Загрузка модели", "Локальная модель готова", "Ошибка подготовки", "Установить и подготовить (с моделью)", "Повторить подготовку", "Официальная инструкция"],
1640
+ };
1641
+ function localLabel(phase, lang) { var index = LOCAL_PHASES.indexOf(phase); return (LOCAL_LABELS[lang] || LOCAL_LABELS.en)[index < 0 ? 0 : index]; }
1642
+ function localOrigin(p) {
1643
+ try { var u = new URL(p && p.baseURL); return p.type === "openai" && u.protocol === "http:" &&
1644
+ ["localhost", "127.0.0.1", "[::1]"].indexOf(u.hostname) >= 0 && u.port === "11434" && /^\/v1\/?$/.test(u.pathname) && !u.username && !u.password && !u.search && !u.hash ? u.origin : null; }
1645
+ catch (e) { return null; }
1646
+ }
1594
1647
  function useDraft(key, initial) {
1595
1648
  var pair = useState(key in draft ? draft[key] : initial);
1596
1649
  if (!(key in draft)) draft[key] = pair[0];
@@ -1614,6 +1667,10 @@ window.__ModuleLoader__.load({
1614
1667
  var cfg = pair[0], setCfg = pair[1];
1615
1668
  var msgPair = useDraft("msg", "");
1616
1669
  var msg = msgPair[0], setMsg = msgPair[1];
1670
+ var localPair = useDraft("localJob", null);
1671
+ var localJob = localPair[0];
1672
+ var localJobRef = useState(function () { return { current: localJob && localJob.id }; })[0];
1673
+ function setLocalJob(job) { localJobRef.current = job && job.id; localPair[1](job); }
1617
1674
  var modelsPair = useDraft("models", null);
1618
1675
  var models = modelsPair[0], setModels = modelsPair[1];
1619
1676
  var modelMenuPair = useDraft("modelMenu", null); var modelMenu = modelMenuPair[0], setModelMenu = modelMenuPair[1];
@@ -1662,6 +1719,52 @@ window.__ModuleLoader__.load({
1662
1719
  // not overwrite a newer optimistic state (Important-3/4).
1663
1720
  var seqRefPair = useState(function () { return { current: 0 }; });
1664
1721
  var seqRef = seqRefPair[0];
1722
+ var localSeq = useState(function () { return { current: 0 }; })[0];
1723
+ function testProvider(id, sequence) {
1724
+ var testSequence = (providerTestSequence[id] || 0) + 1;
1725
+ providerTestSequence[id] = testSequence;
1726
+ var testedProvider = cfgRef.current.providers[id] || {};
1727
+ var testedModel = testedProvider.model;
1728
+ var testedSignature = providerFingerprint(testedProvider);
1729
+ setMsg(t.testing);
1730
+ return fetch("/_xlate/translate", { method: "POST", headers: { "Content-Type": "application/json" },
1731
+ body: JSON.stringify({ text: "Hello world, connection test. [" + Date.now().toString(36) + "]", target: s.lang, provider: id }) })
1732
+ .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
1733
+ .then(function (j) { if (sequence !== undefined && sequence !== localSeq.current) return;
1734
+ if (providerTestSequence[id] !== testSequence) return;
1735
+ var currentProvider = cfgRef.current.providers[id] || {};
1736
+ if (providerFingerprint(currentProvider) !== testedSignature || (j.model && testedModel && j.model !== testedModel)) return;
1737
+ recordCheck(id, !!(j && j.ok), j.model || testedModel, j.providerSignature || testedSignature);
1738
+ if (j && j.ok) stats.lastError = null;
1739
+ setMsg(j && j.ok ? t.connOk + " ✓ (" + providerLabel(id, (cfgRef.current.providers || {})[id] || {}, t) + (j.model ? " · " + j.model : "") + ")" : (t.connFail + ": " + (j.error || "unknown"))); })
1740
+ .catch(function (e) { if (sequence !== undefined && sequence !== localSeq.current) return;
1741
+ if (providerTestSequence[id] !== testSequence || providerFingerprint(cfgRef.current.providers[id] || {}) !== testedSignature) return;
1742
+ recordCheck(id, false, testedModel, testedSignature);
1743
+ setMsg(t.connFail + ": " + (e && e.message ? e.message : e)); });
1744
+ }
1745
+ function prepareLocal(allowSetup, testAfter, downloadOnly) {
1746
+ var sequence = ++localSeq.current;
1747
+ return fetch("/_xlate/local/prepare", { method: "POST", headers: { "Content-Type": "application/json" },
1748
+ body: JSON.stringify({ allowInstall: allowSetup === true, allowDownload: allowSetup === true || downloadOnly === true }) })
1749
+ .then(function (r) { return r.json().then(function (j) { if (!r.ok || !j.ok) throw new Error(j.error || "HTTP " + r.status); return j; }); })
1750
+ .then(function (j) { if (sequence !== localSeq.current) return; setLocalJob(j.job); if (testAfter) return testProvider("openai", sequence); })
1751
+ .catch(function (e) { if (sequence === localSeq.current) setMsg(localLabel("error", s.lang) + ": " + (e.message || e)); });
1752
+ }
1753
+ var localProvider = cfg && cfg.providers && cfg.providers.openai;
1754
+ var firstEnabled = cfg && (cfg.chain || []).filter(function (id) { var p = cfg.providers[id]; return p && (p.enabled || p.source === "dsh"); })[0];
1755
+ var localKey = firstEnabled === "openai" && localOrigin(localProvider) && localProvider.model ? localProvider.baseURL + "|" + localProvider.model : "";
1756
+ useEffect(function () { if (localKey) prepareLocal(false, false); }, [localKey]);
1757
+ useEffect(function () {
1758
+ if (!localJob || !localJob.running) return;
1759
+ var alive = true;
1760
+ var timer = setInterval(function () {
1761
+ fetch("/_xlate/local/status?id=" + encodeURIComponent(localJob.id)).then(function (r) { return r.json(); }).then(function (j) {
1762
+ if (alive && j.job && localJobRef.current === localJob.id) setLocalJob(j.job);
1763
+ }).catch(function () {});
1764
+ }, 750);
1765
+ return function () { alive = false; clearInterval(timer); };
1766
+ }, [localJob ? localJob.id + "|" + localJob.running : ""]);
1767
+ useEffect(function () { return function () { localSeq.current++; }; }, []);
1665
1768
  // Add/edit inline form state. Lives here (not in a nested component,
1666
1769
  // and before the cfg guard) so re-renders — including the cfg=null →
1667
1770
  // cfg=value transition — never reset or reorder what the user types;
@@ -1779,20 +1882,9 @@ window.__ModuleLoader__.load({
1779
1882
  var startPull = function (m) {
1780
1883
  if (!m || !m.trim()) return;
1781
1884
  var name = m.trim();
1782
- setPull({ model: name, status: "starting", percent: 0, done: false, error: null });
1783
- // The POST only starts the job (the 1 s poll reports progress), so a
1784
- // rejected start must surface here — otherwise the panel sits in
1785
- // "downloading" forever and the user is never told why.
1786
- fetch("/_xlate/model/pull", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: name }) })
1787
- .then(function (r) {
1788
- return r.json().catch(function () { return {}; }).then(function (j) {
1789
- if (!r.ok || (j && j.ok === false)) throw new Error((j && j.error) || ("HTTP " + r.status));
1790
- return j;
1791
- });
1792
- })
1793
- .catch(function (e) {
1794
- setPull({ model: name, status: "error", percent: 0, done: false, error: e && e.message ? e.message : "network" });
1795
- });
1885
+ // Download intent prepares an existing service first. Software install
1886
+ // still needs the separately labelled setup button if Ollama is absent.
1887
+ pickModel(name).then(function (saved) { if (saved) prepareLocal(false, false, true); });
1796
1888
  };
1797
1889
  var pickModel = function (m) {
1798
1890
  if (!m) return;
@@ -1800,9 +1892,9 @@ window.__ModuleLoader__.load({
1800
1892
  var openai = (cur.providers && cur.providers.openai) || {};
1801
1893
  applyLocal({ providers: { openai: { model: m } } });
1802
1894
  setModelMenu(null);
1803
- fetch("/_xlate/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providers: { openai: { model: m } } }) })
1895
+ return fetch("/_xlate/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providers: { openai: { model: m } } }) })
1804
1896
  .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
1805
- .then(function (c) { cfgRef.current = c; setCfg(c); })
1897
+ .then(function (c) { cfgRef.current = c; setCfg(c); return c; })
1806
1898
  .catch(function (e) {
1807
1899
  // Do not leave the chip showing a model the host never stored.
1808
1900
  setMsg((t.providerSaveError || t.connFail) + ": " + (e && e.message ? e.message : e));
@@ -1870,12 +1962,8 @@ window.__ModuleLoader__.load({
1870
1962
  saveCfg({ chain: chain });
1871
1963
  },
1872
1964
  test: function (id) {
1873
- setMsg(t.testing);
1874
- fetch("/_xlate/translate", { method: "POST", headers: { "Content-Type": "application/json" },
1875
- body: JSON.stringify({ text: "Hello world, connection test. [" + Date.now().toString(36) + "]", target: s.lang, provider: id }) })
1876
- .then(function (r) { return r.json(); })
1877
- .then(function (j) { setMsg(j && j.ok ? t.connOk + " ✓ (" + providerLabel(id, (cfgRef.current.providers || {})[id] || {}, t) + (j.model ? " · " + j.model : "") + ")" : (t.connFail + ": " + (j.error || "unknown"))); })
1878
- .catch(function (e) { setMsg(t.connFail + ": " + (e && e.message ? e.message : e)); });
1965
+ if (id === "openai" && localOrigin(cfgRef.current.providers.openai)) prepareLocal(false, true);
1966
+ else testProvider(id);
1879
1967
  },
1880
1968
  edit: function (id) {
1881
1969
  var p = cfgRef.current.providers && cfgRef.current.providers[id];
@@ -2229,6 +2317,17 @@ window.__ModuleLoader__.load({
2229
2317
  : createElement("button", { className: "xl-btn", type: "button", onClick: rescanDsh, style: { marginTop: "4px", marginLeft: "6px" } },
2230
2318
  "↻ " + t.dshImport),
2231
2319
  unusedArea,
2320
+ localJob && localProvider && localJob.model === localProvider.model && localJob.origin === localOrigin(localProvider)
2321
+ ? createElement("div", { className: "xl-local-status", role: "status", style: { marginTop: "8px" } },
2322
+ createElement("div", { className: "xl-note" }, localLabel(localJob.phase, s.lang) + " · " + localJob.model +
2323
+ (localJob.percent !== null && localJob.phase.indexOf("downloading") === 0 ? " · " + localJob.percent + "%" : "") +
2324
+ (localJob.completed ? " · " + (localJob.completed / 1048576).toFixed(1) + " MB" + (localJob.total ? " / " + (localJob.total / 1048576).toFixed(1) + " MB" : "") : "")),
2325
+ localJob.running ? createElement("div", { className: "xl-progress" }, createElement("div", { className: "xl-progress-bar", style: { width: (localJob.percent === null ? 15 : localJob.percent) + "%" } })) : null,
2326
+ localJob.error ? createElement("div", { className: "xl-warn" }, localJob.error) : null,
2327
+ !localJob.running && (localJob.phase === "needs_model" || localJob.phase === "error" || (localJob.phase === "needs_install" && localJob.canInstall))
2328
+ ? createElement("button", { className: "xl-btn", onClick: function () { prepareLocal(true, true); } }, localLabel(localJob.phase === "error" ? "retry" : "setup", s.lang)) : null,
2329
+ localJob.phase === "needs_install" && !localJob.canInstall ? createElement("a", { href: "https://ollama.com/download", target: "_blank", rel: "noreferrer" }, localLabel("manual", s.lang)) : null)
2330
+ : null,
2232
2331
  msg ? createElement("div", { className: "xl-note", style: { marginTop: "6px" } }, msg) : null);
2233
2332
  }
2234
2333
 
package/lib/index.js CHANGED
@@ -1,16 +1,17 @@
1
1
  // dsh-think-translate host half: same-origin translation proxy with a pluggable
2
2
  // provider chain. The browser calls POST /_xlate/translate (no CORS involved);
3
3
  // this side owns provider adapters, API keys (local config file), caching,
4
- // and the system-proxy-aware curl transport.
4
+ // and the system-proxy-aware native transport.
5
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, copyFile } from 'node:fs/promises'
10
- import { dirname } from 'node:path'
6
+ // Providers: google (gtx), bing (ttranslatev3), openai-compatible (any
7
+ // /chat/completions endpoint — usually the local Ollama).
8
+ import { readFile, writeFile, mkdir, copyFile, access, unlink, stat } from 'node:fs/promises'
9
+ import { dirname, join } from 'node:path'
10
+ import { tmpdir } from 'node:os'
11
+ import { randomUUID } from 'node:crypto'
12
+ import { createLocalPreparation, ollamaOrigin, localFirst, validOllamaSignature } from './ollama.js'
11
13
  import { fileURLToPath } from 'node:url'
12
- import http from 'node:http'
13
- import https from 'node:https'
14
+ import { requestText, isLoopback } from './transport.js'
14
15
 
15
16
  export const name = 'dsh-think-translate'
16
17
 
@@ -85,6 +86,133 @@ const VALID_TYPES = ['google', 'bing', 'openai', 'anthropic']
85
86
  let subprocessRef = null
86
87
  let timerRef = null
87
88
 
89
+ async function localJson(url, options = {}, timeoutMs = 10000) {
90
+ const response = await fetch(url, { ...options, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) })
91
+ const body = await response.json()
92
+ if (!response.ok || body.error) throw new Error(body.error || 'Ollama HTTP ' + response.status)
93
+ return body
94
+ }
95
+
96
+ async function findOllamaExecutable() {
97
+ if (!subprocessRef) throw new Error('subprocess unavailable')
98
+ try { return await subprocessRef.resolveExecutable('ollama') } catch { /* Try installed-app locations when PATH is stale. */ }
99
+ const candidates = process.platform === 'win32'
100
+ ? [join(process.env.LOCALAPPDATA || '', 'Programs', 'Ollama', 'ollama.exe')]
101
+ : ['/usr/local/bin/ollama', '/usr/bin/ollama', '/Applications/Ollama.app/Contents/Resources/ollama']
102
+ if (process.platform === 'win32') for (const hive of ['HKCU', 'HKLM']) {
103
+ try {
104
+ const key = hive + ':\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{44E83376-CE68-45EB-8FC1-393500EB558C}_is1'
105
+ const result = await runProgram('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
106
+ '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);$p=Get-ItemProperty -LiteralPath ' + "'" + key + "'" + ' -ErrorAction Stop;[Console]::Write($p.InstallLocation)'], 8000)
107
+ if (result.trim()) candidates.push(join(result.trim(), 'ollama.exe'))
108
+ } catch { /* This hive may not contain an Ollama installation. */ }
109
+ }
110
+ for (const candidate of candidates) {
111
+ try { await access(candidate); return candidate } catch { /* Probe the next known installation location. */ }
112
+ }
113
+ return null
114
+ }
115
+
116
+ async function startOllamaServer(executable, origin) {
117
+ const child = subprocessRef.spawn({
118
+ argv: [executable, 'serve'], cwd: await workdir(), graceMs: 2000,
119
+ stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
120
+ env: { OLLAMA_HOST: new URL(origin).host },
121
+ })
122
+ // Drain the long-lived pipe; the subprocess provider owns shutdown and joins.
123
+ if (child.stderr) (async () => { for await (const chunk of child.stderr) { void chunk } })().catch(() => {})
124
+ child.done.catch(() => {})
125
+ }
126
+
127
+ async function streamModelPull(origin, model, progress) {
128
+ const response = await fetch(origin + '/api/pull', {
129
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, redirect: 'error',
130
+ body: JSON.stringify({ model, stream: true }), signal: AbortSignal.timeout(60 * 60 * 1000),
131
+ })
132
+ if (!response.ok) throw new Error('Ollama download HTTP ' + response.status)
133
+ const decoder = new TextDecoder()
134
+ const layers = new Map()
135
+ let buffer = '', success = false
136
+ const consume = line => {
137
+ if (!line.trim()) return
138
+ const event = JSON.parse(line)
139
+ if (event.error) throw new Error(event.error)
140
+ if (event.digest && typeof event.total === 'number') layers.set(event.digest, {
141
+ total: event.total, completed: typeof event.completed === 'number' ? event.completed : 0,
142
+ })
143
+ let total = 0, completed = 0
144
+ for (const layer of layers.values()) { total += layer.total; completed += layer.completed }
145
+ progress({ total, completed, percent: total ? Math.min(100, Math.round(completed / total * 100)) : null })
146
+ if (event.status === 'success') success = true
147
+ }
148
+ for await (const bytes of response.body) {
149
+ buffer += decoder.decode(bytes, { stream: true })
150
+ let index
151
+ while ((index = buffer.indexOf('\n')) >= 0) { consume(buffer.slice(0, index)); buffer = buffer.slice(index + 1) }
152
+ }
153
+ consume(buffer + decoder.decode())
154
+ if (!success) throw new Error('Model download ended before Ollama reported success')
155
+ }
156
+
157
+ async function installOllama(progress) {
158
+ const file = join(tmpdir(), 'dsh-ollama-' + randomUUID() + '.exe')
159
+ try {
160
+ progress({ phase: 'downloading_ollama', percent: null, completed: 0, total: 0 })
161
+ let totalBytes = 0
162
+ try {
163
+ const headers = await runProgram('curl', await curlArgs(['--fail', '--head', '--location', 'https://ollama.com/download/OllamaSetup.exe']), 30000)
164
+ const lengths = [...headers.matchAll(/content-length:\s*(\d+)/gi)]
165
+ if (lengths.length) totalBytes = Number(lengths[lengths.length - 1][1])
166
+ } catch { /* A missing HEAD size does not prevent a signed download. */ }
167
+ const args = await curlArgs(['--fail', '--location', '--proto', '=https', '--proto-redir', '=https',
168
+ '--progress-bar', '--retry', '2', '--max-time', '1800', '--output', file, 'https://ollama.com/download/OllamaSetup.exe'])
169
+ // Replace curlArgs' silent/25-second prefix with the download's own limit.
170
+ const filtered = args.slice(3)
171
+ const executable = await subprocessRef.resolveExecutable('curl')
172
+ const child = subprocessRef.spawn({ argv: [executable, ...filtered], cwd: await workdir(), graceMs: 2000,
173
+ signal: AbortSignal.timeout(30 * 60 * 1000), stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' } })
174
+ let tail = ''
175
+ if (child.stderr) for await (const bytes of child.stderr) {
176
+ tail = (tail + new TextDecoder().decode(bytes)).slice(-2000)
177
+ const matches = [...tail.matchAll(/(\d+(?:\.\d+)?)%/g)]
178
+ const downloaded = await stat(file).catch(() => null)
179
+ progress({ completed: downloaded ? downloaded.size : 0, total: totalBytes,
180
+ percent: matches.length ? Math.min(100, Number(matches[matches.length - 1][1])) : null })
181
+ }
182
+ const outcome = await child.done
183
+ if (outcome.exitCode !== 0) throw new Error('Ollama installer download failed: ' + tail.trim().slice(-300))
184
+ progress({ phase: 'verifying', percent: null })
185
+ const quoted = "'" + file.replace(/'/g, "''") + "'"
186
+ const signature = await runProgram('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
187
+ '$s=Get-AuthenticodeSignature -LiteralPath ' + quoted + '; @{status=[string]$s.Status;subject=$s.SignerCertificate.Subject}|ConvertTo-Json -Compress'], 30000)
188
+ if (!validOllamaSignature(JSON.parse(signature))) throw new Error('Ollama installer signature verification failed')
189
+ progress({ phase: 'installing', percent: null })
190
+ const appState = join(process.env.LOCALAPPDATA, 'Ollama')
191
+ await mkdir(appState, { recursive: true })
192
+ await writeFile(join(appState, 'upgraded'), '', 'utf8') // Official installer convention: launch the app hidden.
193
+ // The official per-user installer supports these unattended flags.
194
+ const controller = new AbortController()
195
+ const deadline = setTimeout(() => controller.abort(), 10 * 60 * 1000)
196
+ try {
197
+ // Ignore inherited pipes: a background app launched by the installer can
198
+ // retain them even after the installer itself exits successfully.
199
+ const child = subprocessRef.spawn({ argv: [file, '/VERYSILENT', '/NORESTART', '/SUPPRESSMSGBOXES'],
200
+ cwd: await workdir(), graceMs: 2000, signal: controller.signal,
201
+ stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' } })
202
+ const outcome = await child.done
203
+ if (outcome.exitCode !== 0) throw new Error('Ollama installation failed with exit code ' + outcome.exitCode)
204
+ } finally { clearTimeout(deadline) }
205
+ } finally {
206
+ await unlink(file).catch(() => {}) // Only this operation's unique installer download.
207
+ }
208
+ }
209
+
210
+ const localPreparation = createLocalPreparation({
211
+ platform: process.platform, json: localJson, findExecutable: findOllamaExecutable,
212
+ startServer: startOllamaServer, install: installOllama, pull: streamModelPull,
213
+ wait: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
214
+ })
215
+
88
216
  async function workdir() {
89
217
  return process.cwd() || '.'
90
218
  }
@@ -197,84 +325,49 @@ async function getProxy() {
197
325
  return value
198
326
  }
199
327
 
328
+ // curl remains necessary for the signed installer download and legacy SOCKS
329
+ // proxies. Ordinary translation requests use Node directly without subprocesses.
200
330
  async function curlArgs(extra) {
201
331
  const args = ['-sS', '--max-time', '25']
202
- // Loopback targets (local LLM endpoints) must bypass the system proxy.
203
- const last = extra.length ? String(extra[extra.length - 1]) : ''
204
- const mh = last.match(/^https?:\/\/([^\/?#]+)/i)
205
- const host = mh ? mh[1].replace(/^\[/, '').split(':')[0].toLowerCase() : ''
206
- const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]' || host === '::ffff:127.0.0.1'
207
- if (!isLocal) {
208
- const px = await getProxy()
209
- if (px) args.push('-x', px)
332
+ const url = extra.length ? String(extra[extra.length - 1]) : ''
333
+ if (!isLoopback(url)) {
334
+ const proxy = await getProxy()
335
+ if (proxy) args.push('-x', proxy)
210
336
  }
211
- for (const a of extra) args.push(a)
212
- return args
337
+ return args.concat(extra)
213
338
  }
214
339
 
215
- async function curlGet(url) {
216
- return runProgram('curl', await curlArgs(['-L', url]), 30000)
340
+ async function httpFetch(url, options = {}, timeoutMs = 25000) {
341
+ const proxy = isLoopback(url) ? null : await getProxy()
342
+ if (proxy && /^socks/i.test(proxy)) {
343
+ const extra = ['--fail', '-L', '--max-time', String(Math.ceil(timeoutMs / 1000)), '-X', options.method || 'GET']
344
+ for (const [key, value] of Object.entries(options.headers || {})) extra.push('-H', key + ': ' + value)
345
+ if (options.body !== undefined) extra.push('--data-binary', options.body)
346
+ extra.push(url)
347
+ return runProgram('curl', await curlArgs(extra), timeoutMs + 5000)
348
+ }
349
+ // A failed proxy request is a provider failure. Do not silently bypass the
350
+ // configured proxy or send a second copy of a potentially billable POST.
351
+ return requestText(url, { ...options, proxy, timeoutMs })
217
352
  }
218
353
 
219
- async function curlPostForm(url, form) {
220
- const extra = ['-X', 'POST', '-L', '-H', 'Content-Type: application/x-www-form-urlencoded', '--data-binary', form, url]
221
- return runProgram('curl', await curlArgs(extra), 30000)
354
+ async function curlGet(url, timeoutMs = 25000) {
355
+ return httpFetch(url, { method: 'GET' }, timeoutMs)
222
356
  }
223
357
 
224
- async function curlPostJson(url, headerPairs, body) {
225
- const extra = ['-X', 'POST']
226
- for (const h of headerPairs) extra.push('-H', h)
227
- extra.push('--data-binary', body, url)
228
- return runProgram('curl', await curlArgs(extra), 40000)
358
+ async function curlPostForm(url, form, timeoutMs = 25000) {
359
+ return httpFetch(url, {
360
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form,
361
+ }, timeoutMs)
229
362
  }
230
363
 
231
- // HTTPS GET through an HTTP proxy via a CONNECT tunnel, using Node's own TLS
232
- // stack. curl is blocked by google gtx's anti-bot (TLS-fingerprint based:
233
- // curl gets a "Sorry..." HTML page while .NET/Node/real browsers pass), so the
234
- // google provider must not go through curl. SOCKS proxies are not supported
235
- // here — callers keep the curl path for those.
236
- function httpsViaProxy(urlStr, proxyHost, proxyPort, timeoutMs) {
237
- return new Promise((resolve, reject) => {
238
- const url = new URL(urlStr)
239
- const connectReq = http.request({
240
- host: proxyHost,
241
- port: proxyPort,
242
- method: 'CONNECT',
243
- path: url.host + ':443',
244
- headers: { Host: url.host + ':443', 'Proxy-Connection': 'Keep-Alive' },
245
- timeout: timeoutMs,
246
- })
247
- connectReq.on('connect', (res, socket) => {
248
- if (res.statusCode !== 200) {
249
- socket.destroy()
250
- reject(new Error('proxy CONNECT ' + res.statusCode))
251
- return
252
- }
253
- const req2 = https.request({
254
- host: url.host,
255
- path: url.pathname + url.search,
256
- method: 'GET',
257
- headers: {
258
- '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',
259
- 'Accept': 'application/json,text/plain,*/*',
260
- 'Accept-Language': 'zh-CN,zh;q=0.9',
261
- },
262
- socket,
263
- servername: url.host,
264
- timeout: timeoutMs,
265
- }, (res2) => {
266
- const chunks = []
267
- res2.on('data', (c) => chunks.push(c))
268
- res2.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
269
- })
270
- req2.on('error', reject)
271
- req2.on('timeout', () => req2.destroy(new Error('https timeout')))
272
- req2.end()
273
- })
274
- connectReq.on('error', reject)
275
- connectReq.on('timeout', () => connectReq.destroy(new Error('proxy connect timeout')))
276
- connectReq.end()
277
- })
364
+ async function curlPostJson(url, headerPairs, body, timeoutMs = 25000) {
365
+ const headers = {}
366
+ for (const header of headerPairs) {
367
+ const colon = header.indexOf(':')
368
+ if (colon > 0) headers[header.slice(0, colon).trim()] = header.slice(colon + 1).trim()
369
+ }
370
+ return httpFetch(url, { method: 'POST', headers, body }, timeoutMs)
278
371
  }
279
372
 
280
373
  // ---------------------------------------------------------------- providers
@@ -282,38 +375,22 @@ function httpsViaProxy(urlStr, proxyHost, proxyPort, timeoutMs) {
282
375
  let bingCache = null
283
376
 
284
377
  async function viaGoogle(text, to) {
285
- // translate.googleapis.com gets TLS-Reset by Clash/GFW in CN networks even
286
- // through the proxy; translate.google.com serves the same gtx JSON (with
287
- // client=dict-chrome-ex, the Chrome dictionary extension identifier, which
288
- // bypasses the 429 anti-bot that now blocks client=gtx) and is reachable
289
- // there. The reverse may hold on other networks, so try both hosts.
290
378
  const qs = 'translate_a/single?client=dict-chrome-ex&sl=auto&tl=' + encodeURIComponent(to) + '&dt=t&q=' + encodeURIComponent(text)
291
379
  const hosts = ['https://translate.google.com/', 'https://translate.googleapis.com/']
292
- const px = await getProxy()
293
380
  let lastErr = null
294
381
  for (const host of hosts) {
295
382
  const url = host + qs
296
383
  let raw = null
297
- if (px && /^http/i.test(px)) {
298
- // curl is fingerprint-blocked by google ("Sorry..." page); Node's TLS
299
- // stack passes. Only plain HTTP proxies support CONNECT here; SOCKS falls
300
- // back to curl.
301
- try {
302
- const p = new URL(px)
303
- raw = await httpsViaProxy(url, p.hostname, Number(p.port) || 80, 30000)
304
- // httpsViaProxy resolves the body even for anti-bot HTML (429
305
- // "Sorry..."); treat non-JSON as a failure so we fall through to
306
- // curl, which passes through the system proxy (2026-09-01).
307
- if (raw !== null) {
308
- try { JSON.parse(raw) } catch (e) { raw = null }
309
- }
310
- } catch (e) {
311
- raw = null
384
+ try {
385
+ raw = await curlGet(url)
386
+ if (raw) {
387
+ try { JSON.parse(raw) } catch { raw = null }
312
388
  }
389
+ } catch (e) {
390
+ lastErr = e
391
+ continue
313
392
  }
314
- if (raw === null) {
315
- try { raw = await curlGet(url) } catch (e) { lastErr = e; continue }
316
- }
393
+ if (!raw) continue
317
394
  try {
318
395
  const arr = JSON.parse(raw)
319
396
  const segs = arr && arr[0]
@@ -949,6 +1026,12 @@ function fnv(s) {
949
1026
  function cacheGet(k) {
950
1027
  if (!cache.has(k)) return undefined
951
1028
  const v = cache.get(k)
1029
+ if (v && v.ok === false) {
1030
+ if (Date.now() >= v.retryAt) {
1031
+ cache.delete(k)
1032
+ return undefined
1033
+ }
1034
+ }
952
1035
  cache.delete(k)
953
1036
  cache.set(k, v)
954
1037
  return v
@@ -1029,6 +1112,7 @@ function chunkText(text) {
1029
1112
  // Run one ordered provider-id list; returns { ok } or { ok: false, error }.
1030
1113
  async function runChainFor(ids, text, target, cfg) {
1031
1114
  let lastErr = null
1115
+ const attempts = []
1032
1116
  for (const id of ids) {
1033
1117
  const p = cfg.providers && cfg.providers[id]
1034
1118
  // DSH-sourced providers are activated by chain membership (they cannot be
@@ -1039,6 +1123,7 @@ async function runChainFor(ids, text, target, cfg) {
1039
1123
  const adapter = ADAPTERS[p.type || id]
1040
1124
  if (!adapter) continue
1041
1125
  try {
1126
+ if (ollamaOrigin(id, p)) await localPreparation.ensure(p)
1042
1127
  const chunks = chunkText(text)
1043
1128
  const outs = []
1044
1129
  for (const c of chunks) outs.push(await adapter(c, target, p))
@@ -1046,13 +1131,20 @@ async function runChainFor(ids, text, target, cfg) {
1046
1131
  state.lastProvider = id
1047
1132
  state.lastError = null
1048
1133
  // Carry the concrete model name so the UI can show e.g. "openai: qwen2.5:14b".
1049
- return { ok: true, text: outs.join('\n\n'), provider: id, ...(p.model ? { model: p.model } : {}) }
1134
+ attempts.push({ provider: id, signature: providerFingerprint(p), ok: true, model: p.model || null })
1135
+ return { ok: true, text: outs.join('\n\n'), provider: id, providerSignature: providerFingerprint(p), attempts, ...(p.model ? { model: p.model } : {}) }
1050
1136
  } catch (e) {
1051
1137
  lastErr = e
1138
+ attempts.push({ provider: id, signature: providerFingerprint(p), ok: false, model: p.model || null })
1052
1139
  if (id === 'bing') bingCache = null
1053
1140
  }
1054
1141
  }
1055
- return { ok: false, error: lastErr instanceof Error ? lastErr.message : (lastErr || 'no enabled provider in chain') }
1142
+ return { ok: false, attempts, error: lastErr instanceof Error ? lastErr.message : (lastErr || 'no enabled provider in chain') }
1143
+ }
1144
+
1145
+ function providerFingerprint(p) {
1146
+ return fnv(JSON.stringify({ type: p.type, baseURL: p.baseURL, model: p.model, apiKeyEnv: p.apiKeyEnv,
1147
+ apiKey: p.source === 'dsh' || p.apiKeyEnv ? undefined : p.apiKey }))
1056
1148
  }
1057
1149
 
1058
1150
  async function translateViaChain(text, target, cfg) {
@@ -1061,11 +1153,11 @@ async function translateViaChain(text, target, cfg) {
1061
1153
  let fb = null
1062
1154
  if (cfg.fallback && cfg.fallback.enabled && Array.isArray(cfg.fallback.chain)) {
1063
1155
  fb = await runChainFor(cfg.fallback.chain, text, target, cfg)
1064
- if (fb.ok) return fb
1156
+ if (fb.ok) return { ...fb, attempts: [...primary.attempts, ...fb.attempts] }
1065
1157
  }
1066
1158
  state.fail++
1067
1159
  state.lastError = primary.error || (fb && fb.error) || 'translate unavailable'
1068
- return { ok: false, text: text, error: state.lastError }
1160
+ return { ok: false, text: text, attempts: [...primary.attempts, ...(fb ? fb.attempts : [])], error: state.lastError }
1069
1161
  }
1070
1162
 
1071
1163
  async function handleTranslate(body) {
@@ -1086,12 +1178,13 @@ async function handleTranslate(body) {
1086
1178
  const p = cfg.providers && cfg.providers[forced]
1087
1179
  if (!p) return { ok: false, text, error: 'unknown provider: ' + forced }
1088
1180
  // DSH providers are activated by chain membership, not the enabled flag.
1089
- if (!p.enabled && p.source !== 'dsh') return { ok: false, text, error: 'provider disabled: ' + forced }
1181
+ if (!p.enabled && p.source !== 'dsh' && !ollamaOrigin(forced, p)) return { ok: false, text, error: 'provider disabled: ' + forced }
1090
1182
  const adapter = ADAPTERS[p.type || forced]
1091
1183
  if (!adapter) return { ok: false, text, error: 'unsupported type: ' + p.type }
1092
1184
  try {
1185
+ if (ollamaOrigin(forced, p)) await localPreparation.ensure(p)
1093
1186
  const out = await adapter(text, target, p)
1094
- return { ok: true, text: out, provider: forced, ...(p.model ? { model: p.model } : {}) }
1187
+ return { ok: true, text: out, provider: forced, providerSignature: providerFingerprint(p), ...(p.model ? { model: p.model } : {}) }
1095
1188
  } catch (e) {
1096
1189
  return { ok: false, text, error: e instanceof Error ? e.message : String(e) }
1097
1190
  }
@@ -1102,7 +1195,8 @@ async function handleTranslate(body) {
1102
1195
  return r
1103
1196
  }
1104
1197
  const r = await translateViaChain(text, target, cfg)
1105
- if (r.ok) cacheSet(key, r)
1198
+ if (!r.ok) r.retryAt = Date.now() + 30000
1199
+ cacheSet(key, r)
1106
1200
  return r
1107
1201
  })()
1108
1202
  inflight.set(key, job)
@@ -1175,8 +1269,7 @@ async function handleModels() {
1175
1269
  if (!res.ok) throw new Error('models HTTP ' + res.status)
1176
1270
  raw = await res.text()
1177
1271
  } else {
1178
- const extra = ['-H', 'Authorization: Bearer ' + key, url]
1179
- raw = await runProgram('curl', await curlArgs(extra), 30000)
1272
+ raw = await httpFetch(url, { headers: { 'Authorization': 'Bearer ' + key } }, 10000)
1180
1273
  }
1181
1274
  const j = JSON.parse(raw)
1182
1275
  const list = Array.isArray(j && j.data)
@@ -1362,6 +1455,7 @@ export function apply(ctx) {
1362
1455
  // provider's output (and its name in the status row) until restart.
1363
1456
  cache.clear()
1364
1457
  inflight.clear()
1458
+ if (localFirst(merged)) localPreparation.start(merged.providers.openai)
1365
1459
  sendJson(res, 200, stripResolvedKeys(merged))
1366
1460
  return
1367
1461
  }
@@ -1374,6 +1468,30 @@ export function apply(ctx) {
1374
1468
  return dispose
1375
1469
  }, 'dsh-think-translate: config route')
1376
1470
 
1471
+ ctx.effect(function () {
1472
+ return webServer.register({ kind: 'exact', path: '/_xlate/local/prepare', handler: async function (req, res) {
1473
+ try {
1474
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'POST only' }); return }
1475
+ const origin = req.headers.origin
1476
+ if (origin && new URL(origin).host !== req.headers.host) { sendJson(res, 403, { ok: false, error: 'same-origin only' }); return }
1477
+ const body = await readBody(req)
1478
+ const cfg = await loadConfig()
1479
+ sendJson(res, 202, { ok: true, job: localPreparation.start(cfg.providers.openai, {
1480
+ allowInstall: body.allowInstall === true, allowDownload: body.allowDownload === true,
1481
+ }) })
1482
+ } catch (error) { sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }) }
1483
+ } })
1484
+ }, 'dsh-think-translate: local preparation route')
1485
+ ctx.effect(function () {
1486
+ return webServer.register({ kind: 'exact', path: '/_xlate/local/status', handler: function (req, res) {
1487
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'GET only' }); return }
1488
+ const id = new URL(req.url, 'http://localhost').searchParams.get('id')
1489
+ sendJson(res, 200, { ok: true, job: localPreparation.snapshot(id) })
1490
+ } })
1491
+ }, 'dsh-think-translate: local preparation status')
1492
+ // Restoring a profile with Ollama already first must prepare it too.
1493
+ loadConfig().then(cfg => { if (localFirst(cfg)) localPreparation.start(cfg.providers.openai) }).catch(() => {})
1494
+
1377
1495
  ctx.effect(function () {
1378
1496
  const dispose = webServer.register({
1379
1497
  kind: 'exact',
@@ -1475,4 +1593,5 @@ export {
1475
1593
  structuredCloneSafe, ADAPTERS,
1476
1594
  resolveApiKey, stripResolvedKeys, missingApiKey, clearNullFields, handleVersion,
1477
1595
  harnessHomeCandidates, pickHarnessHome,
1596
+ streamModelPull,
1478
1597
  }
package/lib/ollama.js ADDED
@@ -0,0 +1,132 @@
1
+ // Local Ollama preparation: asynchronous, observable, and reusable by config
2
+ // changes and explicit provider tests. Dependencies are injected for offline tests.
3
+ export function ollamaOrigin(id, provider) {
4
+ if (id !== 'openai' || !provider || provider.type !== 'openai') return null
5
+ try {
6
+ const url = new URL(provider.baseURL)
7
+ if (url.protocol !== 'http:' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) ||
8
+ url.port !== '11434' || !/^\/v1\/?$/.test(url.pathname) || url.username || url.password || url.search || url.hash) return null
9
+ return url.origin
10
+ } catch { return null }
11
+ }
12
+
13
+ export function localFirst(cfg) {
14
+ const id = (cfg.chain || []).find(id => {
15
+ const p = cfg.providers && cfg.providers[id]
16
+ return p && (p.enabled || p.source === 'dsh')
17
+ })
18
+ const provider = cfg.providers && cfg.providers[id]
19
+ return ollamaOrigin(id, provider) !== null && typeof provider.model === 'string' && !!provider.model.trim()
20
+ }
21
+
22
+ export function validOllamaSignature(report) {
23
+ return report && report.status === 'Valid' && typeof report.subject === 'string' &&
24
+ /(^|, )O=Ollama Inc\.(,|$)/.test(report.subject)
25
+ }
26
+
27
+ function modelKey(model) { return model.includes(':') ? model : model + ':latest' }
28
+
29
+ export function createLocalPreparation(runtime) {
30
+ const jobs = new Map()
31
+ const servers = new Map()
32
+ let sequence = 0
33
+ let latest = null
34
+ const snapshot = id => {
35
+ const job = id ? jobs.get(id) : latest
36
+ if (!job) return null
37
+ const { promise, options, ...out } = job
38
+ return { ...out, platform: runtime.platform, canInstall: runtime.platform === 'win32' }
39
+ }
40
+ async function online(origin) {
41
+ try { const version = await runtime.json(origin + '/api/version', {}, 2000); return typeof version.version === 'string' }
42
+ catch { return false }
43
+ }
44
+ async function server(job, options, progress) {
45
+ if (await online(job.origin)) return
46
+ if (servers.has(job.origin)) return servers.get(job.origin)
47
+ const pending = (async () => {
48
+ let executable = await runtime.findExecutable()
49
+ if (!executable) {
50
+ if (!options.allowInstall || runtime.platform !== 'win32') {
51
+ progress({ phase: 'needs_install', running: false })
52
+ return false
53
+ }
54
+ await runtime.install(progress)
55
+ executable = await runtime.findExecutable()
56
+ if (!executable) throw new Error('Ollama installation finished but the executable was not found')
57
+ }
58
+ progress({ phase: 'starting', percent: null })
59
+ await runtime.startServer(executable, job.origin)
60
+ for (let n = 0; n < 30; n++) {
61
+ if (await online(job.origin)) return true
62
+ await runtime.wait(500)
63
+ }
64
+ throw new Error('Ollama did not become reachable after startup')
65
+ })()
66
+ servers.set(job.origin, pending)
67
+ try { return await pending } finally { servers.delete(job.origin) }
68
+ }
69
+ async function run(job, options) {
70
+ const progress = patch => Object.assign(job, patch, { updatedAt: Date.now() })
71
+ try {
72
+ if (await server(job, options, progress) === false) {
73
+ progress({ phase: 'needs_install', running: false })
74
+ return
75
+ }
76
+ const tags = await runtime.json(job.origin + '/api/tags', {}, 10000)
77
+ const installed = (tags.models || []).some(m => typeof m.name === 'string' && modelKey(m.name) === modelKey(job.model))
78
+ if (!installed) {
79
+ if (!options.allowDownload) { progress({ phase: 'needs_model', running: false }); return }
80
+ progress({ phase: 'downloading_model', completed: 0, total: 0, percent: null })
81
+ await runtime.pull(job.origin, job.model, progress)
82
+ }
83
+ const loaded = await runtime.json(job.origin + '/api/ps', {}, 10000)
84
+ if (!(loaded.models || []).some(m => modelKey(m.name || m.model || '') === modelKey(job.model))) {
85
+ progress({ phase: 'loading_model', percent: null, completed: 0, total: 0 })
86
+ await runtime.json(job.origin + '/api/generate', {
87
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify({ model: job.model, prompt: '', stream: false, keep_alive: '10m' }),
89
+ }, 120000)
90
+ const verify = await runtime.json(job.origin + '/api/ps', {}, 10000)
91
+ if (!(verify.models || []).some(m => modelKey(m.name || m.model || '') === modelKey(job.model))) {
92
+ throw new Error('Ollama did not report the selected model as loaded')
93
+ }
94
+ }
95
+ progress({ phase: 'ready', running: false, percent: 100, error: null })
96
+ } catch (error) {
97
+ progress({ phase: 'error', running: false, error: error instanceof Error ? error.message : String(error) })
98
+ }
99
+ }
100
+ function start(provider, options = {}) {
101
+ const origin = ollamaOrigin('openai', provider)
102
+ const model = typeof provider?.model === 'string' ? provider.model.trim() : ''
103
+ if (!origin || !model) throw new Error('A local Ollama endpoint and model are required')
104
+ // No automatic installation or model download: the setup button grants both.
105
+ const allowed = { allowInstall: options.allowInstall === true, allowDownload: options.allowDownload === true }
106
+ const busy = [...jobs.values()].find(j => j.running && j.origin === origin && j.model === model)
107
+ if (busy) {
108
+ busy.options.allowInstall ||= allowed.allowInstall
109
+ busy.options.allowDownload ||= allowed.allowDownload
110
+ return snapshot(busy.id)
111
+ }
112
+ const job = { id: String(++sequence), origin, model, phase: 'checking', running: true,
113
+ percent: null, completed: 0, total: 0, error: null, updatedAt: Date.now() }
114
+ job.options = allowed
115
+ latest = job
116
+ jobs.set(job.id, job)
117
+ job.promise = Promise.resolve().then(() => run(job, allowed))
118
+ if (jobs.size > 30) for (const [id, old] of jobs) {
119
+ if (!old.running && old !== latest) { jobs.delete(id); break }
120
+ }
121
+ return snapshot(job.id)
122
+ }
123
+ async function ensure(provider) {
124
+ const result = start(provider)
125
+ const job = jobs.get(result.id)
126
+ await job.promise
127
+ if (job.phase !== 'ready') throw new Error(job.error || (job.phase === 'needs_install'
128
+ ? 'Ollama is not installed; use the local setup button' : 'Selected model is not downloaded; use the local setup button'))
129
+ return snapshot(job.id)
130
+ }
131
+ return { start, snapshot, ensure }
132
+ }
@@ -0,0 +1,135 @@
1
+ import http from 'node:http'
2
+ import https from 'node:https'
3
+ import tls from 'node:tls'
4
+ import { isIP } from 'node:net'
5
+
6
+ export function isLoopback(url) {
7
+ const host = new URL(url).hostname.replace(/^\[|\]$/g, '').toLowerCase()
8
+ return ['localhost', '127.0.0.1', '::1', '::ffff:7f00:1'].includes(host)
9
+ }
10
+
11
+ function proxyAuthorization(proxy) {
12
+ if (!proxy.username && !proxy.password) return {}
13
+ const credentials = decodeURIComponent(proxy.username) + ':' + decodeURIComponent(proxy.password)
14
+ return { 'Proxy-Authorization': 'Basic ' + Buffer.from(credentials).toString('base64') }
15
+ }
16
+
17
+ // One native request, with one deadline covering CONNECT, TLS, and the body.
18
+ // HTTP targets use an ordinary forward proxy; HTTPS targets use its CONNECT
19
+ // socket through a dedicated TLS agent, never a second direct connection.
20
+ function requestOnce(url, options) {
21
+ return new Promise((resolve, reject) => {
22
+ const proxy = options.proxy ? new URL(options.proxy) : null
23
+ if (proxy && !['http:', 'https:'].includes(proxy.protocol)) {
24
+ reject(new Error('Unsupported native proxy protocol: ' + proxy.protocol))
25
+ return
26
+ }
27
+ let request, connectRequest, tunnel, agent, settled = false
28
+ const deadline = setTimeout(() => finish(new Error('request timeout')), options.timeoutMs)
29
+ function finish(error, value) {
30
+ if (settled) return
31
+ settled = true
32
+ clearTimeout(deadline)
33
+ // Destroy unfinished requests and the operation's private tunnel only.
34
+ // Direct requests keep Node's shared connection pool available.
35
+ if (error) request?.destroy()
36
+ connectRequest?.destroy()
37
+ tunnel?.destroy()
38
+ agent?.destroy()
39
+ if (error) reject(error)
40
+ else resolve(value)
41
+ }
42
+ function send(client, requestOptions) {
43
+ if (settled) return
44
+ try { request = client.request(requestOptions, response => {
45
+ const chunks = []
46
+ response.on('data', chunk => chunks.push(chunk))
47
+ response.on('error', error => finish(error))
48
+ response.on('aborted', () => finish(new Error('response aborted')))
49
+ response.on('end', () => finish(null, {
50
+ status: response.statusCode, headers: response.headers,
51
+ text: Buffer.concat(chunks).toString('utf8'),
52
+ }))
53
+ }) } catch (error) { finish(error); return }
54
+ request.on('error', error => finish(error))
55
+ if (options.body !== undefined) request.write(options.body)
56
+ request.end()
57
+ }
58
+ const targetHost = url.hostname.replace(/^\[|\]$/g, '')
59
+ const targetOptions = {
60
+ hostname: targetHost, port: url.port || (url.protocol === 'https:' ? 443 : 80),
61
+ path: url.pathname + url.search, method: options.method || 'GET',
62
+ headers: { ...options.headers }, ca: options.ca,
63
+ }
64
+ // Bing and some model gateways reject chunked POST bodies with HTTP 411.
65
+ if (options.body !== undefined && !Object.keys(targetOptions.headers).some(key =>
66
+ ['content-length', 'transfer-encoding'].includes(key.toLowerCase()))) {
67
+ targetOptions.headers['Content-Length'] = Buffer.byteLength(options.body)
68
+ }
69
+ if (!proxy) {
70
+ send(url.protocol === 'https:' ? https : http, targetOptions)
71
+ } else {
72
+ const proxyClient = proxy.protocol === 'https:' ? https : http
73
+ const proxyOptions = {
74
+ hostname: proxy.hostname.replace(/^\[|\]$/g, ''),
75
+ port: proxy.port || (proxy.protocol === 'https:' ? 443 : 80), ca: options.ca,
76
+ }
77
+ if (url.protocol === 'http:') {
78
+ send(proxyClient, { ...proxyOptions, method: targetOptions.method, path: url.href,
79
+ headers: { ...targetOptions.headers, Host: url.host, ...proxyAuthorization(proxy) } })
80
+ } else {
81
+ const authority = url.host + (url.port ? '' : ':443')
82
+ connectRequest = proxyClient.request({ ...proxyOptions, method: 'CONNECT', path: authority,
83
+ headers: { Host: authority, ...proxyAuthorization(proxy) } })
84
+ connectRequest.on('error', error => finish(error))
85
+ connectRequest.on('connect', (response, socket, head) => {
86
+ if (settled) { socket.destroy(); return }
87
+ tunnel = socket
88
+ tunnel.on('error', error => finish(error))
89
+ if (response.statusCode !== 200) { finish(new Error('proxy CONNECT ' + response.statusCode)); return }
90
+ if (head.length) socket.unshift(head)
91
+ agent = new https.Agent({ keepAlive: false })
92
+ agent.createConnection = () => tls.connect({ socket,
93
+ servername: isIP(targetHost) ? undefined : targetHost, ca: options.ca,
94
+ ALPNProtocols: ['http/1.1'],
95
+ })
96
+ send(https, { ...targetOptions, agent })
97
+ })
98
+ connectRequest.end()
99
+ }
100
+ }
101
+ })
102
+ }
103
+
104
+ export async function requestText(urlString, options = {}) {
105
+ let url = new URL(urlString)
106
+ let method = options.method || 'GET', body = options.body
107
+ let headers = { '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',
108
+ Accept: 'application/json,text/plain,*/*', ...options.headers }
109
+ const deadline = Date.now() + (options.timeoutMs || 25000)
110
+ for (let redirects = 0; redirects <= 5; redirects++) {
111
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Unsupported request protocol: ' + url.protocol)
112
+ const remaining = deadline - Date.now()
113
+ if (remaining <= 0) throw new Error('request timeout')
114
+ // Re-evaluate loopback on every redirect so a local model never uses a proxy.
115
+ const response = await requestOnce(url, { ...options, method, body, headers,
116
+ timeoutMs: remaining, proxy: isLoopback(url.href) ? null : options.proxy })
117
+ if ([301, 302, 303, 307, 308].includes(response.status) && response.headers.location) {
118
+ if (redirects === 5) throw new Error('Too many redirects')
119
+ const next = new URL(response.headers.location, url)
120
+ if (next.origin !== url.origin) {
121
+ headers = Object.fromEntries(Object.entries(headers).filter(([key]) =>
122
+ !['authorization', 'cookie', 'x-api-key', 'api-key', 'host'].includes(key.toLowerCase())))
123
+ }
124
+ if ((response.status === 303 && method !== 'HEAD') || ([301, 302].includes(response.status) && method === 'POST')) {
125
+ method = 'GET'; body = undefined
126
+ headers = Object.fromEntries(Object.entries(headers).filter(([key]) =>
127
+ !['content-type', 'content-length', 'transfer-encoding'].includes(key.toLowerCase())))
128
+ }
129
+ url = next
130
+ continue
131
+ }
132
+ if (response.status < 200 || response.status >= 300) throw new Error('HTTP ' + response.status)
133
+ return response.text
134
+ }
135
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-think-translate",
3
3
  "description": "Display-layer translation for the DSH web app: thinking chain, task cards, and optional answer text, in 8 target languages (local Ollama / any OpenAI-compatible or Anthropic endpoint / Google / Bing, fail-open)",
4
- "version": "1.2.5",
4
+ "version": "1.2.8",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -12,6 +12,8 @@
12
12
  "files": [
13
13
  "lib/index.js",
14
14
  "lib/client.js",
15
+ "lib/ollama.js",
16
+ "lib/transport.js",
15
17
  "cordis.patch.yml",
16
18
  "demo/demo.gif",
17
19
  "demo/demo2.gif"