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 +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 +142 -51
- package/lib/index.js +188 -22
- package/lib/ollama.js +132 -0
- package/package.json +3 -2
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.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
|
-
|
|
1267
|
-
|
|
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
|
-
|
|
1483
|
-
//
|
|
1484
|
-
var
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
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)); });
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|