dsh-think-translate 1.2.5 → 1.2.7

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.7";
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
@@ -713,7 +713,18 @@ window.__ModuleLoader__.load({
713
713
  } catch (e) {}
714
714
  }
715
715
  var pend = new Map();
716
- var stats = { ok: 0, fail: 0, lastError: null, lastProvider: null, lastModel: null };
716
+ var stats = { ok: 0, fail: 0, lastError: null, lastProvider: null, lastModel: null };
717
+ var providerChecks = {};
718
+ var providerTestSequence = {};
719
+ var statusListeners = new Set();
720
+ function providerFingerprint(p) {
721
+ return fnv(JSON.stringify({ type: p.type, baseURL: p.baseURL, model: p.model, apiKeyEnv: p.apiKeyEnv,
722
+ apiKey: p.source === "dsh" || p.apiKeyEnv ? undefined : p.apiKey }));
723
+ }
724
+ function recordCheck(id, ok, model, signature) {
725
+ providerChecks[id] = { ok: ok, model: model || null, signature: signature };
726
+ statusListeners.forEach(function (notify) { notify(); });
727
+ }
717
728
  // Diagnostic ledger surfaced in the settings panel (2026-08-24): bundle
718
729
  // version, apply execution, per-slot registration outcome, current lang.
719
730
  var diag = { version: "v5-mode", applied: false, lang: "?", slots: {}, lastLangChange: 0 };
@@ -844,7 +855,8 @@ window.__ModuleLoader__.load({
844
855
  body: JSON.stringify({ text: text, target: target })
845
856
  }, 30000);
846
857
  if (!res.ok) throw new Error("route HTTP " + res.status);
847
- var j = await res.json();
858
+ var j = await res.json();
859
+ if (j && Array.isArray(j.attempts)) j.attempts.forEach(function (attempt) { recordCheck(attempt.provider, attempt.ok, attempt.model, attempt.signature); });
848
860
  if (!(j && j.ok)) throw new Error("route ok=false");
849
861
  return j;
850
862
  }
@@ -880,7 +892,8 @@ window.__ModuleLoader__.load({
880
892
  r = { ok: true, text: outs.join(""), provider: "google-direct" };
881
893
  } catch (e2) { r = null; if (e2 && e2.message) lastErr = e2; }
882
894
  }
883
- if (r && r.ok) {
895
+ if (r && r.ok) {
896
+ if (r.providerSignature) recordCheck(r.provider, true, r.model, r.providerSignature);
884
897
  stats.ok++; stats.lastError = null; stats.lastProvider = r.provider; stats.lastModel = r.model || null;
885
898
  memSet(key, r);
886
899
  return r;
@@ -1225,8 +1238,9 @@ window.__ModuleLoader__.load({
1225
1238
  }
1226
1239
 
1227
1240
  function AssistantView(props) {
1228
- var node = props.node;
1229
- var data = node.data;
1241
+ var node = props.node;
1242
+ var data = node.data;
1243
+ var groupPart = props.groupPart;
1230
1244
  var useTurnData = props.useTurnData;
1231
1245
  var tt = UI_TEXT[useStore().lang] || UI_TEXT.en;
1232
1246
  var tail = useTurnData("turn-tail");
@@ -1245,8 +1259,12 @@ window.__ModuleLoader__.load({
1245
1259
  var rendered = [];
1246
1260
  var last = blocks.length - 1;
1247
1261
  for (var i = 0; i < blocks.length; i++) {
1248
- var block = blocks[i];
1249
- if (!block) continue;
1262
+ var block = blocks[i];
1263
+ if (!block) continue;
1264
+ // DSH may mount this step separately for reasoning and response.
1265
+ // Keep original block indexes so streaming-tail detection is unchanged.
1266
+ if (groupPart === "reasoning" && block.kind !== "reasoning") continue;
1267
+ if (groupPart === "response" && block.kind === "reasoning") continue;
1250
1268
  if (block.kind === "text") {
1251
1269
  rendered.push(createElement(TextBlockSafe, { key: "t" + i, text: block.text, streaming: streaming }));
1252
1270
  } else if (block.kind === "reasoning") {
@@ -1262,16 +1280,20 @@ window.__ModuleLoader__.load({
1262
1280
  } else {
1263
1281
  rendered.push(createElement("pre", { key: "u" + i, className: "xl-pre" }, JSON.stringify(block, null, 2)));
1264
1282
  }
1265
- }
1266
- return createElement("div", null, rendered,
1267
- interrupted ? createElement("span", { className: "xl-warn" }, (props.t && props.t("message.stopped")) || tt.stopped) : null);
1283
+ }
1284
+ var showStopped = interrupted && (groupPart === undefined || groupPart === "response" ||
1285
+ !blocks.some(function (b) { return b && b.kind !== "reasoning" && b.kind !== "tool-call"; }));
1286
+ return createElement("div", null, rendered,
1287
+ showStopped ? createElement("span", { className: "xl-warn" }, (props.t && props.t("message.stopped")) || tt.stopped) : null);
1268
1288
  }
1269
1289
 
1270
- function originalBlocksText(blocks) {
1290
+ function originalBlocksText(blocks, groupPart) {
1271
1291
  var parts = [];
1272
1292
  for (var i = 0; i < blocks.length; i++) {
1273
- var b = blocks[i];
1274
- if (!b) continue;
1293
+ var b = blocks[i];
1294
+ if (!b) continue;
1295
+ if (groupPart === "reasoning" && b.kind !== "reasoning") continue;
1296
+ if (groupPart === "response" && b.kind === "reasoning") continue;
1275
1297
  if ((b.kind === "text" || b.kind === "reasoning") && typeof b.text === "string" && b.text.trim()) parts.push(b.text);
1276
1298
  }
1277
1299
  return parts;
@@ -1294,7 +1316,7 @@ window.__ModuleLoader__.load({
1294
1316
  try {
1295
1317
  var node = props && props.node;
1296
1318
  var blocks = node && node.data && Array.isArray(node.data.blocks) ? node.data.blocks : [];
1297
- var parts = originalBlocksText(blocks);
1319
+ var parts = originalBlocksText(blocks, props.groupPart);
1298
1320
  if (parts.length) fb = createElement("div", null, createElement(Markdownish, { text: parts.join("\n\n") }));
1299
1321
  } catch (e) { fb = null; }
1300
1322
  return createElement(XBoundary, { fallback: fb }, createElement(AssistantView, props));
@@ -1478,12 +1500,17 @@ window.__ModuleLoader__.load({
1478
1500
  // `mem` is a plain Map, so clearing it changes nothing React can see: the
1479
1501
  // 缓存条目 counter kept the old number until some other state moved.
1480
1502
  var tickPair = useState(0);
1481
- 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;
1503
+ var bumpTick = tickPair[1];
1504
+ useEffect(function () { var notify = function () { bumpTick(function (n) { return n + 1; }); }; statusListeners.add(notify); return function () { statusListeners.delete(notify); }; }, []);
1505
+ // A lower-ranked provider test cannot displace a verified higher-ranked one.
1506
+ var currentCfg = draft.cfg || {};
1507
+ var ids = (currentCfg.chain || []).concat(currentCfg.fallback && currentCfg.fallback.enabled ? currentCfg.fallback.chain || [] : []);
1508
+ var current = null;
1509
+ for (var i = 0; i < ids.length; i++) {
1510
+ var p = currentCfg.providers && currentCfg.providers[ids[i]], check = providerChecks[ids[i]];
1511
+ if (p && (p.enabled || p.source === "dsh") && check && check.ok && check.signature === providerFingerprint(p)) { current = { id: ids[i], model: check.model }; break; }
1512
+ }
1513
+ var inUse = current ? ((t.labels[current.id] || current.id) + (current.model ? " · " + current.model : "")) : (LOCAL_EMPTY[s.lang] || LOCAL_EMPTY.en);
1487
1514
  return createElement("div", null,
1488
1515
  // The clear button rides the "当前使用" row, pushed to that row's end:
1489
1516
  // one row per idea (what is in use, then the counters), no stray rows.
@@ -1590,7 +1617,25 @@ window.__ModuleLoader__.load({
1590
1617
  // lives here instead, outside React. Refs (cfgRef/seqRef) deliberately do NOT:
1591
1618
  // they belong to the instance that owns the in-flight requests.
1592
1619
  // ------------------------------------------------------------------
1593
- var draft = {};
1620
+ var draft = {};
1621
+ 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: "Провайдер пока не проверен" };
1622
+ var LOCAL_PHASES = ["checking", "needs_install", "downloading_ollama", "verifying", "installing", "starting", "needs_model", "downloading_model", "loading_model", "ready", "error", "setup", "retry", "manual"];
1623
+ var LOCAL_LABELS = {
1624
+ "zh-CN": ["检查已有 Ollama", "未找到 Ollama", "下载 Ollama", "验证安装包", "安装 Ollama", "启动 Ollama", "所选模型尚未下载", "下载模型", "加载模型", "本地模型已就绪", "准备失败", "一键安装并准备(含模型下载)", "重试准备", "打开官方安装指南"],
1625
+ 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"],
1626
+ ja: ["既存の Ollama を確認", "Ollama が見つかりません", "Ollama をダウンロード", "インストーラーを検証", "Ollama をインストール", "Ollama を起動", "モデルは未ダウンロード", "モデルをダウンロード", "モデルをロード", "ローカルモデル準備完了", "準備に失敗", "インストールして準備(モデルを含む)", "準備を再試行", "公式インストールガイド"],
1627
+ ko: ["기존 Ollama 확인", "Ollama를 찾을 수 없음", "Ollama 다운로드", "설치 파일 검증", "Ollama 설치", "Ollama 시작", "모델이 다운로드되지 않음", "모델 다운로드", "모델 로드", "로컬 모델 준비 완료", "준비 실패", "설치 및 준비 (모델 다운로드 포함)", "준비 다시 시도", "공식 설치 안내"],
1628
+ 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"],
1629
+ 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"],
1630
+ 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"],
1631
+ ru: ["Проверка Ollama", "Ollama не найден", "Загрузка Ollama", "Проверка установщика", "Установка Ollama", "Запуск Ollama", "Модель не скачана", "Скачивание модели", "Загрузка модели", "Локальная модель готова", "Ошибка подготовки", "Установить и подготовить (с моделью)", "Повторить подготовку", "Официальная инструкция"],
1632
+ };
1633
+ function localLabel(phase, lang) { var index = LOCAL_PHASES.indexOf(phase); return (LOCAL_LABELS[lang] || LOCAL_LABELS.en)[index < 0 ? 0 : index]; }
1634
+ function localOrigin(p) {
1635
+ try { var u = new URL(p && p.baseURL); return p.type === "openai" && u.protocol === "http:" &&
1636
+ ["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; }
1637
+ catch (e) { return null; }
1638
+ }
1594
1639
  function useDraft(key, initial) {
1595
1640
  var pair = useState(key in draft ? draft[key] : initial);
1596
1641
  if (!(key in draft)) draft[key] = pair[0];
@@ -1613,7 +1658,11 @@ window.__ModuleLoader__.load({
1613
1658
  var pair = useDraft("cfg", null);
1614
1659
  var cfg = pair[0], setCfg = pair[1];
1615
1660
  var msgPair = useDraft("msg", "");
1616
- var msg = msgPair[0], setMsg = msgPair[1];
1661
+ var msg = msgPair[0], setMsg = msgPair[1];
1662
+ var localPair = useDraft("localJob", null);
1663
+ var localJob = localPair[0];
1664
+ var localJobRef = useState(function () { return { current: localJob && localJob.id }; })[0];
1665
+ function setLocalJob(job) { localJobRef.current = job && job.id; localPair[1](job); }
1617
1666
  var modelsPair = useDraft("models", null);
1618
1667
  var models = modelsPair[0], setModels = modelsPair[1];
1619
1668
  var modelMenuPair = useDraft("modelMenu", null); var modelMenu = modelMenuPair[0], setModelMenu = modelMenuPair[1];
@@ -1661,7 +1710,53 @@ window.__ModuleLoader__.load({
1661
1710
  // Monotonic save sequence: a stale /_xlate/config POST response must
1662
1711
  // not overwrite a newer optimistic state (Important-3/4).
1663
1712
  var seqRefPair = useState(function () { return { current: 0 }; });
1664
- var seqRef = seqRefPair[0];
1713
+ var seqRef = seqRefPair[0];
1714
+ var localSeq = useState(function () { return { current: 0 }; })[0];
1715
+ function testProvider(id, sequence) {
1716
+ var testSequence = (providerTestSequence[id] || 0) + 1;
1717
+ providerTestSequence[id] = testSequence;
1718
+ var testedProvider = cfgRef.current.providers[id] || {};
1719
+ var testedModel = testedProvider.model;
1720
+ var testedSignature = providerFingerprint(testedProvider);
1721
+ setMsg(t.testing);
1722
+ return fetch("/_xlate/translate", { method: "POST", headers: { "Content-Type": "application/json" },
1723
+ body: JSON.stringify({ text: "Hello world, connection test. [" + Date.now().toString(36) + "]", target: s.lang, provider: id }) })
1724
+ .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
1725
+ .then(function (j) { if (sequence !== undefined && sequence !== localSeq.current) return;
1726
+ if (providerTestSequence[id] !== testSequence) return;
1727
+ var currentProvider = cfgRef.current.providers[id] || {};
1728
+ if (providerFingerprint(currentProvider) !== testedSignature || (j.model && testedModel && j.model !== testedModel)) return;
1729
+ recordCheck(id, !!(j && j.ok), j.model || testedModel, j.providerSignature || testedSignature);
1730
+ if (j && j.ok) stats.lastError = null;
1731
+ setMsg(j && j.ok ? t.connOk + " ✓ (" + providerLabel(id, (cfgRef.current.providers || {})[id] || {}, t) + (j.model ? " · " + j.model : "") + ")" : (t.connFail + ": " + (j.error || "unknown"))); })
1732
+ .catch(function (e) { if (sequence !== undefined && sequence !== localSeq.current) return;
1733
+ if (providerTestSequence[id] !== testSequence || providerFingerprint(cfgRef.current.providers[id] || {}) !== testedSignature) return;
1734
+ recordCheck(id, false, testedModel, testedSignature);
1735
+ setMsg(t.connFail + ": " + (e && e.message ? e.message : e)); });
1736
+ }
1737
+ function prepareLocal(allowSetup, testAfter, downloadOnly) {
1738
+ var sequence = ++localSeq.current;
1739
+ return fetch("/_xlate/local/prepare", { method: "POST", headers: { "Content-Type": "application/json" },
1740
+ body: JSON.stringify({ allowInstall: allowSetup === true, allowDownload: allowSetup === true || downloadOnly === true }) })
1741
+ .then(function (r) { return r.json().then(function (j) { if (!r.ok || !j.ok) throw new Error(j.error || "HTTP " + r.status); return j; }); })
1742
+ .then(function (j) { if (sequence !== localSeq.current) return; setLocalJob(j.job); if (testAfter) return testProvider("openai", sequence); })
1743
+ .catch(function (e) { if (sequence === localSeq.current) setMsg(localLabel("error", s.lang) + ": " + (e.message || e)); });
1744
+ }
1745
+ var localProvider = cfg && cfg.providers && cfg.providers.openai;
1746
+ var firstEnabled = cfg && (cfg.chain || []).filter(function (id) { var p = cfg.providers[id]; return p && (p.enabled || p.source === "dsh"); })[0];
1747
+ var localKey = firstEnabled === "openai" && localOrigin(localProvider) && localProvider.model ? localProvider.baseURL + "|" + localProvider.model : "";
1748
+ useEffect(function () { if (localKey) prepareLocal(false, false); }, [localKey]);
1749
+ useEffect(function () {
1750
+ if (!localJob || !localJob.running) return;
1751
+ var alive = true;
1752
+ var timer = setInterval(function () {
1753
+ fetch("/_xlate/local/status?id=" + encodeURIComponent(localJob.id)).then(function (r) { return r.json(); }).then(function (j) {
1754
+ if (alive && j.job && localJobRef.current === localJob.id) setLocalJob(j.job);
1755
+ }).catch(function () {});
1756
+ }, 750);
1757
+ return function () { alive = false; clearInterval(timer); };
1758
+ }, [localJob ? localJob.id + "|" + localJob.running : ""]);
1759
+ useEffect(function () { return function () { localSeq.current++; }; }, []);
1665
1760
  // Add/edit inline form state. Lives here (not in a nested component,
1666
1761
  // and before the cfg guard) so re-renders — including the cfg=null →
1667
1762
  // cfg=value transition — never reset or reorder what the user types;
@@ -1776,23 +1871,12 @@ window.__ModuleLoader__.load({
1776
1871
  .catch(function () {});
1777
1872
  });
1778
1873
  };
1779
- var startPull = function (m) {
1780
- if (!m || !m.trim()) return;
1781
- 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
- });
1874
+ var startPull = function (m) {
1875
+ if (!m || !m.trim()) return;
1876
+ var name = m.trim();
1877
+ // Download intent prepares an existing service first. Software install
1878
+ // still needs the separately labelled setup button if Ollama is absent.
1879
+ pickModel(name).then(function (saved) { if (saved) prepareLocal(false, false, true); });
1796
1880
  };
1797
1881
  var pickModel = function (m) {
1798
1882
  if (!m) return;
@@ -1800,9 +1884,9 @@ window.__ModuleLoader__.load({
1800
1884
  var openai = (cur.providers && cur.providers.openai) || {};
1801
1885
  applyLocal({ providers: { openai: { model: m } } });
1802
1886
  setModelMenu(null);
1803
- fetch("/_xlate/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providers: { openai: { model: m } } }) })
1887
+ return fetch("/_xlate/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ providers: { openai: { model: m } } }) })
1804
1888
  .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
1805
- .then(function (c) { cfgRef.current = c; setCfg(c); })
1889
+ .then(function (c) { cfgRef.current = c; setCfg(c); return c; })
1806
1890
  .catch(function (e) {
1807
1891
  // Do not leave the chip showing a model the host never stored.
1808
1892
  setMsg((t.providerSaveError || t.connFail) + ": " + (e && e.message ? e.message : e));
@@ -1869,13 +1953,9 @@ window.__ModuleLoader__.load({
1869
1953
  applyLocal({ chain: chain });
1870
1954
  saveCfg({ chain: chain });
1871
1955
  },
1872
- 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)); });
1956
+ test: function (id) {
1957
+ if (id === "openai" && localOrigin(cfgRef.current.providers.openai)) prepareLocal(false, true);
1958
+ else testProvider(id);
1879
1959
  },
1880
1960
  edit: function (id) {
1881
1961
  var p = cfgRef.current.providers && cfgRef.current.providers[id];
@@ -2228,7 +2308,18 @@ window.__ModuleLoader__.load({
2228
2308
  adding || editingId ? null
2229
2309
  : createElement("button", { className: "xl-btn", type: "button", onClick: rescanDsh, style: { marginTop: "4px", marginLeft: "6px" } },
2230
2310
  "↻ " + t.dshImport),
2231
- unusedArea,
2311
+ unusedArea,
2312
+ localJob && localProvider && localJob.model === localProvider.model && localJob.origin === localOrigin(localProvider)
2313
+ ? createElement("div", { className: "xl-local-status", role: "status", style: { marginTop: "8px" } },
2314
+ createElement("div", { className: "xl-note" }, localLabel(localJob.phase, s.lang) + " · " + localJob.model +
2315
+ (localJob.percent !== null && localJob.phase.indexOf("downloading") === 0 ? " · " + localJob.percent + "%" : "") +
2316
+ (localJob.completed ? " · " + (localJob.completed / 1048576).toFixed(1) + " MB" + (localJob.total ? " / " + (localJob.total / 1048576).toFixed(1) + " MB" : "") : "")),
2317
+ localJob.running ? createElement("div", { className: "xl-progress" }, createElement("div", { className: "xl-progress-bar", style: { width: (localJob.percent === null ? 15 : localJob.percent) + "%" } })) : null,
2318
+ localJob.error ? createElement("div", { className: "xl-warn" }, localJob.error) : null,
2319
+ !localJob.running && (localJob.phase === "needs_model" || localJob.phase === "error" || (localJob.phase === "needs_install" && localJob.canInstall))
2320
+ ? createElement("button", { className: "xl-btn", onClick: function () { prepareLocal(true, true); } }, localLabel(localJob.phase === "error" ? "retry" : "setup", s.lang)) : null,
2321
+ localJob.phase === "needs_install" && !localJob.canInstall ? createElement("a", { href: "https://ollama.com/download", target: "_blank", rel: "noreferrer" }, localLabel("manual", s.lang)) : null)
2322
+ : null,
2232
2323
  msg ? createElement("div", { className: "xl-note", style: { marginTop: "6px" } }, msg) : null);
2233
2324
  }
2234
2325
 
package/lib/index.js CHANGED
@@ -6,8 +6,11 @@
6
6
  // Providers: google (gtx via Node CONNECT tunnel / curl), bing (ttranslatev3
7
7
  // via curl), openai-compatible (any /chat/completions endpoint — usually the
8
8
  // local Ollama).
9
- import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises'
10
- import { dirname } from 'node:path'
9
+ import { readFile, writeFile, mkdir, copyFile, access, unlink, stat } from 'node:fs/promises'
10
+ import { dirname, join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+ import { randomUUID } from 'node:crypto'
13
+ import { createLocalPreparation, ollamaOrigin, localFirst, validOllamaSignature } from './ollama.js'
11
14
  import { fileURLToPath } from 'node:url'
12
15
  import http from 'node:http'
13
16
  import https from 'node:https'
@@ -83,7 +86,134 @@ const VALID_TYPES = ['google', 'bing', 'openai', 'anthropic']
83
86
  // ---------------------------------------------------------------- transport
84
87
 
85
88
  let subprocessRef = null
86
- let timerRef = null
89
+ let timerRef = null
90
+
91
+ async function localJson(url, options = {}, timeoutMs = 10000) {
92
+ const response = await fetch(url, { ...options, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) })
93
+ const body = await response.json()
94
+ if (!response.ok || body.error) throw new Error(body.error || 'Ollama HTTP ' + response.status)
95
+ return body
96
+ }
97
+
98
+ async function findOllamaExecutable() {
99
+ if (!subprocessRef) throw new Error('subprocess unavailable')
100
+ try { return await subprocessRef.resolveExecutable('ollama') } catch { /* Try installed-app locations when PATH is stale. */ }
101
+ const candidates = process.platform === 'win32'
102
+ ? [join(process.env.LOCALAPPDATA || '', 'Programs', 'Ollama', 'ollama.exe')]
103
+ : ['/usr/local/bin/ollama', '/usr/bin/ollama', '/Applications/Ollama.app/Contents/Resources/ollama']
104
+ if (process.platform === 'win32') for (const hive of ['HKCU', 'HKLM']) {
105
+ try {
106
+ const key = hive + ':\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{44E83376-CE68-45EB-8FC1-393500EB558C}_is1'
107
+ const result = await runProgram('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
108
+ '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);$p=Get-ItemProperty -LiteralPath ' + "'" + key + "'" + ' -ErrorAction Stop;[Console]::Write($p.InstallLocation)'], 8000)
109
+ if (result.trim()) candidates.push(join(result.trim(), 'ollama.exe'))
110
+ } catch { /* This hive may not contain an Ollama installation. */ }
111
+ }
112
+ for (const candidate of candidates) {
113
+ try { await access(candidate); return candidate } catch { /* Probe the next known installation location. */ }
114
+ }
115
+ return null
116
+ }
117
+
118
+ async function startOllamaServer(executable, origin) {
119
+ const child = subprocessRef.spawn({
120
+ argv: [executable, 'serve'], cwd: await workdir(), graceMs: 2000,
121
+ stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
122
+ env: { OLLAMA_HOST: new URL(origin).host },
123
+ })
124
+ // Drain the long-lived pipe; the subprocess provider owns shutdown and joins.
125
+ if (child.stderr) (async () => { for await (const chunk of child.stderr) { void chunk } })().catch(() => {})
126
+ child.done.catch(() => {})
127
+ }
128
+
129
+ async function streamModelPull(origin, model, progress) {
130
+ const response = await fetch(origin + '/api/pull', {
131
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, redirect: 'error',
132
+ body: JSON.stringify({ model, stream: true }), signal: AbortSignal.timeout(60 * 60 * 1000),
133
+ })
134
+ if (!response.ok) throw new Error('Ollama download HTTP ' + response.status)
135
+ const decoder = new TextDecoder()
136
+ const layers = new Map()
137
+ let buffer = '', success = false
138
+ const consume = line => {
139
+ if (!line.trim()) return
140
+ const event = JSON.parse(line)
141
+ if (event.error) throw new Error(event.error)
142
+ if (event.digest && typeof event.total === 'number') layers.set(event.digest, {
143
+ total: event.total, completed: typeof event.completed === 'number' ? event.completed : 0,
144
+ })
145
+ let total = 0, completed = 0
146
+ for (const layer of layers.values()) { total += layer.total; completed += layer.completed }
147
+ progress({ total, completed, percent: total ? Math.min(100, Math.round(completed / total * 100)) : null })
148
+ if (event.status === 'success') success = true
149
+ }
150
+ for await (const bytes of response.body) {
151
+ buffer += decoder.decode(bytes, { stream: true })
152
+ let index
153
+ while ((index = buffer.indexOf('\n')) >= 0) { consume(buffer.slice(0, index)); buffer = buffer.slice(index + 1) }
154
+ }
155
+ consume(buffer + decoder.decode())
156
+ if (!success) throw new Error('Model download ended before Ollama reported success')
157
+ }
158
+
159
+ async function installOllama(progress) {
160
+ const file = join(tmpdir(), 'dsh-ollama-' + randomUUID() + '.exe')
161
+ try {
162
+ progress({ phase: 'downloading_ollama', percent: null, completed: 0, total: 0 })
163
+ let totalBytes = 0
164
+ try {
165
+ const headers = await runProgram('curl', await curlArgs(['--fail', '--head', '--location', 'https://ollama.com/download/OllamaSetup.exe']), 30000)
166
+ const lengths = [...headers.matchAll(/content-length:\s*(\d+)/gi)]
167
+ if (lengths.length) totalBytes = Number(lengths[lengths.length - 1][1])
168
+ } catch { /* A missing HEAD size does not prevent a signed download. */ }
169
+ const args = await curlArgs(['--fail', '--location', '--proto', '=https', '--proto-redir', '=https',
170
+ '--progress-bar', '--retry', '2', '--max-time', '1800', '--output', file, 'https://ollama.com/download/OllamaSetup.exe'])
171
+ // Replace curlArgs' silent/25-second prefix with the download's own limit.
172
+ const filtered = args.slice(3)
173
+ const executable = await subprocessRef.resolveExecutable('curl')
174
+ const child = subprocessRef.spawn({ argv: [executable, ...filtered], cwd: await workdir(), graceMs: 2000,
175
+ signal: AbortSignal.timeout(30 * 60 * 1000), stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' } })
176
+ let tail = ''
177
+ if (child.stderr) for await (const bytes of child.stderr) {
178
+ tail = (tail + new TextDecoder().decode(bytes)).slice(-2000)
179
+ const matches = [...tail.matchAll(/(\d+(?:\.\d+)?)%/g)]
180
+ const downloaded = await stat(file).catch(() => null)
181
+ progress({ completed: downloaded ? downloaded.size : 0, total: totalBytes,
182
+ percent: matches.length ? Math.min(100, Number(matches[matches.length - 1][1])) : null })
183
+ }
184
+ const outcome = await child.done
185
+ if (outcome.exitCode !== 0) throw new Error('Ollama installer download failed: ' + tail.trim().slice(-300))
186
+ progress({ phase: 'verifying', percent: null })
187
+ const quoted = "'" + file.replace(/'/g, "''") + "'"
188
+ const signature = await runProgram('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
189
+ '$s=Get-AuthenticodeSignature -LiteralPath ' + quoted + '; @{status=[string]$s.Status;subject=$s.SignerCertificate.Subject}|ConvertTo-Json -Compress'], 30000)
190
+ if (!validOllamaSignature(JSON.parse(signature))) throw new Error('Ollama installer signature verification failed')
191
+ progress({ phase: 'installing', percent: null })
192
+ const appState = join(process.env.LOCALAPPDATA, 'Ollama')
193
+ await mkdir(appState, { recursive: true })
194
+ await writeFile(join(appState, 'upgraded'), '', 'utf8') // Official installer convention: launch the app hidden.
195
+ // The official per-user installer supports these unattended flags.
196
+ const controller = new AbortController()
197
+ const deadline = setTimeout(() => controller.abort(), 10 * 60 * 1000)
198
+ try {
199
+ // Ignore inherited pipes: a background app launched by the installer can
200
+ // retain them even after the installer itself exits successfully.
201
+ const child = subprocessRef.spawn({ argv: [file, '/VERYSILENT', '/NORESTART', '/SUPPRESSMSGBOXES'],
202
+ cwd: await workdir(), graceMs: 2000, signal: controller.signal,
203
+ stdio: { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' } })
204
+ const outcome = await child.done
205
+ if (outcome.exitCode !== 0) throw new Error('Ollama installation failed with exit code ' + outcome.exitCode)
206
+ } finally { clearTimeout(deadline) }
207
+ } finally {
208
+ await unlink(file).catch(() => {}) // Only this operation's unique installer download.
209
+ }
210
+ }
211
+
212
+ const localPreparation = createLocalPreparation({
213
+ platform: process.platform, json: localJson, findExecutable: findOllamaExecutable,
214
+ startServer: startOllamaServer, install: installOllama, pull: streamModelPull,
215
+ wait: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
216
+ })
87
217
 
88
218
  async function workdir() {
89
219
  return process.cwd() || '.'
@@ -1027,8 +1157,9 @@ function chunkText(text) {
1027
1157
  }
1028
1158
 
1029
1159
  // Run one ordered provider-id list; returns { ok } or { ok: false, error }.
1030
- async function runChainFor(ids, text, target, cfg) {
1031
- let lastErr = null
1160
+ async function runChainFor(ids, text, target, cfg) {
1161
+ let lastErr = null
1162
+ const attempts = []
1032
1163
  for (const id of ids) {
1033
1164
  const p = cfg.providers && cfg.providers[id]
1034
1165
  // DSH-sourced providers are activated by chain membership (they cannot be
@@ -1038,22 +1169,30 @@ async function runChainFor(ids, text, target, cfg) {
1038
1169
  if (!p.enabled && p.source !== 'dsh') continue
1039
1170
  const adapter = ADAPTERS[p.type || id]
1040
1171
  if (!adapter) continue
1041
- try {
1042
- const chunks = chunkText(text)
1172
+ try {
1173
+ if (ollamaOrigin(id, p)) await localPreparation.ensure(p)
1174
+ const chunks = chunkText(text)
1043
1175
  const outs = []
1044
1176
  for (const c of chunks) outs.push(await adapter(c, target, p))
1045
1177
  state.ok++
1046
1178
  state.lastProvider = id
1047
1179
  state.lastError = null
1048
1180
  // 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 } : {}) }
1181
+ attempts.push({ provider: id, signature: providerFingerprint(p), ok: true, model: p.model || null })
1182
+ return { ok: true, text: outs.join('\n\n'), provider: id, providerSignature: providerFingerprint(p), attempts, ...(p.model ? { model: p.model } : {}) }
1050
1183
  } catch (e) {
1051
- lastErr = e
1184
+ lastErr = e
1185
+ attempts.push({ provider: id, signature: providerFingerprint(p), ok: false, model: p.model || null })
1052
1186
  if (id === 'bing') bingCache = null
1053
1187
  }
1054
1188
  }
1055
- return { ok: false, error: lastErr instanceof Error ? lastErr.message : (lastErr || 'no enabled provider in chain') }
1056
- }
1189
+ return { ok: false, attempts, error: lastErr instanceof Error ? lastErr.message : (lastErr || 'no enabled provider in chain') }
1190
+ }
1191
+
1192
+ function providerFingerprint(p) {
1193
+ return fnv(JSON.stringify({ type: p.type, baseURL: p.baseURL, model: p.model, apiKeyEnv: p.apiKeyEnv,
1194
+ apiKey: p.source === 'dsh' || p.apiKeyEnv ? undefined : p.apiKey }))
1195
+ }
1057
1196
 
1058
1197
  async function translateViaChain(text, target, cfg) {
1059
1198
  const primary = await runChainFor(cfg.chain || [], text, target, cfg)
@@ -1061,11 +1200,11 @@ async function translateViaChain(text, target, cfg) {
1061
1200
  let fb = null
1062
1201
  if (cfg.fallback && cfg.fallback.enabled && Array.isArray(cfg.fallback.chain)) {
1063
1202
  fb = await runChainFor(cfg.fallback.chain, text, target, cfg)
1064
- if (fb.ok) return fb
1203
+ if (fb.ok) return { ...fb, attempts: [...primary.attempts, ...fb.attempts] }
1065
1204
  }
1066
1205
  state.fail++
1067
1206
  state.lastError = primary.error || (fb && fb.error) || 'translate unavailable'
1068
- return { ok: false, text: text, error: state.lastError }
1207
+ return { ok: false, text: text, attempts: [...primary.attempts, ...(fb ? fb.attempts : [])], error: state.lastError }
1069
1208
  }
1070
1209
 
1071
1210
  async function handleTranslate(body) {
@@ -1086,12 +1225,13 @@ async function handleTranslate(body) {
1086
1225
  const p = cfg.providers && cfg.providers[forced]
1087
1226
  if (!p) return { ok: false, text, error: 'unknown provider: ' + forced }
1088
1227
  // 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 }
1228
+ if (!p.enabled && p.source !== 'dsh' && !ollamaOrigin(forced, p)) return { ok: false, text, error: 'provider disabled: ' + forced }
1090
1229
  const adapter = ADAPTERS[p.type || forced]
1091
1230
  if (!adapter) return { ok: false, text, error: 'unsupported type: ' + p.type }
1092
- try {
1093
- const out = await adapter(text, target, p)
1094
- return { ok: true, text: out, provider: forced, ...(p.model ? { model: p.model } : {}) }
1231
+ try {
1232
+ if (ollamaOrigin(forced, p)) await localPreparation.ensure(p)
1233
+ const out = await adapter(text, target, p)
1234
+ return { ok: true, text: out, provider: forced, providerSignature: providerFingerprint(p), ...(p.model ? { model: p.model } : {}) }
1095
1235
  } catch (e) {
1096
1236
  return { ok: false, text, error: e instanceof Error ? e.message : String(e) }
1097
1237
  }
@@ -1361,8 +1501,9 @@ export function apply(ctx) {
1361
1501
  // reordering the chain or switching models keeps serving the old
1362
1502
  // provider's output (and its name in the status row) until restart.
1363
1503
  cache.clear()
1364
- inflight.clear()
1365
- sendJson(res, 200, stripResolvedKeys(merged))
1504
+ inflight.clear()
1505
+ if (localFirst(merged)) localPreparation.start(merged.providers.openai)
1506
+ sendJson(res, 200, stripResolvedKeys(merged))
1366
1507
  return
1367
1508
  }
1368
1509
  sendJson(res, 405, { error: 'GET/POST only' })
@@ -1372,7 +1513,31 @@ export function apply(ctx) {
1372
1513
  },
1373
1514
  })
1374
1515
  return dispose
1375
- }, 'dsh-think-translate: config route')
1516
+ }, 'dsh-think-translate: config route')
1517
+
1518
+ ctx.effect(function () {
1519
+ return webServer.register({ kind: 'exact', path: '/_xlate/local/prepare', handler: async function (req, res) {
1520
+ try {
1521
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'POST only' }); return }
1522
+ const origin = req.headers.origin
1523
+ if (origin && new URL(origin).host !== req.headers.host) { sendJson(res, 403, { ok: false, error: 'same-origin only' }); return }
1524
+ const body = await readBody(req)
1525
+ const cfg = await loadConfig()
1526
+ sendJson(res, 202, { ok: true, job: localPreparation.start(cfg.providers.openai, {
1527
+ allowInstall: body.allowInstall === true, allowDownload: body.allowDownload === true,
1528
+ }) })
1529
+ } catch (error) { sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }) }
1530
+ } })
1531
+ }, 'dsh-think-translate: local preparation route')
1532
+ ctx.effect(function () {
1533
+ return webServer.register({ kind: 'exact', path: '/_xlate/local/status', handler: function (req, res) {
1534
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'GET only' }); return }
1535
+ const id = new URL(req.url, 'http://localhost').searchParams.get('id')
1536
+ sendJson(res, 200, { ok: true, job: localPreparation.snapshot(id) })
1537
+ } })
1538
+ }, 'dsh-think-translate: local preparation status')
1539
+ // Restoring a profile with Ollama already first must prepare it too.
1540
+ loadConfig().then(cfg => { if (localFirst(cfg)) localPreparation.start(cfg.providers.openai) }).catch(() => {})
1376
1541
 
1377
1542
  ctx.effect(function () {
1378
1543
  const dispose = webServer.register({
@@ -1474,5 +1639,6 @@ export {
1474
1639
  needsTranslate, hasProse, chunkText, fnv, deepMerge,
1475
1640
  structuredCloneSafe, ADAPTERS,
1476
1641
  resolveApiKey, stripResolvedKeys, missingApiKey, clearNullFields, handleVersion,
1477
- harnessHomeCandidates, pickHarnessHome,
1478
- }
1642
+ harnessHomeCandidates, pickHarnessHome,
1643
+ streamModelPull,
1644
+ }
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
+ }
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.7",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -11,7 +11,8 @@
11
11
  },
12
12
  "files": [
13
13
  "lib/index.js",
14
- "lib/client.js",
14
+ "lib/client.js",
15
+ "lib/ollama.js",
15
16
  "cordis.patch.yml",
16
17
  "demo/demo.gif",
17
18
  "demo/demo2.gif"