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 +4 -0
- package/README.es.md +4 -0
- package/README.fr.md +4 -0
- package/README.ja.md +4 -0
- package/README.ko.md +4 -0
- package/README.md +4 -0
- package/README.ru.md +4 -0
- package/README.zh-CN.md +4 -0
- package/lib/client.js +163 -64
- package/lib/index.js +227 -108
- package/lib/ollama.js +132 -0
- package/lib/transport.js +135 -0
- package/package.json +3 -1
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.
|
|
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)
|
|
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
|
-
},
|
|
866
|
+
}, 180000);
|
|
846
867
|
if (!res.ok) throw new Error("route HTTP " + res.status);
|
|
847
868
|
var j = await res.json();
|
|
848
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
914
|
-
|
|
915
|
-
requestTranslate(text, s.lang).then(function (r) {
|
|
941
|
+
var retryTimer = null;
|
|
942
|
+
function attempt() {
|
|
916
943
|
if (!alive) return;
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
setTimeout(
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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.
|
|
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
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1483
|
-
//
|
|
1484
|
-
var
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
1783
|
-
//
|
|
1784
|
-
|
|
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
|
-
|
|
1874
|
-
|
|
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
|
|
4
|
+
// and the system-proxy-aware native transport.
|
|
5
5
|
//
|
|
6
|
-
// Providers: google (gtx
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
212
|
-
return args
|
|
337
|
+
return args.concat(extra)
|
|
213
338
|
}
|
|
214
339
|
|
|
215
|
-
async function
|
|
216
|
-
|
|
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
|
|
220
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/transport.js
ADDED
|
@@ -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.
|
|
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"
|