diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 050f99b..84b2b75 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,7 +29,7 @@ jobs:
run: python scripts/sync_catalog.py --check
- name: Python syntax
- run: python -m py_compile server.py audio_encoding.py tray_app.py "Kokoro TTS.pyw" tts_catalog.py windows_runtime.py windows_startup.py
+ run: python -m py_compile server.py audio_encoding.py tray_app.py "Kokoro TTS.pyw" tts_catalog.py windows_protocol.py windows_runtime.py windows_startup.py
- name: Bundled FFmpeg check
run: python -c "from audio_encoding import validate_ffmpeg; validate_ffmpeg()"
diff --git a/README.md b/README.md
index 7586e31..6c3a8a5 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
# 本地划词听译助手 - Local Selection Read & Translate
-> Select text in Chrome, then read it aloud locally with [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) or translate it locally through Ollama. Your selected text stays on your machine.
+> Select text in Chrome, then read it aloud with local [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) or translate it through local Ollama by default. You can also opt into a project-server Ollama source that you configure yourself.
[](https://github.com/Yan-ShiBo/LocalReadTranslate/actions/workflows/ci.yml)
- Local read-aloud · Local translation · Privacy-first
+ Local read-aloud · Local-first translation · User-controlled remote option
---
@@ -18,7 +18,7 @@
- **17 voices** — American male/female + British female, easily switchable
- **System tray app** — Runs silently in the background, right-click to control, with optional login auto-start
- **Browser settings panel** — Change and persist voice, speed, translation model and target language from a floating gear icon
-- **Local translation** — Select text and translate it locally through Ollama (`translategemma:4b` by default, switchable to another local model)
+- **Local-first translation** — Use local Ollama by default, or explicitly select a model exposed by the tray-managed project server
- **Copy selection as LaTeX** — Copy selected prose without translation while converting detected MathJax/MathML/KaTeX formulas to LaTeX
- **Trusted Types friendly UI** — The userscript builds UI with DOM APIs instead of assigning HTML strings, so stricter Google pages such as Gemini can run it
- **Manual Ollama residency** — Keep the selected translation model loaded while reading heavily, then unload it from the browser settings panel to free VRAM
@@ -35,20 +35,26 @@
- **Robust UI cleanup** — Frontend uses `MutationObserver` and `AbortController` to cleanly handle SPA routing changes
- **Playback progress** — Floating button shows a horizontal progress fill; streaming mode shows played seconds until final duration is known
- **GPU-accelerated** — Near real-time inference on NVIDIA GPUs
-- **Fully offline** — No internet required after initial model download (~200MB)
+- **Offline-capable local mode** — After models are downloaded, local TTS and local Ollama do not require the internet; remote mode intentionally sends requests to the configured server
## 📐 Architecture
+```text
+┌──────────────────────┐ HTTP on loopback ┌───────────────────────┐
+│ Chrome + Tampermonkey│ ──────────────────────► │ Local FastAPI broker │
+│ │ 127.0.0.1:5000 │ │
+│ Read / Translate │ ◄────────────────────── │ /tts → lazy Kokoro │
+│ Settings actions │ │ /translate → Ollama │
+└──────────────────────┘ └──────────┬────────────┘
+ │ localreadtranslate://start │
+ ▼ ├─► Local Ollama
+┌──────────────────────┐ SSH/API └─► Configured remote Ollama
+│ Windows tray app │
+│ start or wake server │
+└──────────────────────┘
```
-┌──────────────────────────┐ ┌─────────────────────────┐
-│ Chrome Browser │ POST /tts │ Local API Server │
-│ Tampermonkey Script │ ───────────────────► │ FastAPI + Kokoro TTS │
-│ │ │ 127.0.0.1:5000 │
-│ ① Select English text │ ◄─────────────────── │ │
-│ ② Click 🔊 button │ audio/wav stream │ ③ GPU inference │
-│ ④ HTML5 Audio plays │ │ │
-└──────────────────────────┘ └─────────────────────────┘
-```
+
+The userscript never receives SSH credentials or talks directly to Ollama. The local API is the single browser boundary; the tray app owns process startup, SSH/API configuration and tunnel lifecycle.
## 💻 Requirements
@@ -83,12 +89,26 @@ pip install -r requirements.txt
### 3. Start the Server
**Option A: System tray app** (recommended)
-- Double-click `Kokoro TTS.bat` — starts the tray app without relying on Windows `.pyw` file associations
+- Double-click `Kokoro TTS.bat` — starts the tray app without relying on Windows `.pyw` file associations. On Windows, the tray app also creates or repairs the current-user `localreadtranslate://start` URL handler.
**Option B: Terminal mode**
- Double-click `start.bat` — shows a console window with logs
-Both `.bat` launchers locate the `kokoro-tts` Conda environment Python directly, so normal startup does not require `conda init`. `Kokoro TTS.pyw` is kept as a no-console Python launcher, but it only works by double-click when Windows has a `.pyw` file association.
+Both `.bat` launchers locate the `kokoro-tts` Conda environment Python directly, so normal startup does not require `conda init`. `Kokoro TTS.pyw` is kept as a no-console Python launcher, but it only works by double-click when Windows has a `.pyw` file association. Terminal mode starts only the FastAPI process; use the tray app when you need an SSH tunnel or the browser's one-click service start.
+
+You can register or repair the browser start handler explicitly:
+
+```powershell
+conda run -n kokoro-tts python windows_protocol.py register
+```
+
+The handler is stored under the current user's registry hive and does not require administrator rights. It contains absolute paths, so rerun the command after moving or renaming the project folder.
+
+To remove only this per-user handler:
+
+```powershell
+conda run -n kokoro-tts python windows_protocol.py unregister
+```
For local translation, install [Ollama](https://ollama.com/) and pull a model:
@@ -104,9 +124,20 @@ Use **Keep loaded** in the Translation settings when you plan to translate or re
### Remote Ollama over LAN
-Right-click the Kokoro TTS tray icon and choose `Remote Service`. Enter the server name, IP, SSH port, username, password, and remote Ollama host/port. The app creates a local SSH tunnel to the server's native Ollama API, then the browser model selector will show models as `Server Name / model`.
+Right-click the Kokoro TTS tray icon and choose `Remote Service`. The bundled profile is prefilled for `10.12.96.203` but remains disabled by default, so normal startup stays local. Choose one of two connection modes:
+
+- `ssh`: uses your SSH agent, default keys, or matching `~/.ssh/config` entry first; an optional key file can be supplied explicitly, and a password is only used as fallback. The app loads system/OpenSSH host keys and rejects an unknown host, then forwards the remote Ollama endpoint through a local tunnel.
+- `api`: connects directly to an Ollama API base URL such as `http://10.12.96.203:11434` without creating a tunnel.
+
+After connecting, the browser model selector shows models as `Server Name / model`. The userscript never switches a local selection to a remote model automatically: click **Use project server** or choose a remote entry yourself. Ollama requests bypass ambient HTTP proxy settings so loopback and LAN prompts are not sent through an unrelated proxy.
-The browser script never receives the SSH password. For convenience, the tray app stores the remote profile in `tray_settings.json`.
+The browser script never receives the SSH password or key path. The tray app stores the remote profile in the ignored `tray_settings.json` file. This file is not encrypted: if you enter a fallback password, it is stored as plaintext on this computer. Prefer an SSH agent, OpenSSH config or a key file, and protect the local account and file permissions.
+
+Direct API mode targets a native Ollama base URL. It does not add API-key headers or turn Ollama into an authenticated public service. A URL such as `http://10.12.96.203:11434` is plaintext and should be used only on a trusted LAN or VPN; do not expose an unauthenticated Ollama port to the public internet.
+
+> SSH host identity is fail-closed: the client calls `load_system_host_keys()` and uses Paramiko `RejectPolicy`. Add a host to `known_hosts` only after verifying its fingerprint through a trusted channel. The configured `10.12.96.203` entry exists on this machine and was verified by a successful real reconnection.
+
+The Kokoro TTS model is loaded lazily on the first Read request. Starting the API or translating through a remote model does not initialize Torch/Kokoro or allocate local GPU memory; `/health` exposes `api_ready` and `tts_model_loaded` separately.
Formula wording is guided by `config/math_glossary.json`. Each symbol can define a direct reading, read-aloud defaults and contextual readings, for example right arrow can mean `maps to`, `approaches`, `implies`, `gives`, or simply `right arrow`. Local rules choose common cases first. For 4B models, the formula read-aloud path deliberately prefers these literal rules and omits formula context where possible; the same glossary is included in Ollama prompts only for harder formulas.
@@ -116,17 +147,35 @@ Formula wording is guided by `config/math_glossary.json`. Each symbol can define
2. Install the published script from Greasy Fork, or open the [GitHub raw userscript](https://raw.githubusercontent.com/Yan-ShiBo/LocalReadTranslate/main/tts-userscript.js) for the development version
3. Confirm installation in Tampermonkey
+Editing the repository file does not update a copy already installed in Tampermonkey. See [Tampermonkey development and publishing](#tampermonkey-development-and-publishing) for the local test and release flow.
+
### 5. Use it!
1. Open any webpage
2. **Select text** → floating `Read`, `Translate`, and `Copy` buttons appear
-3. Click `Read` for local English TTS with background formula verbalization, `Translate` for local Ollama translation, or `Copy` to copy the selection while preserving formulas as LaTeX
+3. Click `Read` for local English TTS with background formula verbalization, `Translate` with the selected local or remote Ollama model, or `Copy` to copy the selection while preserving formulas as LaTeX
+4. Open the gear panel for explicit service actions:
+ - **Use project server** refreshes remote options, keeps an already available remote choice or selects the first available remote model, saves it, and checks that model's remote health. It does not establish SSH credentials or a tunnel; configure and connect `Remote Service` in the tray app first.
+ - **Initialize local model** calls Ollama keep-alive for the selected local translation model. Select a local model first; this action does not load Kokoro.
+ - **Start local service** opens `localreadtranslate://start`. After the browser's external-app confirmation, it starts a new tray instance or wakes the existing tray to start FastAPI, then polls `/health` for about 20 seconds. It does not initialize either model by itself.
> ⌨️ Shortcut: `Ctrl+Shift+S` to read selected text directly.
If the floating gear does not appear on a site such as Gemini, first check Tampermonkey and Chrome extension site access for that domain. The script is declared for `*://*/*`, so a missing gear usually means the userscript did not get injected. If the gear appears but selection buttons do not, the page likely uses custom selection DOM; the script also listens to `selectionchange` as a fallback and expands partial formula selections to full math frames where possible. The UI avoids `innerHTML` and related HTML sinks for Trusted Types compatibility.
-## Greasy Fork Publishing
+## Tampermonkey Development and Publishing
+
+For a local pre-push check, open the installed script in Tampermonkey's editor, replace its contents with the complete local `tts-userscript.js`, and save. A repository edit alone cannot change Tampermonkey storage.
+
+The current repository metadata version is `1.13.0`.
+
+For each release:
+
+1. Increment the userscript `@version`; Tampermonkey will not replace an installed copy with the same version.
+2. Run the catalog, Python, JavaScript and metadata checks in [Tests](#-tests).
+3. Commit and push the tested files. Both `@downloadURL` and `@updateURL` point at the raw `main` script, so a versioned push is a userscript release.
+4. Open the [raw userscript](https://raw.githubusercontent.com/Yan-ShiBo/LocalReadTranslate/main/tts-userscript.js), or use Tampermonkey's **Check for updates**, and verify that the installed version matches the repository.
+5. Publish the same script version on Greasy Fork and update its additional information from `docs/greasyfork-additional-info.md`.
The script metadata includes:
@@ -134,7 +183,7 @@ The script metadata includes:
- `@supportURL`: GitHub Issues, shown as the feedback/support link
- `@license`: MIT
-When publishing on Greasy Fork, paste the Markdown from [`docs/greasyfork-additional-info.md`](docs/greasyfork-additional-info.md) into the script's additional info field. The GitHub repository should be linked both through `@homepageURL` and in that additional info section.
+Keep the GitHub repository linked both through `@homepageURL` and in the Greasy Fork additional information. Before announcing a release, verify that the local file, GitHub raw response, Tampermonkey installation and Greasy Fork page show the same version.
## 🎭 Available Voices
@@ -149,6 +198,7 @@ browser script and built-in test page are generated from this catalog.
| `server.py` | FastAPI server with Kokoro TTS inference |
| `audio_encoding.py` | Bundled FFmpeg helpers for OGG/Opus and WebM/Opus |
| `tray_app.py` | System tray application (background mode) |
+| `windows_protocol.py` | Per-user `localreadtranslate://start` registration and validation |
| `windows_startup.py` | Windows Startup shortcut management for tray auto-start |
| `Kokoro TTS.bat` | Recommended tray launcher; does not require `.pyw` file association |
| `Kokoro TTS.pyw` | No-console launcher for tray app |
@@ -160,6 +210,7 @@ browser script and built-in test page are generated from this catalog.
| `requirements-test.txt` | Lightweight CI/test dependencies (no Torch/Kokoro) |
| `config/tts_catalog.json` | Canonical voices, speeds and defaults |
| `scripts/sync_catalog.py` | Synchronizes the catalog into the userscript |
+| `docs/iteration-4-2026-07-18.md` | Current service-control and remote-translation release record |
| `.github/workflows/ci.yml` | Windows CI |
## 🔌 API
@@ -219,31 +270,58 @@ Fallback endpoint returning concise spoken English descriptions for formulas tha
### `GET /translate/health?model=translategemma:4b`
-Checks local Ollama without starting a generation. Returns whether Ollama is reachable, whether the model is installed, whether it is currently running, and whether this service has pinned it with keep-alive.
+Checks the selected Ollama source without starting a translation. A plain model name selects local Ollama; `remote::` selects a tray-configured remote source. The response reports source metadata, available model options, installation/running state and whether this service pinned the model.
### `POST /translate/model/keepalive`
-Preloads a local Ollama model and keeps it resident. The browser settings panel uses `keep_alive: -1m` for manual model residency.
+Preloads the selected local or remote Ollama model and keeps it resident. The explicit **Initialize local model** action accepts only a local selection, while the general **Keep loaded** action follows the currently selected source.
### `POST /translate/model/unload`
-Unloads a local Ollama model and removes its keep-alive pin.
+Unloads the selected local or remote Ollama model and removes its source-aware keep-alive pin.
+
+### `GET /health` — API and TTS status
+
+Returns `api_ready` and `tts_model_loaded` separately. A healthy translation-only service can report `api_ready: true`, `tts_model_loaded: false`, `device: null` and no local GPU allocation until the first Read request.
-### `GET /health` — Server status
### `GET /voices` — Available voices
### `GET /` — Built-in test page
+## Troubleshooting
+
+### `Start local service` does not open anything
+
+Run the tray app once or repair the current-user protocol registration:
+
+```powershell
+conda run -n kokoro-tts python windows_protocol.py register
+```
+
+Chrome may ask whether it can open an external application; allow it only when you intentionally clicked the button. If the project folder moved, register again so the absolute handler paths point at the new location. You can always start manually with `Kokoro TTS.bat`.
+
+### The project-server button finds no models
+
+The browser cannot log in to SSH or read credentials. Open the tray menu, configure and connect **Remote Service**, wait for the local API restart, then click **Use project server** again. For Direct API mode, confirm `/api/tags` is reachable directly from this computer without an HTTP proxy.
+
+### Local model initialization fails
+
+Select a non-`remote:` model, make sure local Ollama is running and pull the model first. **Initialize local model** controls the translation model only; Kokoro is loaded by the first **Read** request.
+
## ✅ Tests
```powershell
conda run -n kokoro-tts python -m pytest tests -v
+conda run -n kokoro-tts python -m py_compile server.py audio_encoding.py tray_app.py "Kokoro TTS.pyw" tts_catalog.py windows_protocol.py windows_runtime.py windows_startup.py scripts/sync_catalog.py
+node --check tts-userscript.js
node --test tests/userscript-core.test.cjs
-python scripts/sync_catalog.py --check
-python -c "from audio_encoding import validate_ffmpeg; validate_ffmpeg()"
+conda run -n kokoro-tts python scripts/sync_catalog.py --check
+conda run -n kokoro-tts python -c "from audio_encoding import validate_ffmpeg; validate_ffmpeg()"
+conda run -n kokoro-tts python -m pip check
+git diff --check
```
The default suite uses a fake pipeline and does not load Kokoro or CUDA.
-The detailed expert review is in `docs/expert-review-2026-06-15.md`.
+The current release record is in [`docs/iteration-4-2026-07-18.md`](docs/iteration-4-2026-07-18.md); the original expert review remains in `docs/expert-review-2026-06-15.md` as history.
## License
diff --git a/docs/greasyfork-additional-info.md b/docs/greasyfork-additional-info.md
index ca2a278..ddbceac 100644
--- a/docs/greasyfork-additional-info.md
+++ b/docs/greasyfork-additional-info.md
@@ -16,15 +16,17 @@ Keep the GitHub links in both places: metadata makes them appear in Greasy Fork'
选中网页上的文本后,可以直接:
- `Read`:英文含公式时先读正文,同时后台处理公式;播放到公式处如果还没处理好再等待,然后继续交给 Kokoro TTS 朗读
-- `Translate`:调用本机 Ollama 模型翻译,默认 `translategemma:4b`
+- `Translate`:默认调用本机 Ollama,也可以明确选择由 Windows 托盘程序配置的项目服务器模型
- `Copy`:不翻译,只复制选中原文;MathJax/MathML/KaTeX 公式会尽量扩展到完整公式框并复制为 LaTeX
- UI 使用原生 DOM API 构建,不使用 `innerHTML` 等 HTML 字符串注入,以兼容 Gemini 等启用 Trusted Types 的页面
- 在设置面板里切换并保存声音、语速、翻译模型和目标语言
-- 查看本地 TTS 服务与 Ollama 模型状态
+- 使用 **Use project server** 选择可用的项目服务器模型,使用 **Initialize local model** 初始化所选本机 Ollama 模型
+- 本地 API 尚未运行时,使用 **Start local service** 打开固定的 `localreadtranslate://start` 操作,再等待服务就绪
+- 分别查看本地 API、按需加载的 TTS 与本机/项目服务器 Ollama 模型状态
- 在 Translation 设置栏手动常驻或卸载当前 Ollama 模型;频繁使用时减少首次加载等待,不用时释放显存
- 英文会尽量原样保留,中文会翻成英文;英文含公式的朗读会优先开始正文,公式在后台变成英文口语描述
- MathJax/MathML/LaTeX 会优先提取语义公式;翻译结果会把公式渲染为带上下标的易读公式,而不是显示原始 LaTeX 代码
-- 翻译请求可附带附近正文作为本地参考上下文,只用于术语和指代消歧;真正翻译和输出的只有选中内容
+- 翻译请求可附带附近正文作为参考上下文,只用于术语和指代消歧;真正翻译和输出的只有选中内容;选择远程模型时该上下文也会发送到对应服务器
- 上下文长度会按模型大小自动裁剪:4B 模型翻译和公式朗读不参考上下文,9B/14B/更大模型会逐级保留更多上下文
- `qwen3:14b`、QwQ、DeepSeek-R1 等推理模型会通过 Ollama `think: false` 关闭思考过程,降低翻译和朗读准备延迟
- 选择 4B 模型时,常见公式会优先使用本地保守字面读法,例如 `D_I` 读作 `D sub I`,`\hat{B}(x)` 读作 `B hat of x`
@@ -36,11 +38,11 @@ Keep the GitHub links in both places: metadata makes them appear in Greasy Fork'
## 重要:需要本地服务
-这个脚本不是单独安装就能工作的云端脚本。它只负责浏览器里的划词按钮和交互,需要你先在电脑上启动本地服务:
+当前用户脚本版本为 `1.13.0`。它不是单独安装就能工作的云端脚本:浏览器端始终需要本项目的本地 FastAPI 中介服务。
-1. 安装并启动本项目的本地 FastAPI 服务
-2. 朗读需要 Kokoro TTS 环境
-3. 翻译需要安装 Ollama,并拉取本地模型,例如:
+1. 按项目 README 完成环境安装,并至少启动本地 FastAPI 服务。
+2. `Read` 需要 Kokoro TTS 环境;Kokoro 会在第一次朗读时按需加载,不会因仅启动 API 或仅使用远程翻译而占用本地 GPU。
+3. 本机翻译需要安装 Ollama 并拉取本地模型,例如:
```powershell
ollama pull translategemma:4b
@@ -48,32 +50,58 @@ ollama pull translategemma:4b
ollama pull qwen3:14b
```
-翻译、朗读稿准备和复杂公式口语化默认都使用 `translategemma:4b`。可在服务端通过 `OLLAMA_TRANSLATE_MODEL`、`OLLAMA_READ_MODEL`、`OLLAMA_FORMULA_MODEL` 覆盖,也可在脚本设置里切换当前翻译/朗读准备模型。如果第一次变慢,通常是 Ollama 正在加载模型。设置栏里的 **Keep loaded** 会用 Ollama `keep_alive: -1m` 常驻当前模型,**Unload** 会用 `keep_alive: 0` 卸载模型释放显存。4B 模型的翻译和公式朗读不参考上下文,公式朗读也会优先采用保守字面规则;14B 模型会保留更多上下文。使用 `qwen3:14b`、QwQ、DeepSeek-R1 等推理模型时,服务端会自动向 Ollama 传入 `think: false`,让翻译和朗读准备直接输出结果。
+4. 如果只使用项目服务器翻译,本机可以不安装 Ollama;但必须从托盘菜单 `Remote Service` 保存并连接服务器,然后在网页设置中点击 **Use project server**。
+
+托盘程序会为当前 Windows 用户注册 `localreadtranslate://start`。如果 **Start local service** 无法唤起托盘程序,在项目目录执行:
+
+```powershell
+conda run -n kokoro-tts python windows_protocol.py register
+```
+
+注册记录使用绝对路径;移动项目后需要重新执行。注册在 `HKCU` 下,不需要管理员权限。网页发起协议操作时,浏览器可能要求确认打开外部应用;协议只支持固定的 `start` 操作,不携带远程凭据或模型参数。
+
+翻译、朗读稿准备和复杂公式口语化默认使用 `translategemma:4b`。可在服务端通过 `OLLAMA_TRANSLATE_MODEL`、`OLLAMA_READ_MODEL`、`OLLAMA_FORMULA_MODEL` 覆盖,也可在脚本设置里切换模型。设置栏里的 **Keep loaded** 会在当前模型所属来源上常驻模型,**Unload** 会从同一来源卸载模型释放显存。4B 模型的翻译和公式朗读不参考上下文,公式朗读也会优先采用保守字面规则;14B 模型会保留更多上下文。使用 `qwen3:14b`、QwQ、DeepSeek-R1 等推理模型时,服务端会自动向 Ollama 传入 `think: false`。
数学符号读法可在项目的 `config/math_glossary.json` 中调整,当前覆盖箭头、上下标、集合、逻辑、求和、积分、偏导等常见论文符号。
## 隐私说明
-脚本只请求本机地址:
+浏览器脚本只请求本机地址:
```text
http://127.0.0.1:5000
```
-选中文本不会被发送到外部云端服务。朗读和翻译都在你的电脑本地完成。
+浏览器不会获得 SSH 密码、密钥路径或远程 Ollama 地址。本机模型模式下,朗读、翻译和允许的上下文都留在本机;当你明确选择项目服务器模型时,选中文本和允许的附近上下文会由本地 FastAPI 中介发送到你配置的服务器。
+
+远程连接支持 SSH 隧道和 Direct API:
+
+- SSH 模式优先使用 SSH agent、默认密钥或指定密钥文件,只有密钥认证失败且填写了密码时才回退到密码认证。客户端加载系统/OpenSSH 主机密钥,并拒绝 `known_hosts` 中不存在的主机。
+- 如果填写了 SSH 密码,它会以明文保存在 Git 已忽略的 `tray_settings.json` 中。请保护 Windows 账户和项目目录,优先使用 agent/密钥,并且不要同步、提交或分享该文件。
+- Direct API 只支持原生 Ollama API;当前实现不会添加 API key 或其他认证请求头。常见 `http://` 地址传输不加密,只应在可信局域网或 VPN 内使用,不应直接暴露到公网。
+- Ollama 请求会绕过环境中的 HTTP 代理,避免局域网请求和选中文本经过无关代理。
+- SSH 主机身份采用失败即关闭策略:客户端调用 `load_system_host_keys()` 并使用 Paramiko `RejectPolicy`。未知主机必须先通过可信渠道核对指纹,再加入 `known_hosts`;本机的 `10.12.96.203` 已按此策略实机重连成功。
## 常见问题
### 安装后没有反应
-先确认本地服务已启动:
+先点击设置面板中的 **Start local service**,接受浏览器的外部应用确认,然后检查:
```text
http://127.0.0.1:5000/health
```
-如果打不开,先运行项目里的 `start.bat` 或推荐的托盘启动器 `Kokoro TTS.bat`。
+如果打不开,先运行推荐的托盘启动器 `Kokoro TTS.bat`,或用上面的 `windows_protocol.py register` 命令修复协议。`start.bat` 只启动裸 FastAPI,不负责远程 SSH 隧道或协议唤起;需要项目服务器时必须使用托盘程序。
新版 `start.bat` 和 `Kokoro TTS.bat` 会直接定位 `kokoro-tts` 环境里的 Python,不需要先执行 `conda init`;`Kokoro TTS.pyw` 只在 Windows 已有关联 `.pyw` 到 Python 时适合双击。
+### **Use project server** 提示没有可用模型
+
+从托盘菜单打开 `Remote Service`,保存并连接服务器,确认检查成功后再回网页点击该按钮。远程主机和凭据不能在油猴脚本里配置。
+
+### **Initialize local model** 提示不能初始化远端模型
+
+这个按钮只用于本机 Ollama。先在模型列表选择一个本机模型并确保 Ollama 正在运行;远端模型请先在托盘中连接,再使用 **Use project server**、**Keep loaded** 或普通翻译请求。
+
### 翻译健康检测失败
通常是浏览器脚本已更新,但本地后台服务还没重启到最新版。重启本地服务后再刷新网页。
@@ -82,6 +110,10 @@ http://127.0.0.1:5000/health
Ollama 第一次使用某个模型时需要把模型加载到 GPU/内存,之后同一模型会快很多。
+## 更新与发布
+
+仓库中的 `tts-userscript.js` 与浏览器已安装副本是两份文件。发布者应递增 `@version`,运行项目测试并推送,确认 GitHub Raw 地址返回新版本,再从 Tampermonkey 执行“检查用户脚本更新”;Greasy Fork 也必须发布同一个版本号和本附加说明。仅修改本地仓库不会自动更新浏览器脚本。
+
## 项目地址
- GitHub: https://github.com/Yan-ShiBo/LocalReadTranslate
diff --git a/docs/iteration-4-2026-07-18.md b/docs/iteration-4-2026-07-18.md
new file mode 100644
index 0000000..a6bc815
--- /dev/null
+++ b/docs/iteration-4-2026-07-18.md
@@ -0,0 +1,100 @@
+# Iteration 4 Release Record — 2026-07-18
+
+**Status:** Implementation and release verification complete.
+
+## Release Goal
+
+Make local/remote translation source control explicit in the userscript, add a one-click Windows path for starting the local mediator, keep remote credentials out of the browser, and document the security and update boundaries accurately.
+
+## Shipped Behavior
+
+### Browser Controls
+
+The userscript is version `1.13.0` and continues to call only `http://127.0.0.1:5000`. Its settings panel now exposes three distinct actions:
+
+- **Use project server** selects an available `remote:` model, persists it, and checks its health. If the local service has no remote choices, the UI directs the user to configure and connect `Remote Service` in the tray.
+- **Initialize local model** accepts only a local model reference and sends a source-aware keepalive request. It does not attempt to initialize a remote model.
+- **Start local service** opens the fixed `localreadtranslate://start` action and polls local health for about 20 seconds.
+
+The local/remote boundary is explicit: an unavailable selected local model remains selected and reports an error. The script never switches to a `remote:` model because of an ordinary health or translation failure; remote processing begins only after **Use project server** or a manual remote selection.
+
+### Windows Protocol and Tray Ownership
+
+`windows_protocol.py` registers or repairs `localreadtranslate://start` under the current user's `HKCU\Software\Classes\localreadtranslate` tree. Registration needs no administrator rights and stores quoted absolute paths to `pythonw.exe` and `tray_app.py`. Moving the checkout or environment requires:
+
+```powershell
+conda run -n kokoro-tts python windows_protocol.py register
+```
+
+The application recognizes only the exact `start` action as a protocol start request; alternate hosts/actions, extra paths, query strings, and fragments are not interpreted. It does not transport a server address, credentials, model selection, shell fragment, or arbitrary command.
+
+The tray repairs registration during ordinary startup. A Windows named mutex prevents duplicate tray instances. A first protocol invocation launches the tray and server; when the tray already exists, the new process signals a named auto-reset event and exits, and the existing tray starts its server.
+
+The tray owns remote settings, SSH/API validation, tunnel lifecycle, hidden FastAPI launch, and the credential-free `KOKORO_OLLAMA_SOURCES` environment. `start.bat` remains a bare FastAPI launch and does not provide a remote SSH tunnel.
+
+### Source-Aware Ollama Routing
+
+Plain Ollama model names continue to target local Ollama. Remote choices use `remote::` internally. Model discovery, translation, read preparation, formula verbalization, health, keepalive, unload, and pinned-model state resolve the same source-aware reference.
+
+The browser receives display labels and model references, not remote connection details. The server receives only each source's id, display name, and effective base URL; SSH credentials remain in the tray process and settings file.
+
+All Ollama requests bypass ambient HTTP proxy settings. This keeps loopback and trusted-LAN traffic from being redirected through an unrelated proxy.
+
+### Lazy Local TTS
+
+FastAPI startup no longer implies Kokoro initialization. Kokoro loads on the first TTS request, while translation-only use—including project-server translation—can run without allocating local TTS GPU memory. `/health` separates API readiness from `tts_model_loaded`.
+
+## Security and Privacy Boundary
+
+- In local-model mode, selected text and allowed context remain on the local machine.
+- After the user selects a project-server model, selected text and allowed context are sent by the local mediator to that configured server.
+- The userscript never receives SSH passwords, key paths, remote hosts, or remote Ollama URLs.
+- An optional SSH password is persisted as plaintext in the Git-ignored `tray_settings.json`. Users should prefer SSH agent/key authentication, protect the Windows account and project directory, and never sync, commit, or share this file.
+- SSH host identity is fail-closed: the client loads system/OpenSSH host keys and uses Paramiko `RejectPolicy`. Unknown hosts must be added to `known_hosts` only after out-of-band fingerprint verification. The configured `10.12.96.203` entry exists on this machine and a real reconnect succeeded.
+- Direct API mode targets the native Ollama API and does not add API-key or other authentication headers. Plain HTTP is unencrypted; this mode is for a trusted LAN or VPN, never a publicly exposed Ollama port.
+- Any page can ask a browser to open a registered external protocol. The browser confirmation is therefore a user-consent boundary even though this protocol's only accepted operation is the fixed `start` action.
+
+## Tampermonkey Release Flow
+
+The repository copy and the browser-installed copy are separate. For each userscript release:
+
+1. Load the current `tts-userscript.js` into Tampermonkey and validate the affected pages.
+2. Increment `@version`; an installed script will not update to a different file with the same version.
+3. Run the full Python and Node verification suites plus syntax, dependency, FFmpeg, voice-catalog, and diff checks.
+4. Push the tested commit and confirm the GitHub Raw URL serves the new version.
+5. Run Tampermonkey's **Check for updates** and confirm the installed version.
+6. Publish the same version and updated additional information on Greasy Fork.
+
+## Verification
+
+Automated release commands:
+
+```powershell
+conda run -n kokoro-tts python -m pytest tests -v
+conda run -n kokoro-tts python -m py_compile server.py audio_encoding.py tray_app.py "Kokoro TTS.pyw" tts_catalog.py windows_protocol.py windows_runtime.py windows_startup.py scripts/sync_catalog.py
+node --check tts-userscript.js
+node --test tests/userscript-core.test.cjs
+conda run -n kokoro-tts python scripts/sync_catalog.py --check
+conda run -n kokoro-tts python -c "from audio_encoding import validate_ffmpeg; validate_ffmpeg()"
+conda run -n kokoro-tts python -m pip check
+git diff --check
+```
+
+Final automated results: **Python 196 passed + 15 subtests; Node 33/33 passed.**
+
+Completed real-machine checks:
+
+- first invocation of `localreadtranslate://start` launched the tray/server path;
+- invoking the protocol while the tray was already running woke the existing tray through the named event without creating a second instance;
+- `/health` returned an API-ready response with `tts_model_loaded=false`, confirming lazy TTS startup;
+- SSH reconnected to `10.12.96.203` with the system-known-hosts + `RejectPolicy` policy;
+- the remote source health check reported `qwen3:30b` available;
+- regression tests confirmed that a failed SSH reconnect cannot publish a stale persisted forwarding port and that an unrelated HTTP service on port 5000 cannot satisfy the userscript's Kokoro health check.
+
+## Documentation Updated
+
+- `README.md` — architecture, startup, remote security, userscript controls, update flow, troubleshooting, and verification.
+- `说明.md` — the same operational contract in Chinese.
+- `docs/greasyfork-additional-info.md` — install-time requirements, explicit controls, privacy boundaries, and user-facing troubleshooting.
+- `docs/superpowers/specs/2026-06-27-remote-ollama-service-design.md` — current protocol, tray, routing, authentication, and error contracts.
+- Historical iteration 3 and remote-service implementation plans — archive warnings added without rewriting their recorded versions, paths, or expected counts.
diff --git a/docs/superpowers/plans/2026-06-16-iteration-3-streaming-opus-autostart.md b/docs/superpowers/plans/2026-06-16-iteration-3-streaming-opus-autostart.md
index fe6c0e6..03ccef7 100644
--- a/docs/superpowers/plans/2026-06-16-iteration-3-streaming-opus-autostart.md
+++ b/docs/superpowers/plans/2026-06-16-iteration-3-streaming-opus-autostart.md
@@ -1,5 +1,8 @@
# Kokoro TTS Third Iteration Implementation Plan
+> [!WARNING]
+> **Archived implementation plan.** This file preserves the original RED/GREEN steps, user-specific paths, dependency versions, and expected test counts for historical traceability; do not execute it against the current checkout. The former `D:/local-tts-env` path and embedded interpreter paths are historical. Use the current [README](../../../README.md) and [iteration 4 release record](../../iteration-4-2026-07-18.md) instead.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add continuous WebM/Opus streaming, complete OGG/Opus responses, honest playback progress, tray login auto-start, and full boundary/concurrency coverage while preserving WAV compatibility.
diff --git a/docs/superpowers/plans/2026-06-27-remote-ollama-service.md b/docs/superpowers/plans/2026-06-27-remote-ollama-service.md
index 7d63205..7ec947e 100644
--- a/docs/superpowers/plans/2026-06-27-remote-ollama-service.md
+++ b/docs/superpowers/plans/2026-06-27-remote-ollama-service.md
@@ -1,5 +1,8 @@
# Remote Ollama Service Implementation Plan
+> [!WARNING]
+> **Archived implementation plan.** This file preserves the original RED/GREEN steps, paths, versions, and expected test counts for historical traceability; do not execute it against the current checkout. The former `D:/local-tts-env` path no longer describes this project. Use the current [README](../../../README.md), [remote-service design](../specs/2026-06-27-remote-ollama-service-design.md), and [iteration 4 release record](../../iteration-4-2026-07-18.md) instead.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a tray-configured remote Ollama source so the browser can choose either local Ollama models or models exposed through an SSH tunnel to a LAN server.
diff --git a/docs/superpowers/specs/2026-06-27-remote-ollama-service-design.md b/docs/superpowers/specs/2026-06-27-remote-ollama-service-design.md
index 06878f1..7f5b172 100644
--- a/docs/superpowers/specs/2026-06-27-remote-ollama-service-design.md
+++ b/docs/superpowers/specs/2026-06-27-remote-ollama-service-design.md
@@ -1,157 +1,164 @@
# Remote Ollama Service Design
+**Status:** Implemented and verified on 2026-07-18. This document describes the current contract; the original task-by-task implementation plan is archived separately.
+
## Goal
-Add an optional remote Ollama source for translation-related models while keeping the existing local Ollama path working. The user configures the remote server from the Windows tray app, then the browser model selector shows both local models and remote server models.
+Keep local Ollama as the default translation source while allowing the user to opt into a project-server Ollama source configured by the Windows tray app. The browser must continue to use only the loopback FastAPI service and must never receive remote credentials.
## User Flow
-1. The user right-clicks the Kokoro TTS tray icon and opens `Remote Service`.
-2. A small settings window lets the user enter:
- - server display name
- - server IP or host
- - SSH port, default `22`
- - username
- - password
- - remote Ollama host and port, default `127.0.0.1:11434`
-3. The user clicks connect.
-4. The tray app opens an SSH tunnel from a local ephemeral port to the remote server's Ollama endpoint.
-5. The local FastAPI service discovers models from both the local Ollama and configured remote sources.
-6. The browser settings panel model selector shows grouped options for local models and the remote server name.
-7. Translation, read preparation, formula verbalization, keep loaded, unload, and health checks use the selected source.
+1. Launch the Kokoro TTS tray app. It creates or repairs the current-user `localreadtranslate://start` protocol registration and starts the local FastAPI service.
+2. To use a remote source, open the tray menu's `Remote Service` dialog, choose `ssh` or `api`, enter the connection details, and click connect.
+3. The tray app validates the remote native Ollama `/api/tags` endpoint. SSH mode creates a loopback tunnel; Direct API mode validates the configured base URL directly.
+4. The tray restarts its owned FastAPI process with a credential-free `KOKORO_OLLAMA_SOURCES` payload.
+5. The browser health response exposes grouped local and remote model choices.
+6. The userscript settings panel offers three explicit actions:
+ - **Use project server** selects an already available remote choice, or the first available remote choice, persists it, and checks health. If none exists, the user is directed back to the tray dialog.
+ - **Initialize local model** rejects `remote:` references and sends a local keepalive request for the selected local model.
+ - **Start local service** opens the fixed `localreadtranslate://start` action and polls loopback health for about 20 seconds.
+7. Translation, read preparation, formula verbalization, keepalive, unload, and translation health resolve the selected model's source before making an Ollama request.
+
+## Trust and Process Boundaries
-## Architecture
+```text
+Web page + userscript
+ |
+ | HTTP only to 127.0.0.1:5000
+ v
+Local FastAPI mediator
+ |-- local model reference ------> local Ollama
+ `-- remote:: ----> tray-provided tunnel or Direct API
+
+localreadtranslate://start
+ `-------------------------------> Windows tray app
+```
-The browser userscript continues to talk only to the local Kokoro TTS API at `127.0.0.1:5000`. It never receives the remote SSH password.
+The userscript never sees an SSH password, key path, remote host, or remote Ollama base URL. Local mode keeps selected text and permitted context on the machine. When a remote model is selected, the local mediator sends the selected text and permitted context to that configured project server.
-The tray app owns remote connection setup because it already manages background process state and local settings. It saves the remote connection profile in `tray_settings.json` and starts the local server with environment variables that point to the active remote source configuration.
+The tray owns the remote connection and process lifecycle because it already owns hidden FastAPI startup, login auto-start, settings persistence, and the system-tray UI. A bare `start.bat` launch does not establish an SSH tunnel and cannot provide a tray-managed remote source.
-The FastAPI server adds source-aware Ollama routing. Existing model names such as `translategemma:4b` keep using local Ollama. Remote model choices are represented with an internal source prefix, for example:
+Kokoro is lazy-loaded on the first TTS request. Starting the API or translating through a remote model does not initialize Torch/Kokoro or allocate local TTS GPU memory. `/health` reports API readiness separately from `tts_model_loaded`.
+
+## Windows Start Protocol
+
+The only supported URL is:
```text
-remote::qwen3:14b
+localreadtranslate://start
```
-API responses include display metadata so the browser can render friendly labels such as `Lab Server / qwen3:14b`.
+`windows_protocol.py` registers the handler under `HKCU\Software\Classes\localreadtranslate`, so registration is per-user and does not require administrator privileges. The command stores quoted absolute paths to the environment's `pythonw.exe` and this checkout's `tray_app.py`; moving the environment or project requires re-registration:
-## Components
+```powershell
+conda run -n kokoro-tts python windows_protocol.py register
+```
-### Tray App
+The matching `windows_protocol.py unregister` command removes only this exact current-user protocol tree.
-Add a `Remote Service` menu item. It opens a basic Tkinter dialog for configuration and connection. The dialog validates required fields and tests the tunnel by calling `/api/tags` through the local forwarded port.
+The protocol parser rejects query strings, fragments, extra paths, and any action other than `start`. A web page can still prompt the browser to open a registered external application, so the browser's confirmation dialog remains an important user-consent boundary.
-The tray app should store remote settings under a new `remote_ollama` key in `tray_settings.json`:
+The tray enforces a single instance with a Windows named mutex. A first protocol launch starts the tray and its server. If the tray is already running, the second invocation signals a named auto-reset event; the existing tray instance starts the server without creating another tray process.
+
+The protocol carries no host, credentials, model name, shell fragment, or arbitrary command.
+
+## Tray Configuration
+
+The `remote_ollama` object in the Git-ignored `tray_settings.json` has this shape:
```json
{
- "enabled": true,
- "name": "Lab Server",
- "host": "192.168.1.10",
+ "enabled": false,
+ "name": "10.12.96.203",
+ "connection_mode": "ssh",
+ "host": "10.12.96.203",
"ssh_port": 22,
- "username": "user",
- "password": "password",
+ "username": "test",
+ "password": "",
+ "key_file": "",
"ollama_host": "127.0.0.1",
"ollama_port": 11434,
- "local_port": 0
+ "local_port": 0,
+ "base_url": "http://10.12.96.203:11434"
}
```
-`local_port: 0` means choose an available local port automatically. The tray app keeps the SSH tunnel process alive while the Kokoro app is running and restarts the local server after connection changes.
+`local_port: 0` requests an ephemeral loopback port. After an SSH connection succeeds, the selected port may be persisted as the preferred bind for the next connection, but FastAPI receives it only while the current tray process has a live tunnel on that runtime port. A failed reconnect never publishes a stale persisted port. The environment passed to FastAPI contains only a source id, display name, and effective base URL; it omits the SSH host, username, password, and key path.
-### Server
+The optional SSH password is stored as plaintext in `tray_settings.json`. Git ignores this file, but that is not encryption. Users should protect the Windows account and project directory, prefer an agent or key file, and never sync, commit, or share the settings file.
-Add an Ollama source abstraction with:
+## SSH Mode
-- source id
-- display name
-- base URL
-- whether the source is local or remote
+Authentication follows Paramiko's key-first behavior: an explicit key or matching OpenSSH configuration, then SSH agent/default keys, with the configured password available only as a fallback in the same connection attempt.
-Default source:
+Host identity is fail-closed. The client calls `load_system_host_keys()` and uses `RejectPolicy`, so a host absent from the user's known-hosts database is rejected rather than silently trusted. The deployed `10.12.96.203` host is present in this machine's `known_hosts`, and a real reconnection succeeded with this policy.
-```text
-local -> http://127.0.0.1:11434
-```
+The tunnel binds only to local loopback and forwards to the configured remote Ollama host/port, normally `127.0.0.1:11434` on the server. The tray keeps the tunnel alive for the lifetime of the app and stops it on disconnect or exit.
-Remote sources are loaded from environment JSON passed by the tray app, for example `KOKORO_OLLAMA_SOURCES`.
+## Direct API Mode
-The existing Ollama helper functions gain an optional source-aware model reference. They resolve `remote::` into the selected source and clean model name before calling `/api/generate`, `/api/tags`, or `/api/ps`.
+Direct API mode accepts a native Ollama base URL and validates `/api/tags`. It does not add API-key or other authentication headers. An ordinary `http://` URL is unencrypted, so this mode is intended only for a trusted LAN or VPN and must not be used to expose an unauthenticated Ollama port to the public internet.
-Pinned models are tracked by full model reference instead of plain model name, so a local `qwen3:14b` and remote `qwen3:14b` do not collide.
+Both tray validation and server Ollama requests use proxy-free openers. This prevents loopback or trusted-LAN requests, including selected page text, from being redirected through ambient HTTP proxy settings.
-### Browser Userscript
+## Server Routing
-The settings panel keeps the current local custom model behavior. It also uses the health response's available model metadata to append remote model options.
-
-The selected model value can be either:
+The FastAPI service always defines the local source:
```text
-qwen3:14b
-remote:lab-server:qwen3:14b
+local -> http://127.0.0.1:11434
```
-The browser sends that value unchanged to existing endpoints.
-
-## API Changes
-
-`GET /translate/health?model=...` remains backward-compatible.
-
-The response adds optional fields:
+The tray may add remote sources through `KOKORO_OLLAMA_SOURCES`, for example:
```json
-{
- "source": "local",
- "source_name": "Local Ollama",
- "available_model_options": [
- {
- "value": "translategemma:4b",
- "label": "Local Ollama / translategemma:4b",
- "source": "local",
- "source_name": "Local Ollama",
- "model": "translategemma:4b"
- },
- {
- "value": "remote:lab-server:qwen3:14b",
- "label": "Lab Server / qwen3:14b",
- "source": "lab-server",
- "source_name": "Lab Server",
- "model": "qwen3:14b"
- }
- ]
-}
+[
+ {
+ "id": "project-server",
+ "name": "Project Server",
+ "base_url": "http://127.0.0.1:49152"
+ }
+]
```
-Existing `available_models` remains a list of local-compatible strings for older userscripts.
-
-## Error Handling
+Plain model names remain local and backward-compatible. A remote choice uses an internal reference:
-If the remote SSH tunnel cannot connect, the tray dialog shows a concise error and leaves the previous working settings unchanged.
+```text
+remote::
+```
-If the local server starts without a working remote source, local Ollama still works. Health checks for remote selections return `ollama_reachable: false` and do not expose passwords or detailed connection strings.
+For example, `remote:project-server:qwen3:30b` resolves to model `qwen3:30b` at the project-server source. The source-aware helpers route `/api/generate`, `/api/tags`, `/api/ps`, keepalive, and unload consistently. Pinned models are keyed by the full reference, so local and remote models with the same Ollama name do not collide.
-Translation endpoints continue returning generic 502 errors. Logs may include source names but not passwords.
+`GET /translate/health?model=...` remains backward-compatible and adds source metadata plus `available_model_options`. `available_models` remains a list of local-compatible model strings for older userscripts. Health and API errors do not expose passwords or detailed connection strings.
-## Testing
+If a selected local model is unavailable, the browser keeps that explicit local selection and reports the problem; it never crosses the local/remote boundary automatically. The user must click **Use project server** or manually choose a `remote:` entry before selected text can be routed to a project server.
-Add server tests for:
+## Error Handling
-- parsing local and remote model references
-- listing model options across local and remote sources
-- routing generate, tags, ps, keepalive, and unload requests to the selected source
-- pinned model identity including source id
-- backward compatibility for plain local model names
+- A failed tray connection leaves the previous working settings and tunnel intact and shows a concise dialog error.
+- An unknown SSH host key fails closed; the user must add the verified host key to the system/OpenSSH known-hosts database before reconnecting.
+- If FastAPI starts without a working remote source, local Ollama remains usable.
+- Remote health failures return `ollama_reachable: false` without leaking credentials.
+- Translation endpoints continue to use generic upstream failure responses; logs may contain source display names but never passwords.
+- Protocol registration failure is non-fatal to ordinary tray startup. The CLI registration command provides an explicit repair path.
-Add tray tests for:
+## Verification Contract
-- default remote settings shape
-- saving remote settings without breaking existing voice, speed, and auto-start settings
-- building a remote source environment payload without including unrelated fields
+The release is covered by server, tray, protocol, Windows-runtime, and userscript tests for:
-Add userscript tests for:
+- local/remote model parsing, discovery, source routing, keepalive, unload, and pinned identity;
+- remote settings persistence and credential-free environment construction;
+- rejection of stale persisted SSH forwarding ports when no live tunnel exists;
+- SSH agent/key/password behavior, strict known-host rejection, Direct API validation, and proxy bypass;
+- exact protocol parsing, HKCU registration/repair, quoted commands, single-instance signaling, and existing-tray wakeup;
+- remote option merging, selection persistence, and the three settings-panel actions.
+- strict userscript `/health` identity/readiness validation before the local service is marked online.
-- appending remote model options from health metadata
-- preserving selected remote model values when saving settings
+The 2026-07-18 release verification completed **Python 196 passed + 15 subtests** and **Node 33/33 passed**. Real-machine checks covered first protocol launch, existing-tray event wakeup, `/health` with `tts_model_loaded=false`, a strict-host-key SSH reconnect, and remote `qwen3:30b` health availability.
## Non-Goals
-This change does not install or configure Ollama on the remote server. It also does not add public HTTP authentication for Ollama. The intended remote path is SSH login to a LAN server and forwarding to that server's native Ollama API.
+- Installing or configuring Ollama on the project server.
+- Public Ollama exposure or Direct API authentication.
+- Passing remote credentials or arbitrary commands through the browser or URL protocol.
+- Loading Kokoro during API startup or translation-only use.
diff --git a/server.py b/server.py
index 1c5483d..6716345 100644
--- a/server.py
+++ b/server.py
@@ -299,6 +299,7 @@ def _math_glossary_prompt(lang: str = "zh", max_symbols: int = 40) -> str:
pipeline = None
british_pipeline = None
inference_lock = asyncio.Lock()
+_tts_model_load_lock = threading.Lock()
actual_device = None
@@ -309,6 +310,84 @@ def resolve_device(device_cfg: str) -> str:
return device_cfg
+def _tts_model_is_loaded() -> bool:
+ return pipeline is not None and british_pipeline is not None
+
+
+def _load_tts_model() -> None:
+ """Load and warm the local Kokoro pipelines without publishing partial state."""
+ global pipeline, british_pipeline, actual_device
+
+ if torch is None:
+ raise RuntimeError("PyTorch is required to start the TTS model")
+
+ selected_device = resolve_device(DEVICE)
+
+ print()
+ print("=" * 60)
+ print("[LOADING] Kokoro TTS model...")
+ print(f" Device: {selected_device}")
+ if selected_device == "cuda":
+ gpu_name = torch.cuda.get_device_name(0)
+ gpu_mem = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
+ print(f" GPU: {gpu_name} ({gpu_mem:.1f} GB)")
+ print(f" Default voice: {VOICE}")
+ print("=" * 60)
+ print()
+
+ t0 = time.time()
+ try:
+ from kokoro import KPipeline
+
+ loaded_pipeline = KPipeline(
+ lang_code="a",
+ repo_id="hexgrad/Kokoro-82M",
+ device=selected_device,
+ )
+ loaded_british_pipeline = KPipeline(
+ lang_code="b",
+ repo_id="hexgrad/Kokoro-82M",
+ model=loaded_pipeline.model,
+ device=selected_device,
+ )
+
+ if WARMUP_ENABLED:
+ print("[WARMUP] Running initial inference...")
+ warmup_started = time.time()
+ warmup_pipeline = (
+ loaded_british_pipeline
+ if VOICE_LANG_CODES[VOICE] == "b"
+ else loaded_pipeline
+ )
+ _run_pipeline_inference(warmup_pipeline, "Hello.", VOICE, DEFAULT_SPEED)
+ print(f"[WARMUP] Done in {time.time() - warmup_started:.2f}s")
+ except ImportError:
+ print("[ERROR] Cannot import kokoro. Please install: pip install kokoro>=0.9.4")
+ raise
+ except Exception as error:
+ print(f"[ERROR] Model loading failed: {error}")
+ raise
+
+ actual_device = selected_device
+ pipeline = loaded_pipeline
+ british_pipeline = loaded_british_pipeline
+
+ print()
+ print("=" * 60)
+ print(f"[OK] Model loaded in {time.time() - t0:.1f}s")
+ print("=" * 60)
+ print()
+
+
+def _ensure_tts_model_loaded() -> None:
+ if _tts_model_is_loaded():
+ return
+ with _tts_model_load_lock:
+ if _tts_model_is_loaded():
+ return
+ _load_tts_model()
+
+
# ════════════════════════════════════════════════════════════════
# 应用生命周期
# ════════════════════════════════════════════════════════════════
@@ -342,66 +421,17 @@ def watchdog_loop():
@asynccontextmanager
async def lifespan(app: FastAPI):
- """应用启动时加载模型,关闭时释放。"""
+ """Prepare the API; load the local TTS model only when TTS is requested."""
global pipeline, british_pipeline, actual_device
_start_watchdog()
validate_ffmpeg()
- if torch is None:
- raise RuntimeError("PyTorch is required to start the TTS model")
- actual_device = resolve_device(DEVICE)
-
print()
print("=" * 60)
- print("[LOADING] Kokoro TTS model...")
- print(f" Device: {actual_device}")
- if actual_device == "cuda":
- gpu_name = torch.cuda.get_device_name(0)
- gpu_mem = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
- print(f" GPU: {gpu_name} ({gpu_mem:.1f} GB)")
- print(f" Default voice: {VOICE}")
- print("=" * 60)
- print()
-
- t0 = time.time()
-
- try:
- from kokoro import KPipeline
-
- # Initialize Kokoro pipeline ('a' = American English)
- pipeline = KPipeline(
- lang_code="a",
- repo_id="hexgrad/Kokoro-82M",
- device=actual_device,
- )
- british_pipeline = KPipeline(
- lang_code="b",
- repo_id="hexgrad/Kokoro-82M",
- model=pipeline.model,
- device=actual_device,
- )
-
- except ImportError:
- print("[ERROR] Cannot import kokoro. Please install: pip install kokoro>=0.9.4")
- raise
- except Exception as e:
- print(f"[ERROR] Model loading failed: {e}")
- raise
-
- if WARMUP_ENABLED:
- print("[WARMUP] Running initial inference...")
- warmup_started = time.time()
- _run_inference("Hello.", VOICE, DEFAULT_SPEED)
- print(f"[WARMUP] Done in {time.time() - warmup_started:.2f}s")
-
- elapsed = time.time() - t0
-
- print()
- print("=" * 60)
- print(f"[OK] Model loaded in {elapsed:.1f}s")
- print(f"[READY] Server: http://{HOST}:{PORT}")
+ print(f"[READY] API server: http://{HOST}:{PORT}")
+ print("[TTS] Kokoro model will load on the first TTS request")
print(f"[TEST] Page: http://{HOST}:{PORT}/")
print(f"[HEALTH] Check: http://{HOST}:{PORT}/health")
print("=" * 60)
@@ -410,10 +440,14 @@ async def lifespan(app: FastAPI):
try:
yield
finally:
- print("[STOP] Releasing model resources...")
- pipeline = None
- british_pipeline = None
- if actual_device == "cuda" and torch:
+ loaded_device = actual_device
+ if _tts_model_is_loaded():
+ print("[STOP] Releasing model resources...")
+ with _tts_model_load_lock:
+ pipeline = None
+ british_pipeline = None
+ actual_device = None
+ if loaded_device == "cuda" and torch:
torch.cuda.empty_cache()
@@ -424,7 +458,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Kokoro TTS 本地服务",
description="本地运行的高质量英文 TTS 服务(Kokoro 82M)",
- version="1.7.13",
+ version="1.7.14",
lifespan=lifespan,
)
@@ -698,9 +732,7 @@ def _combine_audio_segments(
return _apply_fade(full_audio, sample_rate, fade_ms)
-def _run_inference(text: str, voice: str, speed: float):
- """同步执行 Kokoro TTS 推理(在线程池中运行)。"""
- selected_pipeline = _select_pipeline_for_voice(voice)
+def _run_pipeline_inference(selected_pipeline, text: str, voice: str, speed: float):
if selected_pipeline is None:
raise RuntimeError("模型尚未就绪")
@@ -714,6 +746,16 @@ def _run_inference(text: str, voice: str, speed: float):
return _combine_audio_segments(audio_segments), SAMPLE_RATE
+def _run_inference(text: str, voice: str, speed: float):
+ """同步执行 Kokoro TTS 推理(在线程池中运行)。"""
+ return _run_pipeline_inference(
+ _select_pipeline_for_voice(voice),
+ text,
+ voice,
+ speed,
+ )
+
+
def _select_pipeline_for_voice(voice: str):
return british_pipeline if VOICE_LANG_CODES[voice] == "b" else pipeline
@@ -937,6 +979,12 @@ def _normalize_translation_context(
return limited or None
+def _open_ollama_request(request, timeout: float):
+ """Open an Ollama request without leaking local prompts through HTTP proxies."""
+ opener = urllib_request.build_opener(urllib_request.ProxyHandler({}))
+ return opener.open(request, timeout=timeout)
+
+
def _call_ollama_json(
path: str,
timeout: float = 5.0,
@@ -949,7 +997,7 @@ def _call_ollama_json(
method="GET",
)
try:
- with urllib_request.urlopen(req, timeout=timeout) as resp:
+ with _open_ollama_request(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
except urllib_error.HTTPError as error:
raise RuntimeError(f"Ollama returned HTTP {error.code}") from error
@@ -986,7 +1034,7 @@ def _call_ollama_model_keep_alive(model: str, keep_alive: str | int | float):
)
try:
- with urllib_request.urlopen(req, timeout=OLLAMA_TRANSLATE_TIMEOUT) as resp:
+ with _open_ollama_request(req, timeout=OLLAMA_TRANSLATE_TIMEOUT) as resp:
raw = resp.read().decode("utf-8")
except urllib_error.HTTPError as error:
raise RuntimeError(f"Ollama returned HTTP {error.code}") from error
@@ -1608,7 +1656,7 @@ def _call_ollama_formula_verbalization_zh_single(
)
try:
- with urllib_request.urlopen(req, timeout=OLLAMA_TRANSLATE_TIMEOUT) as resp:
+ with _open_ollama_request(req, timeout=OLLAMA_TRANSLATE_TIMEOUT) as resp:
raw = resp.read().decode("utf-8")
except urllib_error.HTTPError as error:
raise RuntimeError(f"Ollama returned HTTP {error.code}") from error
@@ -1712,7 +1760,7 @@ def _call_ollama_translate_raw(
)
try:
- with urllib_request.urlopen(req, timeout=OLLAMA_TRANSLATE_TIMEOUT) as resp:
+ with _open_ollama_request(req, timeout=OLLAMA_TRANSLATE_TIMEOUT) as resp:
raw = resp.read().decode("utf-8")
except urllib_error.HTTPError as error:
raise RuntimeError(f"Ollama returned HTTP {error.code}") from error
@@ -1864,7 +1912,7 @@ def _call_ollama_text_generation(
)
try:
- with urllib_request.urlopen(req, timeout=OLLAMA_TRANSLATE_TIMEOUT) as resp:
+ with _open_ollama_request(req, timeout=OLLAMA_TRANSLATE_TIMEOUT) as resp:
raw = resp.read().decode("utf-8")
except urllib_error.HTTPError as error:
raise RuntimeError(f"Ollama returned HTTP {error.code}") from error
@@ -2160,7 +2208,7 @@ def _call_ollama_formula_verbalization(
)
try:
- with urllib_request.urlopen(req, timeout=OLLAMA_TRANSLATE_TIMEOUT) as resp:
+ with _open_ollama_request(req, timeout=OLLAMA_TRANSLATE_TIMEOUT) as resp:
raw = resp.read().decode("utf-8")
except urllib_error.HTTPError as error:
raise RuntimeError(f"Ollama returned HTTP {error.code}") from error
@@ -2660,6 +2708,7 @@ async def tts_endpoint(
if http_request and await http_request.is_disconnected():
print("[TTS] Client disconnected before acquiring lock, aborting.")
raise HTTPException(status_code=499, detail="Client Closed Request")
+ await asyncio.to_thread(_ensure_tts_model_loaded)
try:
await asyncio.wait_for(inference_lock.acquire(), timeout=1.0)
except asyncio.TimeoutError:
@@ -2730,9 +2779,6 @@ async def tts_stream_endpoint(
voice = request.voice or VOICE
speed = request.speed if request.speed is not None else DEFAULT_SPEED
- selected_pipeline = _select_pipeline_for_voice(voice)
- if selected_pipeline is None:
- raise HTTPException(status_code=500, detail="语音生成失败")
lock_acquired = False
session = None
@@ -2740,6 +2786,10 @@ async def tts_stream_endpoint(
if await http_request.is_disconnected():
print("[TTS] Client disconnected before acquiring lock, aborting.")
raise HTTPException(status_code=499, detail="Client Closed Request")
+ await asyncio.to_thread(_ensure_tts_model_loaded)
+ selected_pipeline = _select_pipeline_for_voice(voice)
+ if selected_pipeline is None:
+ raise RuntimeError("模型尚未就绪")
try:
await asyncio.wait_for(inference_lock.acquire(), timeout=1.0)
lock_acquired = True
@@ -2804,17 +2854,20 @@ async def stream_body():
@app.get("/health")
async def health_check():
"""健康检查端点。"""
+ tts_model_loaded = _tts_model_is_loaded()
return {
"status": "ok",
"service": "kokoro-tts",
"version": app.version,
"pid": os.getpid(),
- "ready": pipeline is not None,
+ "ready": True,
+ "api_ready": True,
+ "tts_model_loaded": tts_model_loaded,
"model": "Kokoro-82M",
- "device": actual_device,
+ "device": actual_device if tts_model_loaded else None,
"gpu": (
torch.cuda.get_device_name(0)
- if torch and actual_device == "cuda"
+ if tts_model_loaded and torch and actual_device == "cuda"
else "N/A"
),
"default_voice": VOICE,
diff --git a/setup.bat b/setup.bat
index 7727177..b53a24b 100644
--- a/setup.bat
+++ b/setup.bat
@@ -12,7 +12,7 @@ echo Kokoro TTS Environment Setup
echo ========================================
echo.
-echo [0/4] Checking prerequisites...
+echo [0/5] Checking prerequisites...
where conda >nul 2>nul
if errorlevel 1 (
echo [ERROR] Conda was not found in PATH.
@@ -30,7 +30,7 @@ if errorlevel 1 (
echo [OK] Conda and eSpeak-NG are available.
echo.
-echo [1/4] Checking Conda environment...
+echo [1/5] Checking Conda environment...
call conda env list | findstr /R /C:"^%ENV_NAME% " >nul
if errorlevel 1 (
echo Creating %ENV_NAME% with Python 3.10...
@@ -44,7 +44,7 @@ if errorlevel 1 (
)
echo.
-echo [2/4] Installing PyTorch 2.6.0 with CUDA 12.4...
+echo [2/5] Installing PyTorch 2.6.0 with CUDA 12.4...
call conda run -n "%ENV_NAME%" python -m pip install ^
torch==2.6.0 torchaudio==2.6.0 ^
--index-url https://download.pytorch.org/whl/cu124
@@ -54,7 +54,7 @@ if errorlevel 1 (
)
echo.
-echo [3/4] Installing project dependencies...
+echo [3/5] Installing project dependencies...
call conda run -n "%ENV_NAME%" python -m pip install -r "%PROJECT_DIR%requirements.txt"
if errorlevel 1 (
echo [ERROR] Project dependency installation failed.
@@ -62,7 +62,7 @@ if errorlevel 1 (
)
echo.
-echo [4/4] Verifying the environment...
+echo [4/5] Verifying the environment...
call conda run -n "%ENV_NAME%" python -m pip check
if errorlevel 1 (
echo [ERROR] Dependency verification failed.
@@ -81,11 +81,21 @@ if errorlevel 1 (
goto :fail
)
+echo.
+echo [5/5] Registering the browser start link for the current user...
+call conda run -n "%ENV_NAME%" python "%PROJECT_DIR%windows_protocol.py" register
+if errorlevel 1 (
+ echo [ERROR] Could not register localreadtranslate://start.
+ echo You can still start the app manually with Kokoro TTS.bat.
+ goto :fail
+)
+
echo.
echo ========================================
echo Setup complete!
-echo Next: double-click start.bat
-echo or Kokoro TTS.pyw
+echo Next: click Start local service in the userscript
+echo or double-click Kokoro TTS.bat.
+echo Re-run setup.bat after moving this project folder.
echo ========================================
echo.
pause
diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py
index 425a32f..e1e47dc 100644
--- a/tests/test_release_metadata.py
+++ b/tests/test_release_metadata.py
@@ -6,8 +6,8 @@
def test_release_versions_are_current():
userscript = Path("tts-userscript.js").read_text(encoding="utf-8")
- assert server.app.version == "1.7.13"
- assert "// @version 1.12.8" in userscript
+ assert server.app.version == "1.7.14"
+ assert "// @version 1.13.0" in userscript
assert "// @name 本地划词听译助手" in userscript
assert "// @license MIT" in userscript
assert "// @homepageURL https://github.com/Yan-ShiBo/LocalReadTranslate" in userscript
diff --git a/tests/test_server.py b/tests/test_server.py
index 1e68c14..48e5184 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -2,9 +2,11 @@
import json
import subprocess
import sys
+import threading
+import types
import unittest
import warnings
-from unittest.mock import patch
+from unittest.mock import Mock, patch
import numpy as np
@@ -50,17 +52,24 @@ def test_lifespan_validates_ffmpeg_before_loading_model(self):
from audio_encoding import AudioEncodingError
async def exercise():
- with patch.object(
- server,
- "validate_ffmpeg",
- create=True,
- side_effect=AudioEncodingError("FFmpeg is unavailable"),
- ):
- async with server.lifespan(server.app):
- pass
+ async with server.lifespan(server.app):
+ pass
+
+ with patch.object(
+ server,
+ "validate_ffmpeg",
+ create=True,
+ side_effect=AudioEncodingError("FFmpeg is unavailable"),
+ ) as validate, patch.object(
+ server,
+ "_load_tts_model",
+ create=True,
+ ) as load_model:
+ with self.assertRaises(AudioEncodingError):
+ asyncio.run(exercise())
- with self.assertRaises(AudioEncodingError):
- asyncio.run(exercise())
+ validate.assert_called_once_with()
+ load_model.assert_not_called()
def test_server_module_can_import_without_torch(self):
script = """
@@ -82,6 +91,86 @@ def guarded_import(name, *args, **kwargs):
)
self.assertEqual(result.returncode, 0, result.stderr)
+ def test_remote_translation_does_not_eagerly_load_local_tts_gpu(self):
+ pipeline_constructions = []
+
+ class FakeCuda:
+ @staticmethod
+ def is_available():
+ return True
+
+ @staticmethod
+ def get_device_name(_index):
+ return "Fake GPU"
+
+ @staticmethod
+ def get_device_properties(_index):
+ return types.SimpleNamespace(total_memory=8 * 1024**3)
+
+ @staticmethod
+ def empty_cache():
+ return None
+
+ class FakeTorch:
+ cuda = FakeCuda()
+
+ class CountingPipeline:
+ def __init__(self, **kwargs):
+ pipeline_constructions.append(kwargs)
+ self.model = kwargs.get("model") or object()
+
+ fake_kokoro = types.SimpleNamespace(KPipeline=CountingPipeline)
+
+ async def exercise():
+ with patch.object(server, "validate_ffmpeg"), patch.object(
+ server, "torch", FakeTorch()
+ ), patch.object(server, "WARMUP_ENABLED", False), patch.dict(
+ sys.modules, {"kokoro": fake_kokoro}
+ ), patch.object(
+ server,
+ "_call_ollama_translate_raw",
+ return_value="远程翻译结果",
+ ):
+ async with server.lifespan(server.app):
+ response = await server.translate_endpoint(
+ server.TranslateRequest(
+ text="Remote translation only",
+ model="remote:lab-server:qwen3:14b",
+ )
+ )
+ self.assertEqual(response.translated_text, "远程翻译结果")
+ self.assertIsNone(server.pipeline)
+ self.assertIsNone(server.british_pipeline)
+
+ asyncio.run(exercise())
+ self.assertEqual(pipeline_constructions, [])
+
+ def test_remote_translation_stays_available_without_local_tts_dependencies(self):
+ async def exercise():
+ with patch.object(server, "validate_ffmpeg") as validate, patch.object(
+ server, "torch", None
+ ), patch.object(server, "pipeline", None), patch.object(
+ server, "british_pipeline", None
+ ), patch.dict(
+ sys.modules, {"kokoro": None}
+ ), patch.object(
+ server,
+ "_call_ollama_translate_raw",
+ return_value="remote translation result",
+ ):
+ async with server.lifespan(server.app):
+ response = await server.translate_endpoint(
+ server.TranslateRequest(
+ text="Remote translation only",
+ model="remote:lab-server:qwen3:14b",
+ )
+ )
+
+ validate.assert_called_once_with()
+ self.assertEqual(response.translated_text, "remote translation result")
+
+ asyncio.run(exercise())
+
def test_combines_segments_with_silence(self):
combined = server._combine_audio_segments(
[
@@ -159,6 +248,7 @@ def setUp(self):
self.original_pipeline = server.pipeline
self.original_british_pipeline = server.british_pipeline
self.original_inference_lock = server.inference_lock
+ self.original_actual_device = server.actual_device
self.original_pinned_ollama_models = set(server.PINNED_OLLAMA_MODELS)
server.PINNED_OLLAMA_MODELS.clear()
server.pipeline = FakePipeline()
@@ -171,6 +261,7 @@ def tearDown(self):
server.pipeline = self.original_pipeline
server.british_pipeline = self.original_british_pipeline
server.inference_lock = self.original_inference_lock
+ server.actual_device = self.original_actual_device
server.PINNED_OLLAMA_MODELS.clear()
server.PINNED_OLLAMA_MODELS.update(self.original_pinned_ollama_models)
self.print_patcher.stop()
@@ -189,6 +280,67 @@ def test_tts_returns_wav_and_uses_requested_settings(self):
[("Hello world", "af_bella", 1.0)],
)
+ def test_first_tts_request_loads_model_on_demand(self):
+ server.pipeline = None
+ server.british_pipeline = None
+ server.actual_device = None
+
+ def fake_load_model():
+ server.pipeline = FakePipeline()
+ server.british_pipeline = FakePipeline()
+ server.actual_device = "cpu"
+
+ with patch.object(
+ server,
+ "_load_tts_model",
+ create=True,
+ side_effect=fake_load_model,
+ ) as load_model:
+ response = self.client.post("/tts", json={"text": "Load once"})
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(response.content.startswith(b"RIFF"))
+ load_model.assert_called_once_with()
+
+ def test_concurrent_first_tts_requests_load_model_once(self):
+ server.pipeline = None
+ server.british_pipeline = None
+ server.actual_device = None
+ original_ensure_loaded = getattr(server, "_ensure_tts_model_loaded", None)
+ callers_ready = threading.Barrier(2)
+
+ def synchronized_ensure_loaded():
+ callers_ready.wait(timeout=2)
+ return original_ensure_loaded()
+
+ def fake_load_model():
+ server.pipeline = FakePipeline()
+ server.british_pipeline = FakePipeline()
+ server.actual_device = "cpu"
+
+ async def exercise():
+ with patch.object(
+ server,
+ "_ensure_tts_model_loaded",
+ create=True,
+ side_effect=synchronized_ensure_loaded,
+ ), patch.object(
+ server,
+ "_load_tts_model",
+ create=True,
+ side_effect=fake_load_model,
+ ) as load_model:
+ responses = await asyncio.gather(
+ server.tts_endpoint(server.TTSRequest(text="First request")),
+ server.tts_endpoint(server.TTSRequest(text="Second request")),
+ )
+ return responses, load_model.call_count
+
+ responses, load_count = asyncio.run(exercise())
+
+ self.assertTrue(all(response.body.startswith(b"RIFF") for response in responses))
+ self.assertEqual(load_count, 1)
+
def test_ogg_query_returns_opus(self):
response = self.client.post(
"/tts?format=ogg",
@@ -493,7 +645,7 @@ def fake_urlopen(request, timeout):
captured["payload"] = json.loads(request.data.decode("utf-8"))
return FakeUrlopenResponse({"response": "只翻译选中内容"})
- with patch.object(server.urllib_request, "urlopen", side_effect=fake_urlopen):
+ with patch.object(server, "_open_ollama_request", side_effect=fake_urlopen):
result = server._call_ollama_translate_raw(
"selected sentence",
"qwen3:14b",
@@ -521,7 +673,7 @@ def fake_urlopen(request, timeout):
captured["payload"] = json.loads(request.data.decode("utf-8"))
return FakeUrlopenResponse({"response": "只翻译选中内容"})
- with patch.object(server.urllib_request, "urlopen", side_effect=fake_urlopen):
+ with patch.object(server, "_open_ollama_request", side_effect=fake_urlopen):
result = server._call_ollama_translate_raw(
"selected sentence",
"qwen3:14b",
@@ -543,7 +695,7 @@ def fake_urlopen(request, timeout):
return FakeUrlopenResponse({"response": "划词朗读"})
try:
- with patch.object(server.urllib_request, "urlopen", side_effect=fake_urlopen):
+ with patch.object(server, "_open_ollama_request", side_effect=fake_urlopen):
result = server._call_ollama_translate_raw(
"Selection read-aloud",
"remote:lab-server:qwen3:30b",
@@ -891,7 +1043,7 @@ def test_formula_verbalize_hides_ollama_errors(self):
self.assertNotIn("secret formula prompt", response.text)
def test_small_model_formula_verbalization_prefers_conservative_rules(self):
- with patch.object(server.urllib_request, "urlopen") as urlopen:
+ with patch.object(server, "_open_ollama_request") as urlopen:
result = server._call_ollama_formula_verbalization(
[r"D_I", r"B_\theta(x)", r"\hat{B}(x)", r"D_w \to \hat{B}(x)"],
"translategemma:4b",
@@ -917,7 +1069,7 @@ def fake_urlopen(request, timeout):
return FakeUrlopenResponse({"response": '["a two row cases expression"]'})
long_context = "near formula context " * 200
- with patch.object(server.urllib_request, "urlopen", side_effect=fake_urlopen):
+ with patch.object(server, "_open_ollama_request", side_effect=fake_urlopen):
result = server._call_ollama_formula_verbalization(
[r"\begin{cases} x & x > 0 \\ -x & x < 0 \end{cases}"],
"translategemma:4b",
@@ -1063,7 +1215,7 @@ def fake_urlopen(request, timeout):
return FakeUrlopenResponse({"response": "remote result"})
try:
- with patch.object(server.urllib_request, "urlopen", side_effect=fake_urlopen):
+ with patch.object(server, "_open_ollama_request", side_effect=fake_urlopen):
result = server._call_ollama_translate_raw(
"Hello",
"remote:lab-server:qwen3:14b",
@@ -1077,6 +1229,31 @@ def fake_urlopen(request, timeout):
self.assertEqual(captured["url"], "http://127.0.0.1:49152/api/generate")
self.assertEqual(captured["payload"]["model"], "qwen3:14b")
+ def test_ollama_requests_bypass_environment_http_proxies(self):
+ response = FakeUrlopenResponse({"models": []})
+ opener = Mock()
+ opener.open.return_value = response
+
+ with patch.object(
+ server.urllib_request,
+ "build_opener",
+ return_value=opener,
+ ) as build_opener, patch.object(
+ server.urllib_request,
+ "urlopen",
+ side_effect=AssertionError("proxy-aware urlopen must not be used"),
+ ):
+ payload = server._call_ollama_json(
+ "/api/tags",
+ base_url="http://10.12.96.203:11434",
+ )
+
+ self.assertEqual(payload, {"models": []})
+ handler = build_opener.call_args.args[0]
+ self.assertIsInstance(handler, server.urllib_request.ProxyHandler)
+ self.assertEqual(handler.proxies, {})
+ opener.open.assert_called_once()
+
def test_remote_pinned_model_generation_requests_keep_alive(self):
original_sources = server.OLLAMA_SOURCES
server.OLLAMA_SOURCES = {
@@ -1101,7 +1278,7 @@ def fake_urlopen(request, timeout):
return FakeUrlopenResponse({"response": "remote result"})
try:
- with patch.object(server.urllib_request, "urlopen", side_effect=fake_urlopen):
+ with patch.object(server, "_open_ollama_request", side_effect=fake_urlopen):
server._call_ollama_translate_raw(
"Hello",
"remote:lab-server:qwen3:14b",
@@ -1362,10 +1539,26 @@ def test_health_identifies_service(self):
self.assertEqual(payload["service"], "kokoro-tts")
self.assertTrue(payload["ready"])
+ self.assertTrue(payload["api_ready"])
+ self.assertTrue(payload["tts_model_loaded"])
self.assertEqual(payload["default_translate_model"], server.OLLAMA_TRANSLATE_MODEL)
self.assertEqual(payload["default_read_model"], server.OLLAMA_READ_MODEL)
self.assertEqual(payload["default_formula_model"], server.OLLAMA_FORMULA_MODEL)
+ def test_health_reports_api_ready_before_tts_model_load(self):
+ server.pipeline = None
+ server.british_pipeline = None
+ server.actual_device = None
+
+ response = self.client.get("/health")
+ payload = response.json()
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(payload["ready"])
+ self.assertTrue(payload["api_ready"])
+ self.assertFalse(payload["tts_model_loaded"])
+ self.assertIsNone(payload["device"])
+
def test_openapi_declares_wav_response(self):
content = server.app.openapi()["paths"]["/tts"]["post"]["responses"]["200"][
"content"
diff --git a/tests/test_streaming.py b/tests/test_streaming.py
index b3c6cf0..1ee5c01 100644
--- a/tests/test_streaming.py
+++ b/tests/test_streaming.py
@@ -174,7 +174,7 @@ def test_stream_endpoint_returns_webm_headers(monkeypatch):
FakeWebMEncoder.instances = []
fake_pipeline = FakePipeline()
monkeypatch.setattr(server, "pipeline", fake_pipeline)
- monkeypatch.setattr(server, "british_pipeline", FakePipeline())
+ monkeypatch.setattr(server, "british_pipeline", fake_pipeline)
monkeypatch.setattr(
server,
"WebMOpusEncoder",
@@ -194,7 +194,9 @@ def test_stream_endpoint_returns_webm_headers(monkeypatch):
def test_stream_endpoint_rejects_format_query(monkeypatch):
- monkeypatch.setattr(server, "pipeline", FakePipeline())
+ fake_pipeline = FakePipeline()
+ monkeypatch.setattr(server, "pipeline", fake_pipeline)
+ monkeypatch.setattr(server, "british_pipeline", fake_pipeline)
client = TestClient(server.app)
response = client.post("/tts/stream?format=ogg", json={"text": "Hello"})
@@ -203,7 +205,9 @@ def test_stream_endpoint_rejects_format_query(monkeypatch):
def test_stream_endpoint_returns_429_when_lock_is_busy(monkeypatch):
- monkeypatch.setattr(server, "pipeline", FakePipeline())
+ fake_pipeline = FakePipeline()
+ monkeypatch.setattr(server, "pipeline", fake_pipeline)
+ monkeypatch.setattr(server, "british_pipeline", fake_pipeline)
async def exercise():
await server.inference_lock.acquire()
diff --git a/tests/test_tray_app.py b/tests/test_tray_app.py
index f056ac4..9621b8f 100644
--- a/tests/test_tray_app.py
+++ b/tests/test_tray_app.py
@@ -3,7 +3,7 @@
import unittest
from types import SimpleNamespace
from pathlib import Path
-from unittest.mock import Mock, patch
+from unittest.mock import Mock, mock_open, patch
import tray_app
from windows_startup import StartupShortcutError
@@ -15,8 +15,14 @@ def make_app(self):
tray_app,
"find_conda_python",
return_value=Path(sys.executable),
- ), patch.object(tray_app, "reconcile_startup_shortcut"):
- return tray_app.TrayApp()
+ ), patch.object(tray_app, "reconcile_startup_shortcut"), patch.object(
+ tray_app.TrayApp,
+ "_init_and_reconcile_auto_start",
+ ):
+ return tray_app.TrayApp(
+ start_background_tasks=False,
+ enable_windows_protocol=False,
+ )
def test_external_server_cannot_be_stopped_by_tray(self):
app = self.make_app()
@@ -67,8 +73,14 @@ def make_app(self):
tray_app,
"find_conda_python",
return_value=Path(sys.executable),
- ), patch.object(tray_app, "reconcile_startup_shortcut"):
- return tray_app.TrayApp()
+ ), patch.object(tray_app, "reconcile_startup_shortcut"), patch.object(
+ tray_app.TrayApp,
+ "_init_and_reconcile_auto_start",
+ ):
+ return tray_app.TrayApp(
+ start_background_tasks=False,
+ enable_windows_protocol=False,
+ )
def test_default_settings_include_auto_start_disabled(self):
with patch.object(
@@ -150,14 +162,143 @@ def __init__(self, *items):
self.assertTrue(item.kwargs["checked"](item))
+class TrayProtocolLaunchTests(unittest.TestCase):
+ def test_constructor_can_disable_registry_and_background_threads(self):
+ with patch.object(
+ tray_app,
+ "find_conda_python",
+ return_value=Path(sys.executable),
+ ), patch.object(
+ tray_app,
+ "ensure_start_protocol_registered",
+ ) as register, patch.object(tray_app.threading, "Thread") as thread:
+ tray_app.TrayApp(
+ start_background_tasks=False,
+ enable_windows_protocol=False,
+ )
+
+ register.assert_not_called()
+ thread.assert_not_called()
+
+ def test_normal_tray_start_registers_protocol_and_opens_start_event(self):
+ pythonw = Path(r"C:\Conda Env\pythonw.exe")
+ event = Mock()
+ event.create.return_value = event
+ created_threads = []
+
+ class FakeThread:
+ def __init__(self, target=None, daemon=None):
+ self.target = target
+ self.daemon = daemon
+ created_threads.append(self)
+
+ def start(self):
+ return None
+
+ with patch.object(
+ tray_app,
+ "find_conda_python",
+ return_value=Path(sys.executable),
+ ), patch.object(
+ tray_app,
+ "find_conda_pythonw",
+ return_value=pythonw,
+ ), patch.object(
+ tray_app,
+ "ensure_start_protocol_registered",
+ ) as register, patch.object(
+ tray_app,
+ "WindowsNamedAutoResetEvent",
+ return_value=event,
+ ) as event_class, patch.object(
+ tray_app.threading,
+ "Thread",
+ FakeThread,
+ ):
+ tray_app.TrayApp(
+ start_background_tasks=True,
+ enable_windows_protocol=True,
+ )
+
+ register.assert_called_once_with(pythonw, tray_app.SCRIPT_DIR / "tray_app.py")
+ event_class.assert_called_once_with(tray_app.START_SERVER_EVENT_NAME)
+ event.create.assert_called_once_with()
+ self.assertGreaterEqual(len(created_threads), 2)
+
+ def test_primary_tray_starts_server_when_protocol_event_is_signaled(self):
+ with patch.object(
+ tray_app,
+ "find_conda_python",
+ return_value=Path(sys.executable),
+ ):
+ app = tray_app.TrayApp(
+ start_background_tasks=False,
+ enable_windows_protocol=False,
+ )
+ app.start_server = Mock(side_effect=app._protocol_listener_stop.set)
+ app._start_server_event = Mock()
+ app._start_server_event.wait.return_value = True
+
+ app._listen_for_start_server_requests()
+
+ app._start_server_event.wait.assert_called_once_with(timeout_ms=500)
+ app.start_server.assert_called_once_with()
+
+ def test_quit_releases_protocol_listener_resources(self):
+ with patch.object(
+ tray_app,
+ "find_conda_python",
+ return_value=Path(sys.executable),
+ ):
+ app = tray_app.TrayApp(
+ start_background_tasks=False,
+ enable_windows_protocol=False,
+ )
+ app._start_server_event = Mock()
+ app._stop_remote_ollama_tunnel = Mock()
+ app.stop_server = Mock()
+
+ with patch.object(tray_app.os, "_exit"):
+ app.quit_app()
+
+ self.assertTrue(app._protocol_listener_stop.is_set())
+ app._start_server_event.close.assert_called_once_with()
+
+ def test_second_protocol_launch_signals_primary_tray_and_exits(self):
+ mutex = Mock()
+ mutex.acquire.return_value = False
+
+ with patch.object(
+ tray_app,
+ "WindowsNamedMutex",
+ return_value=mutex,
+ ), patch.object(
+ tray_app.WindowsNamedAutoResetEvent,
+ "signal_existing",
+ return_value=True,
+ ) as signal_existing, patch.object(tray_app, "TrayApp") as tray_class:
+ result = tray_app.main(["localreadtranslate://start"])
+
+ self.assertEqual(result, 0)
+ signal_existing.assert_called_once_with(tray_app.START_SERVER_EVENT_NAME)
+ tray_class.assert_not_called()
+ mutex.close.assert_not_called()
+
+
class TrayRemoteOllamaTests(unittest.TestCase):
def make_app(self):
with patch.object(
tray_app,
"find_conda_python",
return_value=Path(sys.executable),
- ), patch.object(tray_app, "reconcile_startup_shortcut"):
- return tray_app.TrayApp()
+ ), patch.object(tray_app, "reconcile_startup_shortcut"), patch.object(
+ tray_app.TrayApp,
+ "_init_and_reconcile_auto_start",
+ ):
+ return tray_app.TrayApp(
+ start_background_tasks=False,
+ enable_windows_protocol=False,
+ )
def test_default_settings_include_remote_ollama(self):
with patch.object(
@@ -171,17 +312,177 @@ def test_default_settings_include_remote_ollama(self):
settings["remote_ollama"],
{
"enabled": False,
- "name": "",
- "host": "",
+ "name": "10.12.96.203",
+ "connection_mode": "ssh",
+ "host": "10.12.96.203",
"ssh_port": 22,
- "username": "",
+ "username": "test",
"password": "",
+ "key_file": "",
"ollama_host": "127.0.0.1",
"ollama_port": 11434,
"local_port": 0,
+ "base_url": "http://10.12.96.203:11434",
+ },
+ )
+
+ def test_legacy_remote_settings_are_migrated_without_losing_values(self):
+ legacy = {
+ "remote_ollama": {
+ "enabled": True,
+ "name": "Old Server",
+ "host": "192.168.1.10",
+ "ssh_port": 2222,
+ "username": "alice",
+ "password": "secret",
+ "ollama_host": "127.0.0.1",
+ "ollama_port": 11434,
+ "local_port": 49152,
+ }
+ }
+ fake_path = Mock()
+ fake_path.exists.return_value = True
+
+ with patch.object(tray_app, "SETTINGS_FILE", fake_path), patch(
+ "builtins.open",
+ mock_open(read_data=json.dumps(legacy)),
+ ):
+ settings = tray_app.load_settings()
+
+ remote = settings["remote_ollama"]
+ self.assertEqual(remote["host"], "192.168.1.10")
+ self.assertEqual(remote["username"], "alice")
+ self.assertEqual(remote["password"], "secret")
+ self.assertEqual(remote["connection_mode"], "ssh")
+ self.assertEqual(remote["key_file"], "")
+ self.assertEqual(remote["base_url"], "http://192.168.1.10:11434")
+
+ def _start_tunnel_with_fake_paramiko(self, settings, ssh_config=None):
+ connect_calls = []
+ transport = Mock()
+ transport.is_active.return_value = True
+ client = Mock()
+ client.get_transport.return_value = transport
+ client.connect.side_effect = lambda **kwargs: connect_calls.append(kwargs)
+ reject_policy = object()
+ fake_paramiko = SimpleNamespace(
+ SSHClient=Mock(return_value=client),
+ AutoAddPolicy=Mock(return_value=object()),
+ RejectPolicy=Mock(return_value=reject_policy),
+ )
+
+ class FakeForwardServer:
+ allow_reuse_address = True
+ daemon_threads = True
+
+ def __init__(self, _address, _handler):
+ self.server_address = ("127.0.0.1", 49152)
+
+ def serve_forever(self):
+ return None
+
+ def shutdown(self):
+ return None
+
+ def server_close(self):
+ return None
+
+ class FakeThread:
+ def __init__(self, target=None, daemon=None):
+ self.target = target
+ self.daemon = daemon
+
+ def start(self):
+ return None
+
+ with patch.dict(sys.modules, {"paramiko": fake_paramiko}), patch.object(
+ tray_app.socketserver,
+ "ThreadingTCPServer",
+ FakeForwardServer,
+ ), patch.object(tray_app.threading, "Thread", FakeThread), patch.object(
+ tray_app,
+ "_load_openssh_host_config",
+ return_value=ssh_config or {},
+ create=True,
+ ):
+ tunnel = tray_app.RemoteOllamaTunnel(settings)
+ tunnel.start()
+ tunnel.stop()
+
+ client.load_system_host_keys.assert_called_once_with()
+ client.set_missing_host_key_policy.assert_called_once_with(reject_policy)
+ fake_paramiko.AutoAddPolicy.assert_not_called()
+ return connect_calls
+
+ def test_remote_tunnel_uses_openssh_key_and_agent_without_password(self):
+ calls = self._start_tunnel_with_fake_paramiko(
+ {
+ "host": "10.12.96.203",
+ "username": "test",
+ "ssh_port": 22,
+ "password": "",
+ "key_file": "",
+ },
+ ssh_config={
+ "hostname": "10.12.96.203",
+ "user": "test",
+ "identityfile": [r"C:\Users\YanShibo\.ssh\ai_server_key"],
},
)
+ self.assertEqual(len(calls), 1)
+ kwargs = calls[0]
+ self.assertEqual(kwargs["hostname"], "10.12.96.203")
+ self.assertEqual(kwargs["username"], "test")
+ self.assertEqual(
+ kwargs["key_filename"],
+ r"C:\Users\YanShibo\.ssh\ai_server_key",
+ )
+ self.assertTrue(kwargs["look_for_keys"])
+ self.assertTrue(kwargs["allow_agent"])
+ self.assertNotIn("password", kwargs)
+
+ def test_explicit_key_file_and_password_are_passed_for_paramiko_fallback(self):
+ calls = self._start_tunnel_with_fake_paramiko(
+ {
+ "host": "10.12.96.203",
+ "username": "test",
+ "password": "secret",
+ "key_file": r"D:\keys\explicit_ed25519",
+ },
+ ssh_config={
+ "identityfile": [r"C:\Users\YanShibo\.ssh\config_key"],
+ },
+ )
+
+ self.assertEqual(len(calls), 1)
+ kwargs = calls[0]
+ self.assertEqual(kwargs["key_filename"], r"D:\keys\explicit_ed25519")
+ self.assertEqual(kwargs["password"], "secret")
+ self.assertTrue(kwargs["look_for_keys"])
+ self.assertTrue(kwargs["allow_agent"])
+
+ def test_openssh_config_loader_looks_up_the_requested_host(self):
+ parsed_config = Mock()
+ parsed_config.lookup.return_value = {
+ "user": "test",
+ "identityfile": [r"C:\Users\YanShibo\.ssh\ai_server_key"],
+ }
+ fake_paramiko = SimpleNamespace(SSHConfig=Mock(return_value=parsed_config))
+
+ with patch.object(Path, "is_file", return_value=True), patch(
+ "builtins.open",
+ mock_open(read_data="Host ai-server 10.12.96.203\n"),
+ ):
+ result = tray_app._load_openssh_host_config(
+ fake_paramiko,
+ "10.12.96.203",
+ )
+
+ parsed_config.parse.assert_called_once()
+ parsed_config.lookup.assert_called_once_with("10.12.96.203")
+ self.assertEqual(result["user"], "test")
+
def test_remote_source_env_omits_password(self):
app = self.make_app()
app.settings["remote_ollama"] = {
@@ -211,6 +512,272 @@ def test_remote_source_env_omits_password(self):
)
self.assertNotIn("secret", payload)
+ def test_ssh_source_env_does_not_publish_a_stale_persisted_port(self):
+ app = self.make_app()
+ app.settings["remote_ollama"] = {
+ **tray_app.default_remote_ollama_settings(),
+ "enabled": True,
+ "connection_mode": "ssh",
+ "name": "Project Server",
+ "local_port": 49152,
+ }
+ app.remote_tunnel = None
+ app.remote_tunnel_local_port = None
+
+ self.assertEqual(app.build_remote_ollama_sources_env(), "")
+
+ def test_direct_api_source_env_does_not_require_a_tunnel(self):
+ app = self.make_app()
+ app.settings["remote_ollama"] = {
+ "enabled": True,
+ "name": "AI Server",
+ "connection_mode": "api",
+ "host": "10.12.96.203",
+ "base_url": "http://10.12.96.203:11434/",
+ }
+ app.remote_tunnel_local_port = None
+
+ payload = json.loads(app.build_remote_ollama_sources_env())
+
+ self.assertEqual(
+ payload,
+ [
+ {
+ "id": "ai-server",
+ "name": "AI Server",
+ "base_url": "http://10.12.96.203:11434",
+ }
+ ],
+ )
+
+ def test_direct_api_probe_calls_api_tags(self):
+ response = Mock()
+ response.status = 200
+ response.read.return_value = b'{"models": []}'
+ response.__enter__ = Mock(return_value=response)
+ response.__exit__ = Mock(return_value=False)
+ opener = Mock()
+ opener.open.return_value = response
+
+ with patch.object(
+ tray_app.urllib.request,
+ "build_opener",
+ return_value=opener,
+ ) as build_opener, patch.object(
+ tray_app.urllib.request,
+ "urlopen",
+ side_effect=AssertionError("proxy-aware urlopen must not be used"),
+ ):
+ app = self.make_app()
+ app._test_remote_ollama_api("http://10.12.96.203:11434/")
+
+ handler = build_opener.call_args.args[0]
+ self.assertIsInstance(handler, tray_app.urllib.request.ProxyHandler)
+ self.assertEqual(handler.proxies, {})
+ opener.open.assert_called_once_with(
+ "http://10.12.96.203:11434/api/tags",
+ timeout=5,
+ )
+
+ def test_connect_direct_api_does_not_create_an_ssh_tunnel(self):
+ app = self.make_app()
+ previous = dict(app.settings["remote_ollama"])
+ candidate = {
+ **previous,
+ "enabled": True,
+ "name": "AI Server",
+ "connection_mode": "api",
+ "host": "10.12.96.203",
+ "base_url": "http://10.12.96.203:11434",
+ }
+ app.restart_server = Mock()
+
+ with patch.object(app, "_test_remote_ollama_api") as probe, patch.object(
+ tray_app,
+ "RemoteOllamaTunnel",
+ ) as tunnel_class, patch.object(tray_app, "save_settings") as save:
+ app.connect_remote_ollama(candidate)
+
+ probe.assert_called_once_with("http://10.12.96.203:11434")
+ tunnel_class.assert_not_called()
+ self.assertEqual(
+ app.settings["remote_ollama"],
+ {**candidate, "local_port": 0},
+ )
+ self.assertIsNone(app.remote_tunnel)
+ self.assertIsNone(app.remote_tunnel_local_port)
+ save.assert_called_once_with(app.settings)
+ app.restart_server.assert_called_once()
+
+ def test_failed_connection_preserves_previous_working_settings_and_tunnel(self):
+ app = self.make_app()
+ previous = {
+ **tray_app.default_remote_ollama_settings(),
+ "enabled": True,
+ "name": "Working Server",
+ "host": "192.168.1.10",
+ "local_port": 49152,
+ }
+ app.settings["remote_ollama"] = dict(previous)
+ old_tunnel = Mock()
+ app.remote_tunnel = old_tunnel
+ app.remote_tunnel_local_port = 49152
+ app.restart_server = Mock()
+ candidate = {
+ **previous,
+ "name": "Broken Server",
+ "host": "10.12.96.203",
+ "local_port": 0,
+ }
+ failed_tunnel = Mock()
+ failed_tunnel.start.side_effect = RuntimeError("authentication failed")
+
+ with patch.object(
+ tray_app,
+ "RemoteOllamaTunnel",
+ return_value=failed_tunnel,
+ ), patch.object(tray_app, "save_settings") as save:
+ with self.assertRaisesRegex(RuntimeError, "authentication failed"):
+ app.connect_remote_ollama(candidate)
+
+ self.assertEqual(app.settings["remote_ollama"], previous)
+ self.assertIs(app.remote_tunnel, old_tunnel)
+ self.assertEqual(app.remote_tunnel_local_port, 49152)
+ old_tunnel.stop.assert_not_called()
+ failed_tunnel.stop.assert_called_once()
+ save.assert_not_called()
+ app.restart_server.assert_not_called()
+
+ def test_startup_connection_failure_does_not_disable_saved_profile(self):
+ app = self.make_app()
+ saved = {
+ **tray_app.default_remote_ollama_settings(),
+ "enabled": True,
+ "host": "10.12.96.203",
+ }
+ app.settings["remote_ollama"] = dict(saved)
+ app.show_error = Mock()
+ failed_tunnel = Mock()
+ failed_tunnel.start.side_effect = RuntimeError("offline")
+
+ with patch.object(
+ tray_app,
+ "RemoteOllamaTunnel",
+ return_value=failed_tunnel,
+ ), patch.object(tray_app, "save_settings") as save:
+ connected = app.ensure_remote_ollama_tunnel()
+
+ self.assertFalse(connected)
+ self.assertEqual(app.settings["remote_ollama"], saved)
+ self.assertTrue(app.settings["remote_ollama"]["enabled"])
+ save.assert_not_called()
+ app.show_error.assert_called_once_with("Remote Service", "offline")
+
+ def test_remote_dialog_saves_mode_key_file_and_direct_api_url(self):
+ app = self.make_app()
+ created_entries = []
+ created_mode_vars = []
+ option_menus = []
+ buttons = {}
+
+ class FakeWidget:
+ def __init__(self, _parent=None, **kwargs):
+ self.value = ""
+ self.command = kwargs.get("command")
+ self.text = kwargs.get("text")
+
+ def grid(self, **_kwargs):
+ return self
+
+ def pack(self, **_kwargs):
+ return self
+
+ def insert(self, _index, value):
+ self.value = str(value)
+
+ def get(self):
+ return self.value
+
+ def focus_set(self):
+ return None
+
+ def config(self, **_kwargs):
+ return None
+
+ class FakeStringVar:
+ def __init__(self, value=""):
+ self.value = value
+ created_mode_vars.append(self)
+
+ def get(self):
+ return self.value
+
+ def set(self, value):
+ self.value = value
+
+ class FakeWindow(FakeWidget):
+ def title(self, _value):
+ return None
+
+ def resizable(self, _width, _height):
+ return None
+
+ def after(self, _delay, _callback):
+ return None
+
+ def focus_force(self):
+ return None
+
+ def update_idletasks(self):
+ return None
+
+ def destroy(self):
+ return None
+
+ def mainloop(self):
+ created_mode_vars[0].set("api")
+ created_entries[5].value = r"D:\keys\ai_server_ed25519"
+ created_entries[8].value = "http://10.12.96.203:11434/"
+ buttons["Save"].command()
+
+ def make_entry(parent=None, **kwargs):
+ entry = FakeWidget(parent, **kwargs)
+ created_entries.append(entry)
+ return entry
+
+ def make_option_menu(parent, variable, *values):
+ option_menus.append((variable, values))
+ return FakeWidget(parent)
+
+ def make_button(parent=None, **kwargs):
+ button = FakeWidget(parent, **kwargs)
+ buttons[button.text] = button
+ return button
+
+ fake_tkinter = SimpleNamespace(
+ Tk=FakeWindow,
+ Label=FakeWidget,
+ Entry=make_entry,
+ StringVar=FakeStringVar,
+ OptionMenu=make_option_menu,
+ Frame=FakeWidget,
+ Button=make_button,
+ messagebox=SimpleNamespace(showerror=Mock(), showinfo=Mock()),
+ )
+
+ with patch.dict(sys.modules, {"tkinter": fake_tkinter}), patch.object(
+ tray_app,
+ "save_settings",
+ ) as save:
+ app._run_remote_service_settings_dialog()
+
+ self.assertEqual(option_menus[0][1], ("ssh", "api"))
+ remote = app.settings["remote_ollama"]
+ self.assertEqual(remote["connection_mode"], "api")
+ self.assertEqual(remote["key_file"], r"D:\keys\ai_server_ed25519")
+ self.assertEqual(remote["base_url"], "http://10.12.96.203:11434")
+ save.assert_called_once_with(app.settings)
+
def test_remote_service_dialog_opens_on_background_thread(self):
app = self.make_app()
started = []
diff --git a/tests/test_windows_protocol.py b/tests/test_windows_protocol.py
new file mode 100644
index 0000000..d661c41
--- /dev/null
+++ b/tests/test_windows_protocol.py
@@ -0,0 +1,203 @@
+import unittest
+from io import StringIO
+from pathlib import Path
+
+from windows_protocol import (
+ PROTOCOL_REGISTRY_PATH,
+ ProtocolRegistrationError,
+ build_start_protocol_command,
+ ensure_start_protocol_registered,
+ is_start_protocol_url,
+ main as protocol_main,
+ unregister_start_protocol,
+)
+
+
+class FakeRegistry:
+ HKEY_CURRENT_USER = "HKCU"
+ REG_SZ = 1
+ KEY_READ = 0x20019
+ KEY_WRITE = 0x20006
+
+ class Key:
+ def __init__(self, registry, path):
+ self.registry = registry
+ self.path = path
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return False
+
+ def __init__(self):
+ self.values = {}
+ self.keys = set()
+
+ def CreateKeyEx(self, root, path, _reserved=0, _access=0):
+ self.assert_root(root)
+ parts = path.split("\\")
+ for index in range(1, len(parts) + 1):
+ self.keys.add("\\".join(parts[:index]))
+ return self.Key(self, path)
+
+ def QueryValueEx(self, key, name):
+ lookup = (key.path, name)
+ if lookup not in self.values:
+ raise FileNotFoundError(lookup)
+ return self.values[lookup], self.REG_SZ
+
+ def SetValueEx(self, key, name, _reserved, _kind, value):
+ self.values[(key.path, name)] = value
+
+ def DeleteKey(self, root, path):
+ self.assert_root(root)
+ if path not in self.keys:
+ raise FileNotFoundError(path)
+ prefix = path + "\\"
+ if any(key.startswith(prefix) for key in self.keys):
+ raise OSError(f"key has children: {path}")
+ self.keys.remove(path)
+ self.values = {
+ key: value
+ for key, value in self.values.items()
+ if key[0] != path
+ }
+
+ def assert_root(self, root):
+ if root != self.HKEY_CURRENT_USER:
+ raise AssertionError(f"unexpected registry root: {root}")
+
+
+class NonPersistingRegistry(FakeRegistry):
+ def SetValueEx(self, _key, _name, _reserved, _kind, _value):
+ return None
+
+
+class WindowsProtocolCommandTests(unittest.TestCase):
+ def test_command_quotes_python_script_and_protocol_url(self):
+ command = build_start_protocol_command(
+ Path(r"C:\Users\Example User\.conda\envs\kokoro-tts\pythonw.exe"),
+ Path(r"D:\Local Read Translate\tray_app.py"),
+ )
+
+ self.assertEqual(
+ command,
+ '"C:\\Users\\Example User\\.conda\\envs\\kokoro-tts\\pythonw.exe" '
+ '"D:\\Local Read Translate\\tray_app.py" "%1"',
+ )
+
+ def test_registration_creates_per_user_url_protocol_values(self):
+ registry = FakeRegistry()
+
+ changed = ensure_start_protocol_registered(
+ Path(r"C:\Conda Env\pythonw.exe"),
+ Path(r"D:\LocalReadTranslate\tray_app.py"),
+ registry=registry,
+ platform_name="nt",
+ )
+
+ command_path = PROTOCOL_REGISTRY_PATH + r"\shell\open\command"
+ self.assertTrue(changed)
+ self.assertEqual(
+ registry.values[(PROTOCOL_REGISTRY_PATH, "")],
+ "URL:LocalReadTranslate Protocol",
+ )
+ self.assertEqual(registry.values[(PROTOCOL_REGISTRY_PATH, "URL Protocol")], "")
+ self.assertEqual(
+ registry.values[(command_path, "")],
+ '"C:\\Conda Env\\pythonw.exe" '
+ '"D:\\LocalReadTranslate\\tray_app.py" "%1"',
+ )
+
+ def test_registration_fails_when_written_values_cannot_be_verified(self):
+ with self.assertRaisesRegex(ProtocolRegistrationError, "verify"):
+ ensure_start_protocol_registered(
+ Path(r"C:\Conda Env\pythonw.exe"),
+ Path(r"D:\LocalReadTranslate\tray_app.py"),
+ registry=NonPersistingRegistry(),
+ platform_name="nt",
+ )
+
+ def test_only_start_url_is_recognized_as_a_start_request(self):
+ self.assertTrue(is_start_protocol_url("localreadtranslate://start"))
+ self.assertTrue(is_start_protocol_url("LOCALREADTRANSLATE://START/"))
+ self.assertFalse(is_start_protocol_url("localreadtranslate://settings"))
+ self.assertFalse(is_start_protocol_url("https://start"))
+
+ def test_register_cli_defaults_to_current_pythonw_and_sibling_tray_script(self):
+ registry = FakeRegistry()
+ output = StringIO()
+
+ result = protocol_main(
+ ["register"],
+ platform_name="nt",
+ registry=registry,
+ executable=Path(r"C:\Conda Env\python.exe"),
+ module_path=Path(r"D:\LocalReadTranslate\windows_protocol.py"),
+ stdout=output,
+ )
+
+ command_path = PROTOCOL_REGISTRY_PATH + r"\shell\open\command"
+ self.assertEqual(result, 0)
+ self.assertEqual(
+ registry.values[(command_path, "")],
+ '"C:\\Conda Env\\pythonw.exe" '
+ '"D:\\LocalReadTranslate\\tray_app.py" "%1"',
+ )
+ self.assertIn(r"HKCU\Software\Classes\localreadtranslate", output.getvalue())
+
+ def test_unregister_removes_only_the_per_user_protocol_tree_and_is_idempotent(self):
+ registry = FakeRegistry()
+ ensure_start_protocol_registered(
+ Path(r"C:\Conda Env\pythonw.exe"),
+ Path(r"D:\LocalReadTranslate\tray_app.py"),
+ registry=registry,
+ platform_name="nt",
+ )
+ registry.keys.add(r"Software\Classes\unrelated")
+ registry.values[(r"Software\Classes\unrelated", "")] = "keep"
+
+ self.assertTrue(
+ unregister_start_protocol(registry=registry, platform_name="nt")
+ )
+ self.assertFalse(
+ unregister_start_protocol(registry=registry, platform_name="nt")
+ )
+
+ self.assertFalse(
+ any(
+ key == PROTOCOL_REGISTRY_PATH
+ or key.startswith(PROTOCOL_REGISTRY_PATH + "\\")
+ for key in registry.keys
+ )
+ )
+ self.assertEqual(
+ registry.values[(r"Software\Classes\unrelated", "")],
+ "keep",
+ )
+
+ def test_unregister_cli_removes_registered_handler(self):
+ registry = FakeRegistry()
+ ensure_start_protocol_registered(
+ Path(r"C:\Conda Env\pythonw.exe"),
+ Path(r"D:\LocalReadTranslate\tray_app.py"),
+ registry=registry,
+ platform_name="nt",
+ )
+ output = StringIO()
+
+ result = protocol_main(
+ ["unregister"],
+ platform_name="nt",
+ registry=registry,
+ stdout=output,
+ )
+
+ self.assertEqual(result, 0)
+ self.assertNotIn(PROTOCOL_REGISTRY_PATH, registry.keys)
+ self.assertIn("removed", output.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_windows_runtime.py b/tests/test_windows_runtime.py
new file mode 100644
index 0000000..00e2877
--- /dev/null
+++ b/tests/test_windows_runtime.py
@@ -0,0 +1,84 @@
+import unittest
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+from windows_runtime import WindowsNamedAutoResetEvent
+
+
+def fake_kernel32():
+ return SimpleNamespace(
+ CreateEventW=Mock(return_value=101),
+ OpenEventW=Mock(return_value=202),
+ SetEvent=Mock(return_value=True),
+ WaitForSingleObject=Mock(return_value=0),
+ CloseHandle=Mock(return_value=True),
+ )
+
+
+class WindowsNamedAutoResetEventTests(unittest.TestCase):
+ def test_create_uses_auto_reset_and_initially_unsignaled_flags(self):
+ kernel32 = fake_kernel32()
+ event = WindowsNamedAutoResetEvent(
+ r"Local\LocalReadTranslate.StartServer",
+ kernel32=kernel32,
+ platform_name="nt",
+ )
+
+ event.create()
+
+ kernel32.CreateEventW.assert_called_once_with(
+ None,
+ False,
+ False,
+ r"Local\LocalReadTranslate.StartServer",
+ )
+ event.close()
+
+ def test_signal_existing_wakes_primary_event_without_creating_another(self):
+ kernel32 = fake_kernel32()
+
+ signaled = WindowsNamedAutoResetEvent.signal_existing(
+ r"Local\LocalReadTranslate.StartServer",
+ kernel32=kernel32,
+ platform_name="nt",
+ )
+
+ self.assertTrue(signaled)
+ kernel32.OpenEventW.assert_called_once_with(
+ 0x0002,
+ False,
+ r"Local\LocalReadTranslate.StartServer",
+ )
+ kernel32.SetEvent.assert_called_once_with(202)
+ kernel32.CloseHandle.assert_called_once_with(202)
+ kernel32.CreateEventW.assert_not_called()
+
+ def test_wait_reports_a_signal_to_the_primary_listener(self):
+ kernel32 = fake_kernel32()
+ event = WindowsNamedAutoResetEvent(
+ r"Local\LocalReadTranslate.StartServer",
+ kernel32=kernel32,
+ platform_name="nt",
+ ).create()
+
+ self.assertTrue(event.wait(timeout_ms=250))
+
+ kernel32.WaitForSingleObject.assert_called_once_with(101, 250)
+ event.close()
+
+ def test_primary_can_signal_its_event_to_stop_the_listener(self):
+ kernel32 = fake_kernel32()
+ event = WindowsNamedAutoResetEvent(
+ r"Local\LocalReadTranslate.StartServer",
+ kernel32=kernel32,
+ platform_name="nt",
+ ).create()
+
+ self.assertTrue(event.set())
+
+ kernel32.SetEvent.assert_called_once_with(101)
+ event.close()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/userscript-core.test.cjs b/tests/userscript-core.test.cjs
index 4b97236..3abdf12 100644
--- a/tests/userscript-core.test.cjs
+++ b/tests/userscript-core.test.cjs
@@ -451,3 +451,219 @@ test("model option merge includes remote health metadata", () => {
{ value: "remote:lab-server:qwen3:14b", label: "Lab Server / qwen3:14b" },
]);
});
+
+test("translation model fallback never opts into a remote model silently", () => {
+ const { chooseTranslationModelFallback } = require("../tts-userscript.js");
+
+ const selected = chooseTranslationModelFallback(
+ {
+ available_model_options: [
+ {
+ value: "remote:project-server:qwen3:14b",
+ label: "Project Server / qwen3:14b",
+ source: "project-server",
+ model: "qwen3:14b",
+ },
+ ],
+ },
+ "translategemma:4b",
+ "translategemma:4b"
+ );
+
+ assert.equal(selected, "translategemma:4b");
+});
+
+test("translation model fallback preserves an available remote selection", () => {
+ const { chooseTranslationModelFallback } = require("../tts-userscript.js");
+ const remoteModel = "remote:project-server:qwen3:14b";
+
+ const selected = chooseTranslationModelFallback(
+ {
+ available_model_options: [
+ { value: remoteModel, label: "Project Server / qwen3:14b" },
+ ],
+ },
+ remoteModel,
+ "translategemma:4b"
+ );
+
+ assert.equal(selected, remoteModel);
+});
+
+test("translation model fallback preserves an unavailable explicit local selection", () => {
+ const { chooseTranslationModelFallback } = require("../tts-userscript.js");
+
+ const selected = chooseTranslationModelFallback(
+ {
+ available_model_options: [
+ {
+ value: "qwen3:4b",
+ label: "Local Ollama / qwen3:4b",
+ source: "local",
+ },
+ {
+ value: "remote:project-server:qwen3:14b",
+ label: "Project Server / qwen3:14b",
+ source: "project-server",
+ },
+ ],
+ },
+ "translategemma:4b",
+ "translategemma:4b"
+ );
+
+ assert.equal(selected, "translategemma:4b");
+});
+
+test("project server action explicitly selects the first remote model", () => {
+ const {
+ chooseProjectServerTranslationModel,
+ getRemoteTranslationModelOptions,
+ } = require("../tts-userscript.js");
+ const payload = {
+ available_model_options: [
+ { value: "qwen3:4b", label: "Local Ollama / qwen3:4b", source: "local" },
+ {
+ value: "remote:project-server:qwen3:14b",
+ label: "Project Server / qwen3:14b",
+ source: "project-server",
+ },
+ ],
+ };
+
+ assert.deepEqual(getRemoteTranslationModelOptions(payload), [
+ {
+ value: "remote:project-server:qwen3:14b",
+ label: "Project Server / qwen3:14b",
+ },
+ ]);
+ assert.deepEqual(chooseProjectServerTranslationModel(payload, "qwen3:4b"), {
+ count: 1,
+ value: "remote:project-server:qwen3:14b",
+ label: "Project Server / qwen3:14b",
+ message: "Using Project Server / qwen3:14b. Checking remote model status...",
+ });
+});
+
+test("project server action preserves an available remote selection", () => {
+ const { chooseProjectServerTranslationModel } = require("../tts-userscript.js");
+ const current = "remote:project-server:qwen3:8b";
+ const payload = {
+ available_model_options: [
+ {
+ value: "remote:project-server:qwen3:14b",
+ label: "Project Server / qwen3:14b",
+ },
+ { value: current, label: "Project Server / qwen3:8b" },
+ ],
+ };
+
+ assert.deepEqual(chooseProjectServerTranslationModel(payload, current), {
+ count: 2,
+ value: current,
+ label: "Project Server / qwen3:8b",
+ message: "Using Project Server / qwen3:8b. Checking remote model status...",
+ });
+});
+
+test("project server action explains when tray remote service is not connected", () => {
+ const { chooseProjectServerTranslationModel } = require("../tts-userscript.js");
+
+ assert.deepEqual(chooseProjectServerTranslationModel({}, "translategemma:4b"), {
+ count: 0,
+ value: "",
+ label: "",
+ message: "No project server models found. Configure and connect Remote Service in the local tray app, then try again.",
+ });
+});
+
+test("local model initialization rejects an explicitly selected remote model", () => {
+ const { getLocalModelInitializationError } = require("../tts-userscript.js");
+
+ assert.equal(
+ getLocalModelInitializationError("remote:project-server:qwen3:14b"),
+ "Choose a local model before initializing. Remote models are started by the project server."
+ );
+ assert.equal(getLocalModelInitializationError("translategemma:4b"), "");
+});
+
+test("local service control distinguishes offline, starting, and running states", () => {
+ const { getLocalServiceControlState } = require("../tts-userscript.js");
+
+ assert.deepEqual(getLocalServiceControlState({ online: false, starting: false }), {
+ label: "Start local service",
+ icon: "\u25B6",
+ disabled: false,
+ });
+ assert.deepEqual(getLocalServiceControlState({ online: false, starting: true }), {
+ label: "Starting local service...",
+ icon: "\u23F3",
+ disabled: true,
+ });
+ assert.deepEqual(getLocalServiceControlState({ online: true, starting: true }), {
+ label: "Local service running",
+ icon: "\u2705",
+ disabled: true,
+ });
+});
+
+test("local service health accepts only the Kokoro API readiness contract", () => {
+ const { isKokoroHealthResponse } = require("../tts-userscript.js");
+
+ assert.equal(
+ isKokoroHealthResponse(
+ 200,
+ JSON.stringify({ service: "kokoro-tts", ready: true })
+ ),
+ true
+ );
+ assert.equal(
+ isKokoroHealthResponse(
+ 200,
+ JSON.stringify({ service: "kokoro-tts", api_ready: true })
+ ),
+ true
+ );
+ assert.equal(
+ isKokoroHealthResponse(
+ 200,
+ JSON.stringify({ service: "another-service", ready: true })
+ ),
+ false
+ );
+ assert.equal(isKokoroHealthResponse(200, "not-json"), false);
+ assert.equal(
+ isKokoroHealthResponse(
+ 503,
+ JSON.stringify({ service: "kokoro-tts", ready: true })
+ ),
+ false
+ );
+ assert.equal(
+ isKokoroHealthResponse(
+ 200,
+ JSON.stringify({ service: "kokoro-tts", ready: false, api_ready: false })
+ ),
+ false
+ );
+});
+
+test("settings expose explicit project, local initialization, and protocol launch controls", () => {
+ const source = fs.readFileSync(
+ path.join(__dirname, "..", "tts-userscript.js"),
+ "utf8"
+ );
+
+ assert.match(source, /"tts-project-server-btn"/);
+ assert.match(source, /"tts-init-local-model-btn"/);
+ assert.match(source, /"tts-start-local-service-btn"/);
+ assert.match(source, /const LOCAL_SERVICE_START_URL = "localreadtranslate:\/\/start"/);
+ assert.match(source, /window\.location\.assign\(LOCAL_SERVICE_START_URL\)/);
+ assert.match(source, /function pollLocalServiceStatus\(/);
+ assert.match(source, /settings\.translateModel = selection\.value/);
+ assert.equal(
+ (source.match(/KokoroTTSCore\.isKokoroHealthResponse/g) || []).length,
+ 2
+ );
+ assert.doesNotMatch(source, /ssh_password|private_key|identity_file|auth_password/i);
+});
diff --git a/tray_app.py b/tray_app.py
index b928d02..d77c0c1 100644
--- a/tray_app.py
+++ b/tray_app.py
@@ -32,7 +32,8 @@
SPEEDS,
VOICE_GROUPS,
)
-from windows_runtime import WindowsNamedMutex
+from windows_protocol import ensure_start_protocol_registered, is_start_protocol_url
+from windows_runtime import WindowsNamedAutoResetEvent, WindowsNamedMutex
from windows_startup import (
StartupShortcutError,
inspect_startup_shortcut,
@@ -53,6 +54,7 @@
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 5000
+START_SERVER_EVENT_NAME = r"Local\LocalReadTranslate.StartServer"
VOICES = {
group["label_en"]: [
@@ -122,17 +124,66 @@ def find_conda_pythonw(env_name: str) -> Path:
def default_remote_ollama_settings():
return {
"enabled": False,
- "name": "",
- "host": "",
+ "name": "10.12.96.203",
+ "connection_mode": "ssh",
+ "host": "10.12.96.203",
"ssh_port": 22,
- "username": "",
+ "username": "test",
"password": "",
+ "key_file": "",
"ollama_host": "127.0.0.1",
"ollama_port": 11434,
"local_port": 0,
+ "base_url": "http://10.12.96.203:11434",
}
+def _remote_connection_mode(settings):
+ value = str((settings or {}).get("connection_mode") or "ssh").strip().lower()
+ return "api" if value in {"api", "direct", "direct_api"} else "ssh"
+
+
+def _remote_api_base_url(settings):
+ remote = settings or {}
+ base_url = str(remote.get("base_url") or "").strip().rstrip("/")
+ if not base_url:
+ host = str(remote.get("host") or "").strip()
+ if not host:
+ return ""
+ port = int(remote.get("ollama_port") or 11434)
+ base_url = f"http://{host}:{port}"
+ elif "://" not in base_url:
+ base_url = f"http://{base_url}"
+ return base_url
+
+
+def _load_openssh_host_config(paramiko, host):
+ config_path = Path.home() / ".ssh" / "config"
+ if not config_path.is_file():
+ return {}
+ try:
+ config = paramiko.SSHConfig()
+ with open(config_path, "r", encoding="utf-8") as config_file:
+ config.parse(config_file)
+ return dict(config.lookup(host) or {})
+ except (OSError, ValueError):
+ return {}
+
+
+def _expand_ssh_key_files(value):
+ if not value:
+ return None
+ raw_values = value if isinstance(value, (list, tuple)) else [value]
+ paths = [
+ os.path.expandvars(os.path.expanduser(str(path).strip()))
+ for path in raw_values
+ if str(path).strip()
+ ]
+ if not paths:
+ return None
+ return paths[0] if len(paths) == 1 else paths
+
+
def slugify_source_id(value):
cleaned = "".join(
ch.lower() if ch.isalnum() else "-"
@@ -162,14 +213,22 @@ def load_settings():
defaults["speed"] = DEFAULT_SPEED
defaults["auto_start"] = bool(defaults.get("auto_start", False))
remote = default_remote_ollama_settings()
- if isinstance(defaults.get("remote_ollama"), dict):
- remote.update(defaults["remote_ollama"])
+ saved_remote = defaults.get("remote_ollama")
+ if isinstance(saved_remote, dict):
+ remote.update(saved_remote)
remote["enabled"] = bool(remote.get("enabled", False))
+ remote["connection_mode"] = _remote_connection_mode(remote)
for key in ("ssh_port", "ollama_port", "local_port"):
try:
remote[key] = int(remote.get(key) or default_remote_ollama_settings()[key])
except (TypeError, ValueError):
remote[key] = default_remote_ollama_settings()[key]
+ if isinstance(saved_remote, dict) and "base_url" not in saved_remote:
+ legacy_host = str(remote.get("host") or "").strip()
+ if legacy_host:
+ remote["base_url"] = f"http://{legacy_host}:{remote['ollama_port']}"
+ remote["base_url"] = _remote_api_base_url(remote)
+ remote["key_file"] = str(remote.get("key_file") or "").strip()
defaults["remote_ollama"] = remote
return defaults
@@ -241,33 +300,47 @@ def start(self):
import paramiko
host = str(self.settings.get("host") or "").strip()
- username = str(self.settings.get("username") or "").strip()
+ ssh_config = _load_openssh_host_config(paramiko, host) if host else {}
+ connect_host = str(ssh_config.get("hostname") or host).strip()
+ username = str(
+ self.settings.get("username") or ssh_config.get("user") or ""
+ ).strip()
password = str(self.settings.get("password") or "")
if not host:
raise RuntimeError("Remote server IP is required")
if not username:
raise RuntimeError("Remote username is required")
- if not password:
- raise RuntimeError("Remote password is required")
- ssh_port = int(self.settings.get("ssh_port") or 22)
+ ssh_port = int(
+ self.settings.get("ssh_port") or ssh_config.get("port") or 22
+ )
ollama_host = str(self.settings.get("ollama_host") or "127.0.0.1").strip()
ollama_port = int(self.settings.get("ollama_port") or 11434)
requested_local_port = int(self.settings.get("local_port") or 0)
+ key_filename = _expand_ssh_key_files(
+ self.settings.get("key_file") or ssh_config.get("identityfile")
+ )
client = paramiko.SSHClient()
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
- client.connect(
- hostname=host,
- port=ssh_port,
- username=username,
- password=password,
- look_for_keys=False,
- allow_agent=False,
- timeout=10,
- banner_timeout=10,
- auth_timeout=10,
- )
+ client.load_system_host_keys()
+ client.set_missing_host_key_policy(paramiko.RejectPolicy())
+ connect_kwargs = {
+ "hostname": connect_host,
+ "port": ssh_port,
+ "username": username,
+ "look_for_keys": True,
+ "allow_agent": True,
+ "timeout": 10,
+ "banner_timeout": 10,
+ "auth_timeout": 10,
+ }
+ if key_filename:
+ connect_kwargs["key_filename"] = key_filename
+ if password:
+ # Paramiko tries explicit/default keys and the SSH agent before
+ # falling back to this password in the same connection attempt.
+ connect_kwargs["password"] = password
+ client.connect(**connect_kwargs)
transport = client.get_transport()
if transport is None or not transport.is_active():
client.close()
@@ -337,7 +410,12 @@ def stop(self):
class TrayApp:
- def __init__(self):
+ def __init__(
+ self,
+ *,
+ start_background_tasks=True,
+ enable_windows_protocol=True,
+ ):
self.server_process = None
self.owns_server = False
self._log_handle = None
@@ -348,9 +426,55 @@ def __init__(self):
self.python_exe = find_conda_python(CONDA_ENV_NAME)
self.remote_tunnel = None
self.remote_tunnel_local_port = None
+ self._enable_windows_protocol = bool(enable_windows_protocol)
+ self._start_server_event = None
+ self._protocol_listener_stop = threading.Event()
+ self._protocol_listener_thread = None
# 缓存开机自启状态,避免右键托盘菜单渲染时同步拉起 PowerShell 子进程导致系统假死
self.auto_start_cached = bool(self.settings.get("auto_start", False))
- threading.Thread(target=self._init_and_reconcile_auto_start, daemon=True).start()
+ if self._enable_windows_protocol:
+ self._initialize_windows_protocol(
+ start_listener=bool(start_background_tasks),
+ )
+ if start_background_tasks:
+ threading.Thread(
+ target=self._init_and_reconcile_auto_start,
+ daemon=True,
+ ).start()
+
+ def _initialize_windows_protocol(self, *, start_listener):
+ try:
+ ensure_start_protocol_registered(
+ find_conda_pythonw(CONDA_ENV_NAME),
+ SCRIPT_DIR / "tray_app.py",
+ )
+ except Exception:
+ # URL registration is a convenience feature and must never prevent
+ # the tray or API server from starting normally.
+ pass
+
+ if not start_listener:
+ return
+ try:
+ event = WindowsNamedAutoResetEvent(START_SERVER_EVENT_NAME).create()
+ except Exception:
+ return
+ self._start_server_event = event
+ listener = threading.Thread(
+ target=self._listen_for_start_server_requests,
+ daemon=True,
+ )
+ self._protocol_listener_thread = listener
+ listener.start()
+
+ def _listen_for_start_server_requests(self):
+ while not self._protocol_listener_stop.is_set():
+ try:
+ requested = self._start_server_event.wait(timeout_ms=500)
+ except Exception:
+ return
+ if requested and not self._protocol_listener_stop.is_set():
+ self.start_server()
def get_health(self, port=DEFAULT_PORT):
try:
@@ -471,14 +595,18 @@ def build_remote_ollama_sources_env(self):
remote = self.settings.get("remote_ollama") or {}
if not remote.get("enabled"):
return ""
- try:
- local_port = int(
- self.remote_tunnel_local_port or remote.get("local_port") or 0
- )
- except (TypeError, ValueError):
- local_port = 0
- if local_port <= 0:
- return ""
+ if _remote_connection_mode(remote) == "api":
+ base_url = _remote_api_base_url(remote)
+ if not base_url:
+ return ""
+ else:
+ try:
+ local_port = int(self.remote_tunnel_local_port or 0)
+ except (TypeError, ValueError):
+ local_port = 0
+ if local_port <= 0:
+ return ""
+ base_url = f"http://127.0.0.1:{local_port}"
name = (remote.get("name") or remote.get("host") or "Remote Ollama").strip()
source_id = slugify_source_id(name)
return json.dumps(
@@ -486,15 +614,20 @@ def build_remote_ollama_sources_env(self):
{
"id": source_id,
"name": name,
- "base_url": f"http://127.0.0.1:{local_port}",
+ "base_url": base_url,
}
],
ensure_ascii=False,
)
def _test_remote_ollama_tunnel(self, local_port):
- with urllib.request.urlopen(
- f"http://{DEFAULT_HOST}:{local_port}/api/tags",
+ self._test_remote_ollama_api(f"http://{DEFAULT_HOST}:{local_port}")
+
+ def _test_remote_ollama_api(self, base_url):
+ tags_url = f"{str(base_url or '').strip().rstrip('/')}/api/tags"
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
+ with opener.open(
+ tags_url,
timeout=5,
) as response:
if response.status != 200:
@@ -511,9 +644,18 @@ def ensure_remote_ollama_tunnel(self):
remote = self.settings.get("remote_ollama") or {}
if not remote.get("enabled"):
return False
- if self.remote_tunnel and self.remote_tunnel_local_port:
+ mode = _remote_connection_mode(remote)
+ if mode == "ssh" and self.remote_tunnel and self.remote_tunnel_local_port:
return True
+ tunnel = None
try:
+ if mode == "api":
+ base_url = _remote_api_base_url(remote)
+ if not base_url:
+ raise RuntimeError("Remote Ollama API base URL is required")
+ self._test_remote_ollama_api(base_url)
+ self._stop_remote_ollama_tunnel()
+ return True
tunnel = RemoteOllamaTunnel(remote)
local_port = tunnel.start()
self._test_remote_ollama_tunnel(local_port)
@@ -524,28 +666,50 @@ def ensure_remote_ollama_tunnel(self):
save_settings(self.settings)
return True
except Exception as error:
- self._stop_remote_ollama_tunnel()
- remote = dict(remote)
- remote["enabled"] = False
- remote["local_port"] = 0
- self.settings["remote_ollama"] = remote
+ if tunnel is not None:
+ tunnel.stop()
self.show_error("Remote Service", str(error))
return False
- def connect_remote_ollama(self):
- self._stop_remote_ollama_tunnel()
- remote = self.settings.get("remote_ollama") or default_remote_ollama_settings()
- tunnel = RemoteOllamaTunnel(remote)
+ def connect_remote_ollama(self, remote_settings=None):
+ previous_tunnel = self.remote_tunnel
+ previous_local_port = self.remote_tunnel_local_port
+ remote = default_remote_ollama_settings()
+ candidate = remote_settings
+ if candidate is None:
+ candidate = self.settings.get("remote_ollama") or {}
+ if isinstance(candidate, dict):
+ remote.update(candidate)
+ remote["enabled"] = True
+ remote["connection_mode"] = _remote_connection_mode(remote)
+ remote["base_url"] = _remote_api_base_url(remote)
+ tunnel = None
try:
- local_port = tunnel.start()
- self._test_remote_ollama_tunnel(local_port)
+ if remote["connection_mode"] == "api":
+ if not remote["base_url"]:
+ raise RuntimeError("Remote Ollama API base URL is required")
+ self._test_remote_ollama_api(remote["base_url"])
+ local_port = 0
+ else:
+ tunnel_settings = dict(remote)
+ if (
+ previous_tunnel is not None
+ and int(tunnel_settings.get("local_port") or 0)
+ == int(previous_local_port or 0)
+ ):
+ tunnel_settings["local_port"] = 0
+ tunnel = RemoteOllamaTunnel(tunnel_settings)
+ local_port = tunnel.start()
+ self._test_remote_ollama_tunnel(local_port)
except Exception:
- tunnel.stop()
+ if tunnel is not None:
+ tunnel.stop()
raise
+ if previous_tunnel is not None:
+ previous_tunnel.stop()
self.remote_tunnel = tunnel
- self.remote_tunnel_local_port = local_port
- remote["enabled"] = True
+ self.remote_tunnel_local_port = local_port or None
remote["local_port"] = local_port
self.settings["remote_ollama"] = remote
save_settings(self.settings)
@@ -600,12 +764,19 @@ def _run_remote_service_settings_dialog(self):
fields = [
("Server name", "name", remote.get("name") or ""),
+ (
+ "Connection mode (ssh/api)",
+ "connection_mode",
+ _remote_connection_mode(remote),
+ ),
("Server IP", "host", remote.get("host") or ""),
("SSH port", "ssh_port", str(remote.get("ssh_port") or 22)),
("Username", "username", remote.get("username") or ""),
("Password", "password", remote.get("password") or ""),
+ ("SSH key file (optional)", "key_file", remote.get("key_file") or ""),
("Ollama host", "ollama_host", remote.get("ollama_host") or "127.0.0.1"),
("Ollama port", "ollama_port", str(remote.get("ollama_port") or 11434)),
+ ("Direct API base URL", "base_url", _remote_api_base_url(remote)),
]
entries = {}
for row, (label, key, value) in enumerate(fields):
@@ -616,10 +787,20 @@ def _run_remote_service_settings_dialog(self):
pady=5,
sticky="e",
)
- entry = tk.Entry(window, width=32, show="*" if key == "password" else "")
- entry.insert(0, str(value))
- entry.grid(row=row, column=1, padx=10, pady=5, sticky="we")
- entries[key] = entry
+ if key == "connection_mode":
+ mode_var = tk.StringVar(value=str(value))
+ mode_menu = tk.OptionMenu(window, mode_var, "ssh", "api")
+ mode_menu.grid(row=row, column=1, padx=10, pady=5, sticky="we")
+ entries[key] = mode_var
+ else:
+ entry = tk.Entry(
+ window,
+ width=32,
+ show="*" if key == "password" else "",
+ )
+ entry.insert(0, str(value))
+ entry.grid(row=row, column=1, padx=10, pady=5, sticky="we")
+ entries[key] = entry
entries["host"].focus_set()
window.after(100, window.focus_force)
@@ -632,18 +813,24 @@ def read_remote_settings():
values.update(
{
"name": entries["name"].get().strip(),
+ "connection_mode": entries["connection_mode"].get().strip().lower(),
"host": entries["host"].get().strip(),
"username": entries["username"].get().strip(),
"password": entries["password"].get(),
+ "key_file": entries["key_file"].get().strip(),
"ollama_host": entries["ollama_host"].get().strip() or "127.0.0.1",
+ "base_url": entries["base_url"].get().strip(),
}
)
+ if values["connection_mode"] not in {"ssh", "api"}:
+ raise RuntimeError("connection mode must be ssh or api")
for key, fallback in (("ssh_port", 22), ("ollama_port", 11434)):
try:
values[key] = int(entries[key].get().strip() or fallback)
except ValueError:
raise RuntimeError(f"{key.replace('_', ' ')} must be a number")
values["name"] = values["name"] or values["host"] or "Remote Ollama"
+ values["base_url"] = _remote_api_base_url(values)
values["local_port"] = int(remote.get("local_port") or 0)
return values
@@ -662,10 +849,9 @@ def on_connect():
try:
values = read_remote_settings()
values["enabled"] = True
- self.settings["remote_ollama"] = values
status.config(text="Connecting...")
window.update_idletasks()
- self.connect_remote_ollama()
+ self.connect_remote_ollama(values)
messagebox.showinfo("Remote Service", "Connected.", parent=window)
window.destroy()
except Exception as error:
@@ -777,6 +963,18 @@ def _schedule_force_exit(self, delay=6.0):
def quit_app(self, _=None):
watchdog = self._schedule_force_exit()
try:
+ self._protocol_listener_stop.set()
+ if self._start_server_event is not None:
+ try:
+ self._start_server_event.set()
+ if (
+ self._protocol_listener_thread is not None
+ and self._protocol_listener_thread is not threading.current_thread()
+ ):
+ self._protocol_listener_thread.join(timeout=1)
+ self._start_server_event.close()
+ except Exception:
+ pass
try:
self._stop_remote_ollama_tunnel()
except Exception:
@@ -877,9 +1075,14 @@ def run(self):
# Entry point
# ---------------------------------------------------------------------------
-if __name__ == "__main__":
+def main(argv=None):
+ args = list(sys.argv[1:] if argv is None else argv)
+ start_requested = any(is_start_protocol_url(value) for value in args)
instance_mutex = WindowsNamedMutex(r"Local\KokoroTTS.Tray")
if not instance_mutex.acquire():
+ if start_requested:
+ WindowsNamedAutoResetEvent.signal_existing(START_SERVER_EVENT_NAME)
+ return 0
import ctypes
ctypes.windll.user32.MessageBoxW(
@@ -888,9 +1091,14 @@ def run(self):
"Kokoro TTS",
0x40,
)
- raise SystemExit(0)
+ return 0
try:
app = TrayApp()
app.run()
finally:
instance_mutex.close()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tts-userscript.js b/tts-userscript.js
index c06e878..1ef7d84 100644
--- a/tts-userscript.js
+++ b/tts-userscript.js
@@ -3,9 +3,10 @@
// @name:zh-CN 本地划词听译助手
// @name:en Local Selection Read & Translate
// @namespace https://github.com/Yan-ShiBo/LocalReadTranslate
-// @version 1.12.8
-// @description 选中文本即可本地朗读或翻译:Kokoro TTS 负责语音朗读,Ollama 模型负责本地翻译,文本不上传云端。
-// @description:en Select text on any page to read aloud locally with Kokoro TTS or translate locally through Ollama.
+// @version 1.13.0
+// @description 默认在本地朗读和翻译选中文本,也可选择使用用户配置的项目服务器。
+// @description:zh-CN 默认在本地朗读和翻译选中文本,也可选择使用用户配置的项目服务器。
+// @description:en Read and translate selected text locally by default, with an option to use a user-configured project server.
// @author Yan-ShiBo
// @license MIT
// @match *://*/*
@@ -148,6 +149,79 @@ const KokoroTTSCore = (() => {
return merged;
}
+ function chooseTranslationModelFallback(_payload, selectedValue, defaultValue) {
+ const selected = String(selectedValue || "").trim();
+ const fallbackDefault = String(defaultValue || "").trim();
+ return selected || fallbackDefault;
+ }
+
+ function getRemoteTranslationModelOptions(payload) {
+ const options =
+ payload && Array.isArray(payload.available_model_options)
+ ? payload.available_model_options
+ : [];
+ return options
+ .map((option) => ({
+ value: String(option && option.value || "").trim(),
+ label: String(option && option.label || option && option.value || "").trim(),
+ }))
+ .filter((option) => option.value.startsWith("remote:"));
+ }
+
+ function chooseProjectServerTranslationModel(payload, selectedValue) {
+ const options = getRemoteTranslationModelOptions(payload);
+ if (!options.length) {
+ return {
+ count: 0,
+ value: "",
+ label: "",
+ message: "No project server models found. Configure and connect Remote Service in the local tray app, then try again.",
+ };
+ }
+ const selected = String(selectedValue || "").trim();
+ const option = options.find((item) => item.value === selected) || options[0];
+ return {
+ count: options.length,
+ value: option.value,
+ label: option.label,
+ message: `Using ${option.label}. Checking remote model status...`,
+ };
+ }
+
+ function getLocalModelInitializationError(model) {
+ return String(model || "").trim().startsWith("remote:")
+ ? "Choose a local model before initializing. Remote models are started by the project server."
+ : "";
+ }
+
+ function getLocalServiceControlState({ online = false, starting = false } = {}) {
+ if (online) {
+ return { label: "Local service running", icon: "\u2705", disabled: true };
+ }
+ if (starting) {
+ return { label: "Starting local service...", icon: "\u23F3", disabled: true };
+ }
+ return { label: "Start local service", icon: "\u25B6", disabled: false };
+ }
+
+ function isKokoroHealthResponse(status, payloadOrText) {
+ if (Number(status) !== 200) return false;
+ let payload = payloadOrText;
+ if (typeof payloadOrText === "string") {
+ try {
+ payload = JSON.parse(payloadOrText);
+ } catch {
+ return false;
+ }
+ }
+ return Boolean(
+ payload &&
+ typeof payload === "object" &&
+ payload.service === "kokoro-tts" &&
+ (payload.ready === true || payload.api_ready === true)
+ );
+ }
+
function formatPlaybackProgress({ currentTime = 0, duration = 0, streamEnded = false } = {}) {
const seconds = Math.max(0, Math.floor(Number(currentTime) || 0));
if (!streamEnded || !Number.isFinite(duration) || duration <= 0) {
@@ -834,15 +908,21 @@ const KokoroTTSCore = (() => {
return {
WEBM_OPUS_MIME,
applyFormulaVerbalizations,
+ chooseProjectServerTranslationModel,
choosePlaybackMode,
cjkRatio,
createAppendQueue,
createRequestGate,
formatPlaybackProgress,
formulaToReadableHtml,
+ getLocalModelInitializationError,
+ getLocalServiceControlState,
+ getRemoteTranslationModelOptions,
isUnsupportedMediaError,
+ isKokoroHealthResponse,
latexToReadableFormula,
mergeTranslationModelOptions,
+ chooseTranslationModelFallback,
normalizeAudioBuffer,
normalizeAudioBlob,
normalizeCopyTextWithLatex,
@@ -887,6 +967,7 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
const API_TRANSLATE_UNLOAD_URL = API_BASE + "/translate/model/unload";
const API_READ_PREPARE_URL = API_BASE + "/read/prepare";
const API_FORMULA_VERBALIZE_URL = API_BASE + "/formula/verbalize";
+ const LOCAL_SERVICE_START_URL = "localreadtranslate://start";
const SHORTCUT = { ctrl: true, shift: true, key: "S" }; // Ctrl+Shift+S
/* CATALOG:START */
@@ -935,6 +1016,8 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
let isTranslating = false;
let settingsPanel = null;
let settingsVisible = false;
+ let localServicePollTimer = null;
+ let localServiceStartPending = false;
// Load saved settings
function loadSettings() {
@@ -1652,6 +1735,19 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
createTextInput("tts-target-language-input", settings.targetLanguage)
);
+ const serviceActions = document.createElement("div");
+ serviceActions.className = "tts-model-actions";
+ serviceActions.appendChild(
+ createSettingsButton("tts-project-server-btn", "Use project server")
+ );
+ serviceActions.appendChild(
+ createSettingsButton("tts-init-local-model-btn", "Initialize local model")
+ );
+ serviceActions.appendChild(
+ createSettingsButton("tts-start-local-service-btn", "Start local service")
+ );
+ translationColumn.appendChild(serviceActions);
+
const modelActions = document.createElement("div");
modelActions.className = "tts-model-actions";
modelActions.appendChild(createSettingsButton("tts-model-keepalive-btn", "Keep loaded"));
@@ -1743,11 +1839,199 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
unloadTranslationModel(e.currentTarget);
});
+ panel.querySelector("#tts-project-server-btn").addEventListener("click", (e) => {
+ checkProjectServerOptions(e.currentTarget);
+ });
+
+ panel.querySelector("#tts-init-local-model-btn").addEventListener("click", (e) => {
+ initializeLocalTranslationModel(e.currentTarget);
+ });
+
+ panel.querySelector("#tts-start-local-service-btn").addEventListener("click", (e) => {
+ startLocalService(e.currentTarget);
+ });
+
// Check server status
checkServerStatus();
checkTranslationStatus();
}
+ function setTranslationControlMessage(message, color = "#f0c040") {
+ const output = document.getElementById("tts-translate-test-output");
+ if (!output) return;
+ output.textContent = message;
+ output.style.color = color;
+ }
+
+ function updateLocalServiceControl(online, starting = localServiceStartPending) {
+ const button = document.getElementById("tts-start-local-service-btn");
+ if (!button) return;
+ const state = KokoroTTSCore.getLocalServiceControlState({ online, starting });
+ const className = online
+ ? "tts-test-btn playing"
+ : starting
+ ? "tts-test-btn loading"
+ : "tts-test-btn";
+ setButtonHtml(button, className, state.icon, state.label);
+ button.disabled = state.disabled;
+ }
+
+ function finishLocalServicePoll(online, message) {
+ if (localServicePollTimer) {
+ clearTimeout(localServicePollTimer);
+ localServicePollTimer = null;
+ }
+ localServiceStartPending = false;
+ updateLocalServiceControl(online, false);
+ if (message) {
+ setTranslationControlMessage(message, online ? "#81c784" : "#e57373");
+ }
+ if (online) {
+ checkServerStatus();
+ checkTranslationStatus();
+ }
+ }
+
+ function pollLocalServiceStatus(attempt = 0) {
+ GM_xmlhttpRequest({
+ method: "GET",
+ url: API_BASE + "/health",
+ timeout: 2000,
+ onload: (response) => {
+ if (
+ KokoroTTSCore.isKokoroHealthResponse(
+ response.status,
+ response.responseText
+ )
+ ) {
+ finishLocalServicePoll(true, "Local service is running. You can now initialize a local model.");
+ return;
+ }
+ scheduleNextLocalServicePoll(attempt);
+ },
+ onerror: () => scheduleNextLocalServicePoll(attempt),
+ ontimeout: () => scheduleNextLocalServicePoll(attempt),
+ });
+ }
+
+ function scheduleNextLocalServicePoll(attempt) {
+ if (!localServiceStartPending) return;
+ if (attempt >= 19) {
+ finishLocalServicePoll(
+ false,
+ "Local service did not start. Install the localreadtranslate:// protocol handler or start the tray app manually."
+ );
+ return;
+ }
+ if (localServicePollTimer) clearTimeout(localServicePollTimer);
+ localServicePollTimer = setTimeout(() => pollLocalServiceStatus(attempt + 1), 1000);
+ }
+
+ function startLocalService(btnElement) {
+ if (localServiceStartPending || (btnElement && btnElement.disabled)) return;
+ localServiceStartPending = true;
+ updateLocalServiceControl(false, true);
+ setTranslationControlMessage("Requesting the local tray service...", "#f0c040");
+ try {
+ window.location.assign(LOCAL_SERVICE_START_URL);
+ } catch (error) {
+ finishLocalServicePoll(
+ false,
+ error && error.message ? error.message : "Cannot open the local service launcher."
+ );
+ return;
+ }
+ pollLocalServiceStatus();
+ }
+
+ function checkProjectServerOptions(btnElement) {
+ syncSettingsFromPanel();
+ if (btnElement) {
+ setButtonHtml(btnElement, "tts-test-btn loading", "\u23F3", "Finding project server...");
+ btnElement.disabled = true;
+ }
+ setTranslationControlMessage("Refreshing project server models...", "#f0c040");
+
+ GM_xmlhttpRequest({
+ method: "GET",
+ url: `${API_TRANSLATE_HEALTH_URL}?model=${encodeURIComponent(settings.translateModel)}`,
+ timeout: 10000,
+ onload: (response) => {
+ let payload = null;
+ if (response.status === 200) {
+ try {
+ payload = JSON.parse(response.responseText || "{}");
+ } catch {}
+ }
+ if (!payload) {
+ setTranslationControlMessage("Project server check failed. Start the local service and try again.", "#e57373");
+ if (btnElement) {
+ setButtonHtml(btnElement, "tts-test-btn error", "\u274C", "Project server check failed");
+ btnElement.disabled = false;
+ }
+ return;
+ }
+
+ syncInstalledTranslationModels(payload);
+ const selection = KokoroTTSCore.chooseProjectServerTranslationModel(
+ payload,
+ settings.translateModel
+ );
+ if (selection.value) {
+ settings.translateModel = selection.value;
+ settings.settingsVersion = DEFAULTS.settingsVersion;
+ const select = document.getElementById("tts-translate-model-select");
+ const input = document.getElementById("tts-translate-model-input");
+ if (select) select.value = selection.value;
+ if (input) input.value = selection.value;
+ saveSettings(settings);
+ }
+ setTranslationControlMessage(
+ selection.message,
+ selection.count ? "#81c784" : "#f0c040"
+ );
+ if (btnElement) {
+ setButtonHtml(
+ btnElement,
+ selection.count ? "tts-test-btn playing" : "tts-test-btn",
+ selection.count ? "\u2705" : "\uD83D\uDD0D",
+ selection.count ? "Using project server" : "Use project server"
+ );
+ btnElement.disabled = false;
+ }
+ if (selection.value) checkTranslationStatus();
+ },
+ onerror: () => {
+ setTranslationControlMessage("Local service is offline. Start it before checking the project server.", "#e57373");
+ if (btnElement) {
+ setButtonHtml(btnElement, "tts-test-btn error", "\u274C", "Project server check failed");
+ btnElement.disabled = false;
+ }
+ },
+ ontimeout: () => {
+ setTranslationControlMessage("Project server check timed out.", "#e57373");
+ if (btnElement) {
+ setButtonHtml(btnElement, "tts-test-btn error", "\u274C", "Project server timeout");
+ btnElement.disabled = false;
+ }
+ },
+ });
+ }
+
+ async function initializeLocalTranslationModel(btnElement) {
+ syncSettingsFromPanel();
+ const error = KokoroTTSCore.getLocalModelInitializationError(settings.translateModel);
+ if (error) {
+ setTranslationControlMessage(error, "#f0c040");
+ if (btnElement) {
+ setButtonHtml(btnElement, "tts-test-btn error", "\u26A0", "Choose a local model first");
+ btnElement.disabled = false;
+ }
+ return;
+ }
+ await keepTranslationModelLoaded(btnElement);
+ }
+
function checkServerStatus() {
const dot = document.getElementById("tts-status-dot");
const text = document.getElementById("tts-status-text");
@@ -1758,22 +2042,32 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
url: API_BASE + "/health",
timeout: 3000,
onload: (resp) => {
- if (resp.status === 200) {
+ if (
+ KokoroTTSCore.isKokoroHealthResponse(
+ resp.status,
+ resp.responseText
+ )
+ ) {
+ localServiceStartPending = false;
+ updateLocalServiceControl(true, false);
dot.className = "tts-status-dot online";
text.textContent = "Server online";
text.style.color = "#81c784";
} else {
+ updateLocalServiceControl(false, localServiceStartPending);
dot.className = "tts-status-dot offline";
text.textContent = "Server error";
text.style.color = "#e57373";
}
},
onerror: () => {
+ updateLocalServiceControl(false, localServiceStartPending);
dot.className = "tts-status-dot offline";
text.textContent = "Server offline - run start.bat";
text.style.color = "#e57373";
},
ontimeout: () => {
+ updateLocalServiceControl(false, localServiceStartPending);
dot.className = "tts-status-dot offline";
text.textContent = "Server timeout";
text.style.color = "#e57373";
@@ -1893,13 +2187,30 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
return;
}
+ syncInstalledTranslationModels(payload);
+ const fallbackModel = KokoroTTSCore.chooseTranslationModelFallback(
+ payload,
+ settings.translateModel,
+ DEFAULTS.translateModel
+ );
+ if (fallbackModel && fallbackModel !== settings.translateModel) {
+ settings.translateModel = fallbackModel;
+ settings.settingsVersion = DEFAULTS.settingsVersion;
+ const select = document.getElementById("tts-translate-model-select");
+ const input = document.getElementById("tts-translate-model-input");
+ if (select) select.value = fallbackModel;
+ if (input) input.value = fallbackModel;
+ saveSettings(settings);
+ checkTranslationStatus();
+ return;
+ }
+
if (!payload.ollama_reachable) {
updateTranslationModelControls(payload);
dot.className = "tts-status-dot offline";
text.textContent = "Ollama offline";
text.style.color = "#e57373";
} else if (payload.model_running) {
- syncInstalledTranslationModels(payload);
updateTranslationModelControls(payload);
dot.className = "tts-status-dot online";
text.textContent = payload.model_pinned
@@ -1907,13 +2218,11 @@ if (typeof window !== "undefined" && typeof document !== "undefined") {
: `${payload.model} running`;
text.style.color = "#81c784";
} else if (payload.model_available) {
- syncInstalledTranslationModels(payload);
updateTranslationModelControls(payload);
dot.className = "tts-status-dot warning";
text.textContent = `${payload.model} installed, not loaded`;
text.style.color = "#f0c040";
} else {
- syncInstalledTranslationModels(payload);
updateTranslationModelControls(payload);
dot.className = "tts-status-dot offline";
text.textContent = `${payload.model} not installed`;
diff --git a/windows_protocol.py b/windows_protocol.py
new file mode 100644
index 0000000..091b5d5
--- /dev/null
+++ b/windows_protocol.py
@@ -0,0 +1,210 @@
+"""Per-user Windows URL protocol support for starting LocalReadTranslate."""
+
+import argparse
+import os
+import sys
+from pathlib import Path
+from urllib.parse import urlsplit
+
+
+PROTOCOL_SCHEME = "localreadtranslate"
+START_PROTOCOL_URL = f"{PROTOCOL_SCHEME}://start"
+PROTOCOL_REGISTRY_PATH = rf"Software\Classes\{PROTOCOL_SCHEME}"
+
+
+class ProtocolRegistrationError(RuntimeError):
+ pass
+
+
+def build_start_protocol_command(pythonw: Path, tray_script: Path) -> str:
+ """Return the quoted command stored in the URL protocol registry key."""
+ return f'"{Path(pythonw)}" "{Path(tray_script)}" "%1"'
+
+
+def is_start_protocol_url(value: str) -> bool:
+ """Return whether *value* is the supported one-click start URL."""
+ try:
+ parsed = urlsplit(str(value or "").strip())
+ except ValueError:
+ return False
+ return (
+ parsed.scheme.lower() == PROTOCOL_SCHEME
+ and parsed.netloc.lower() == "start"
+ and parsed.path in {"", "/"}
+ and not parsed.query
+ and not parsed.fragment
+ )
+
+
+def _set_string_value(registry, key, name: str, value: str) -> bool:
+ try:
+ current, value_type = registry.QueryValueEx(key, name)
+ except FileNotFoundError:
+ current, value_type = None, None
+ if current == value and value_type == registry.REG_SZ:
+ return False
+ registry.SetValueEx(key, name, 0, registry.REG_SZ, value)
+ return True
+
+
+def _verify_string_value(registry, key, name: str, expected: str) -> None:
+ try:
+ actual, value_type = registry.QueryValueEx(key, name)
+ except OSError as error:
+ raise ProtocolRegistrationError("Unable to verify URL protocol registration") from error
+ if actual != expected or value_type != registry.REG_SZ:
+ raise ProtocolRegistrationError("Unable to verify URL protocol registration")
+
+
+def ensure_start_protocol_registered(
+ pythonw: Path,
+ tray_script: Path,
+ *,
+ registry=None,
+ platform_name: str | None = None,
+) -> bool:
+ """Create or repair the current user's ``localreadtranslate`` handler.
+
+ Returns ``True`` when at least one value changed. On non-Windows systems the
+ operation is an intentional no-op, which keeps imports and tests portable.
+ """
+ if (os.name if platform_name is None else platform_name) != "nt":
+ return False
+ if registry is None:
+ import winreg as registry
+
+ command = build_start_protocol_command(pythonw, tray_script)
+ access = registry.KEY_READ | registry.KEY_WRITE
+ changed = False
+ with registry.CreateKeyEx(
+ registry.HKEY_CURRENT_USER,
+ PROTOCOL_REGISTRY_PATH,
+ 0,
+ access,
+ ) as protocol_key:
+ changed |= _set_string_value(
+ registry,
+ protocol_key,
+ "",
+ "URL:LocalReadTranslate Protocol",
+ )
+ changed |= _set_string_value(registry, protocol_key, "URL Protocol", "")
+ _verify_string_value(
+ registry,
+ protocol_key,
+ "",
+ "URL:LocalReadTranslate Protocol",
+ )
+ _verify_string_value(registry, protocol_key, "URL Protocol", "")
+
+ command_path = PROTOCOL_REGISTRY_PATH + r"\shell\open\command"
+ with registry.CreateKeyEx(
+ registry.HKEY_CURRENT_USER,
+ command_path,
+ 0,
+ access,
+ ) as command_key:
+ changed |= _set_string_value(registry, command_key, "", command)
+ _verify_string_value(registry, command_key, "", command)
+ return changed
+
+
+def unregister_start_protocol(
+ *,
+ registry=None,
+ platform_name: str | None = None,
+) -> bool:
+ """Remove only this app's current-user URL protocol tree.
+
+ The operation is idempotent and deliberately does not recurse outside the
+ exact ``Software\\Classes\\localreadtranslate`` key.
+ """
+ if (os.name if platform_name is None else platform_name) != "nt":
+ return False
+ if registry is None:
+ import winreg as registry
+
+ changed = False
+ paths = [
+ PROTOCOL_REGISTRY_PATH + r"\shell\open\command",
+ PROTOCOL_REGISTRY_PATH + r"\shell\open",
+ PROTOCOL_REGISTRY_PATH + r"\shell",
+ PROTOCOL_REGISTRY_PATH,
+ ]
+ for path in paths:
+ try:
+ registry.DeleteKey(registry.HKEY_CURRENT_USER, path)
+ except FileNotFoundError:
+ continue
+ changed = True
+ return changed
+
+
+def main(
+ argv=None,
+ *,
+ platform_name: str | None = None,
+ registry=None,
+ executable: Path | None = None,
+ module_path: Path | None = None,
+ stdout=None,
+ stderr=None,
+) -> int:
+ """CLI used by setup scripts to register or repair the URL handler."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ register = subparsers.add_parser(
+ "register",
+ help="register the current-user localreadtranslate URL handler",
+ )
+ register.add_argument("--pythonw", type=Path)
+ register.add_argument("--tray-script", type=Path)
+ subparsers.add_parser(
+ "unregister",
+ help="remove the current-user localreadtranslate URL handler",
+ )
+ args = parser.parse_args(argv)
+
+ stdout = sys.stdout if stdout is None else stdout
+ stderr = sys.stderr if stderr is None else stderr
+ current_platform = os.name if platform_name is None else platform_name
+ if current_platform != "nt":
+ print("URL protocol registration is only available on Windows.", file=stderr)
+ return 2
+
+ if args.command == "unregister":
+ try:
+ changed = unregister_start_protocol(
+ registry=registry,
+ platform_name=current_platform,
+ )
+ except Exception as error:
+ print(f"Unable to unregister URL protocol: {error}", file=stderr)
+ return 1
+ status = "removed" if changed else "already absent"
+ print(f"Protocol {status}: HKCU\\{PROTOCOL_REGISTRY_PATH}", file=stdout)
+ return 0
+
+ current_python = Path(sys.executable if executable is None else executable)
+ current_module = Path(__file__ if module_path is None else module_path)
+ pythonw = args.pythonw or current_python.with_name("pythonw.exe")
+ tray_script = args.tray_script or current_module.with_name("tray_app.py")
+ try:
+ changed = ensure_start_protocol_registered(
+ pythonw,
+ tray_script,
+ registry=registry,
+ platform_name=current_platform,
+ )
+ except Exception as error:
+ print(f"Unable to register URL protocol: {error}", file=stderr)
+ return 1
+
+ status = "updated" if changed else "verified"
+ print(f"Protocol {status}: HKCU\\{PROTOCOL_REGISTRY_PATH}", file=stdout)
+ print(f"Command: {build_start_protocol_command(pythonw, tray_script)}", file=stdout)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/windows_runtime.py b/windows_runtime.py
index 305492c..9a051cd 100644
--- a/windows_runtime.py
+++ b/windows_runtime.py
@@ -3,6 +3,9 @@
ERROR_ALREADY_EXISTS = 183
+EVENT_MODIFY_STATE = 0x0002
+WAIT_OBJECT_0 = 0x00000000
+WAIT_TIMEOUT = 0x00000102
class WindowsNamedMutex:
@@ -45,3 +48,96 @@ def close(self):
if self._handle:
self._kernel32.CloseHandle(self._handle)
self._handle = None
+
+
+class WindowsNamedAutoResetEvent:
+ """Small wrapper around a named, per-session Windows auto-reset event."""
+
+ def __init__(self, name, *, kernel32=None, platform_name=None):
+ self.name = name
+ self._kernel32 = kernel32
+ self._platform_name = os.name if platform_name is None else platform_name
+ self._handle = None
+
+ def is_supported(self):
+ return self._platform_name == "nt"
+
+ def _load_kernel32(self):
+ if not self.is_supported():
+ raise RuntimeError("Windows named events are only available on Windows")
+ if self._kernel32 is None:
+ self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ self._kernel32.CreateEventW.argtypes = [
+ ctypes.c_void_p,
+ ctypes.c_bool,
+ ctypes.c_bool,
+ ctypes.c_wchar_p,
+ ]
+ self._kernel32.CreateEventW.restype = ctypes.c_void_p
+ self._kernel32.WaitForSingleObject.argtypes = [
+ ctypes.c_void_p,
+ ctypes.c_uint32,
+ ]
+ self._kernel32.WaitForSingleObject.restype = ctypes.c_uint32
+ self._kernel32.SetEvent.argtypes = [ctypes.c_void_p]
+ self._kernel32.SetEvent.restype = ctypes.c_bool
+ self._kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
+ self._kernel32.CloseHandle.restype = ctypes.c_bool
+ return self._kernel32
+
+ def create(self):
+ if self._handle:
+ return self
+ kernel32 = self._load_kernel32()
+ handle = kernel32.CreateEventW(None, False, False, self.name)
+ if not handle:
+ raise ctypes.WinError(ctypes.get_last_error())
+ self._handle = handle
+ return self
+
+ def wait(self, timeout_ms=500):
+ if not self._handle:
+ raise RuntimeError("Named event has not been created")
+ result = self._kernel32.WaitForSingleObject(self._handle, int(timeout_ms))
+ if result == WAIT_OBJECT_0:
+ return True
+ if result == WAIT_TIMEOUT:
+ return False
+ raise ctypes.WinError(ctypes.get_last_error())
+
+ def set(self):
+ if not self._handle:
+ return False
+ return bool(self._kernel32.SetEvent(self._handle))
+
+ @classmethod
+ def signal_existing(cls, name, *, kernel32=None, platform_name=None):
+ event = cls(name, kernel32=kernel32, platform_name=platform_name)
+ if not event.is_supported():
+ return False
+ if event._kernel32 is None:
+ event._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ kernel32 = event._kernel32
+ kernel32.OpenEventW.argtypes = [
+ ctypes.c_uint32,
+ ctypes.c_bool,
+ ctypes.c_wchar_p,
+ ]
+ kernel32.OpenEventW.restype = ctypes.c_void_p
+ kernel32.SetEvent.argtypes = [ctypes.c_void_p]
+ kernel32.SetEvent.restype = ctypes.c_bool
+ kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
+ kernel32.CloseHandle.restype = ctypes.c_bool
+
+ handle = kernel32.OpenEventW(EVENT_MODIFY_STATE, False, name)
+ if not handle:
+ return False
+ try:
+ return bool(kernel32.SetEvent(handle))
+ finally:
+ kernel32.CloseHandle(handle)
+
+ def close(self):
+ if self._handle:
+ self._kernel32.CloseHandle(self._handle)
+ self._handle = None
diff --git "a/\350\257\264\346\230\216.md" "b/\350\257\264\346\230\216.md"
index 1bfe496..176144c 100644
--- "a/\350\257\264\346\230\216.md"
+++ "b/\350\257\264\346\230\216.md"
@@ -1,23 +1,28 @@
# 本地划词听译助手
-> 在 Chrome 浏览器中选中网页文本,一键本地朗读或本地翻译。
-> 朗读由 **Kokoro TTS** 完成,翻译由 **Ollama** 本地模型完成,文本不上传云端。
+> 在 Chrome 浏览器中选中网页文本,一键本地朗读,默认使用本机 Ollama 翻译。
+> 也可以明确选择由托盘程序配置的项目服务器;此时选中文本和允许的上下文会发送到该服务器。
> `Read` 会先清洗文本:英文公式场景会先读正文、后台处理公式,播放到公式时如果还没处理好再等待;中文、代码块、URL、表格碎片等不会直接送进英文 TTS。
---
## 📐 系统架构
+```text
+┌──────────────────────┐ 仅访问本机回环 ┌───────────────────────┐
+│ Chrome + Tampermonkey│ ──────────────────────► │ 本地 FastAPI 中介 │
+│ Read / Translate │ 127.0.0.1:5000 │ /tts → 按需 Kokoro │
+│ 设置面板服务操作 │ ◄────────────────────── │ /translate → Ollama │
+└──────────────────────┘ └──────────┬────────────┘
+ │ localreadtranslate://start ├─► 本机 Ollama
+ ▼ └─► 已配置的远程 Ollama
+┌──────────────────────┐ SSH/API
+│ Windows 托盘程序 │
+│ 启动或唤醒 FastAPI │
+└──────────────────────┘
```
-┌──────────────────────────┐ ┌─────────────────────────┐
-│ Chrome 浏览器 │ POST /tts │ 本地 API 服务器 │
-│ Tampermonkey 油猴脚本 │ ───────────────────► │ FastAPI + Kokoro TTS │
-│ │ │ 127.0.0.1:5000 │
-│ ① 用户选中英文文本 │ ◄─────────────────── │ │
-│ ② 弹出 Read / Translate │ /tts 或 /translate │ ③ 本地生成语音或译文 │
-│ ④ HTML5 Audio 播放 │ │ NVIDIA GPU 加速 │
-└──────────────────────────┘ └─────────────────────────┘
-```
+
+油猴脚本不会拿到 SSH 凭据,也不会直接访问 Ollama。浏览器只与本地 API 交互;托盘程序负责进程启动、SSH/API 配置和隧道生命周期。
---
@@ -36,7 +41,7 @@
| ∑ 公式友好 | MathJax/MathML/LaTeX 会优先提取语义公式;朗读时转成流畅口语,翻译时在前端渲染为带上下标的易读公式 |
| ⎘ 选中复制 | `Copy` 不调用翻译,只复制原文;公式会自动扩展到完整公式框并复制为 LaTeX |
| 🛡️ Trusted Types 兼容 | 前端 UI 不再使用 `innerHTML` 等 HTML 字符串注入,适配 Gemini 等严格页面 |
-| 🧭 上下文参考 | 翻译请求可附带附近正文作为本地参考,只用于术语和指代消歧;真正翻译和输出的只有选中内容 |
+| 🧭 上下文参考 | 翻译请求可附带附近正文作为参考,只用于术语和指代消歧;真正翻译和输出的只有选中内容;选择远程模型时该上下文也会发送到对应服务器 |
| 🧮 模型上下文预算 | 4B 模型翻译和公式朗读不参考上下文,9B/14B/更大模型按大小逐级增加上下文长度 |
| ⚡ Qwen3 no-think | `qwen3:14b`、QwQ、DeepSeek-R1 等推理模型会通过 Ollama `think: false` 关闭思考过程,减少翻译和朗读准备延迟 |
| 📌 模型常驻/卸载 | 在 Translation 设置栏可把当前 Ollama 模型常驻显存,频繁使用时减少首次加载等待;不用时可手动卸载释放显存 |
@@ -45,7 +50,7 @@
| 🧭 避让选区 | Read 和 Translate 可同时进行;按钮行固定在选区下方,译文卡片会根据空间避让,减少遮挡正文 |
| ∑ 局部公式恢复 | 只选中 MathJax/MathML/KaTeX 公式的一部分时,会尽量扩展到完整公式框;选中整句时保留公式前后文字 |
| 🎭 17 种声音 | 内置美式男声、女声、英式声音,无需自备参考音频 |
-| 🔒 隐私安全 | 完全本地运行,不发送任何数据到外部服务器 |
+| 🔒 隐私边界 | 默认本地运行;只有用户明确选择远程模型时,文本才会发送到已配置的服务器 |
| 💰 免费无限 | 开源免费,无 API 调用费用 |
---
@@ -56,7 +61,7 @@
|------|------|---------|
| 操作系统 | Windows 10/11 | ✅ |
| 显卡 | NVIDIA GPU(可选,CPU 也可运行) | 推荐 CUDA 显卡 |
-| Python | 3.10 - 3.12 | 通过 Conda 管理 |
+| Python | 3.10 | 通过 Conda 管理 |
| eSpeak-NG | 语音音素引擎(必需) | 需安装 |
| 浏览器 | Chrome + Tampermonkey 扩展 | 需安装 |
@@ -101,13 +106,26 @@ conda activate kokoro-tts
python server.py
```
-首次启动会自动从 HuggingFace 下载 Kokoro 模型(约 200MB,非常小),之后启动只需几秒。
-看到 `✅ 模型加载完成!服务已就绪。` 表示服务可用。
+服务启动时只初始化 API 和 bundled FFmpeg。Kokoro 模型在第一次点击 `Read` 时才按需下载/加载,因此仅远程翻译不会占用本机 GPU。访问 `/health` 时,`api_ready: true` 表示 API 可用,`tts_model_loaded` 单独表示 Kokoro 是否已加载。
也可以直接双击 **`start.bat`** 一键启动。
-推荐双击 **`Kokoro TTS.bat`** 启动托盘程序;它不依赖 Windows 的 `.pyw` 文件关联。
-`start.bat` 和 `Kokoro TTS.bat` 会直接定位 `kokoro-tts` 环境里的 Python,日常启动不需要先执行 `conda init`。`Kokoro TTS.pyw` 保留为无黑窗口启动器,但只有在 Windows 已把 `.pyw` 关联到 Python 时才适合双击。
+推荐双击 **`Kokoro TTS.bat`** 启动托盘程序;它不依赖 Windows 的 `.pyw` 文件关联,并会为当前用户创建或修复 `localreadtranslate://start` 协议。
+`start.bat` 和 `Kokoro TTS.bat` 会直接定位 `kokoro-tts` 环境里的 Python,日常启动不需要先执行 `conda init`。`start.bat` 只启动 FastAPI,不会建立 SSH 隧道;使用远程服务或网页一键启动时请用托盘程序。`Kokoro TTS.pyw` 保留为无黑窗口启动器,但只有在 Windows 已把 `.pyw` 关联到 Python 时才适合双击。
+
+也可手动注册或修复协议:
+
+```powershell
+conda run -n kokoro-tts python windows_protocol.py register
+```
+
+注册位于当前用户的 `HKCU` 注册表,无需管理员权限。其中保存项目的绝对路径,如果移动或重命名项目文件夹,需重新执行注册。
+
+如需只移除这个当前用户协议处理程序:
+
+```powershell
+conda run -n kokoro-tts python windows_protocol.py unregister
+```
### 第 4 步:安装油猴脚本
@@ -121,7 +139,11 @@ python server.py
1. 确保 TTS 服务器正在运行(第 3 步)
2. 打开任意英文网页
3. **选中一段网页文本** → 自动弹出 `Read`、`Translate` 和 `Copy` 按钮
-4. 点击 `Read` 本地朗读英文内容并后台处理公式,点击 `Translate` 使用本机 Ollama 模型翻译,或点击 `Copy` 复制原文并把公式保留为 LaTeX 🎉
+4. 点击 `Read` 本地朗读英文内容并后台处理公式,点击 `Translate` 使用当前选中的本机或远程 Ollama 模型,或点击 `Copy` 复制原文并把公式保留为 LaTeX 🎉
+5. 齿轮设置面板提供三个明确的服务操作:
+ - **Use project server**:刷新远程模型,保留当前仍可用的远程选择,否则选择第一个可用远程模型,持久化保存并检查远程 health。它不读取 SSH 凭据、不自行建立隧道;必须先在托盘 `Remote Service` 中配置并连接。
+ - **Initialize local model**:对选中的本地 Ollama 翻译模型执行 keep-alive 初始化。如果当前选择是 `remote:` 模型,脚本会要求先选本地模型。此操作不会加载 Kokoro。
+ - **Start local service**:打开 `localreadtranslate://start`。用户确认浏览器的外部应用提示后,新建托盘或唤醒已有托盘启动 FastAPI,脚本会轮询 `/health` 约 20 秒。它不会自动初始化 Kokoro 或 Ollama 模型。
> 如果在 Gemini 等页面右下角齿轮都没有出现,优先检查 Tampermonkey 是否允许在 `gemini.google.com` 运行,以及 Chrome 扩展的“站点访问权限”是否允许该域名;如果齿轮出现但选区按钮不出现,脚本会通过 `selectionchange` 兜底监听动态页面选区。脚本 UI 已移除 `innerHTML` 等 Trusted Types 会拦截的写入点。
@@ -151,14 +173,16 @@ python server.py
| `server.py` | FastAPI 本地 TTS 服务器,加载 Kokoro 模型并暴露 `/tts` API |
| `audio_encoding.py` | bundled FFmpeg 封装,用于 OGG/Opus 与 WebM/Opus |
| `tray_app.py` | **系统托盘应用** — 双击启动,自动缩到右下角托盘,右键菜单控制 |
+| `windows_protocol.py` | 当前用户的 `localreadtranslate://start` 注册、修复与固定 action 校验 |
| `windows_startup.py` | Windows Startup 快捷方式管理,用于托盘开机自启 |
| `Kokoro TTS.bat` | 推荐的托盘启动器,不依赖 `.pyw` 文件关联 |
| `Kokoro TTS.pyw` | 托盘应用启动器(无黑窗口) |
-| `tts-userscript.js` | Tampermonkey 油猴脚本,划词选中后本地朗读或本地翻译(含设置面板) |
+| `tts-userscript.js` | Tampermonkey 油猴脚本,划词选中后本地朗读,并进行本地优先、可明确远程的翻译(含设置面板) |
| `docs/greasyfork-additional-info.md` | Greasy Fork 发布页“附加信息”可直接粘贴的 Markdown |
| `requirements.txt` | Python 依赖清单 |
| `start.bat` | 一键启动服务器(终端模式,调试用) |
| `setup.bat` | 一键环境配置 |
+| `docs/iteration-4-2026-07-18.md` | 本轮服务控制、远程翻译与发布记录 |
> 💡 双击 **`Kokoro TTS.bat`** 即可启动托盘程序。
@@ -195,15 +219,15 @@ Invoke-WebRequest -Uri "http://127.0.0.1:5000/tts" `
### `GET /health` — 健康检查
-返回服务是否在线、当前设备、默认声音、语速、TTS 模型等信息。
+返回 API 与 TTS 两层状态。`api_ready` 表示本地中介服务可以接收请求;`tts_model_loaded` 表示 Kokoro 是否已经按需载入。仅使用翻译时,API 可以正常工作而 TTS 仍未载入。
### `POST /translate/model/keepalive` — 常驻当前 Ollama 模型
-设置面板的 **Keep loaded** 会调用这个接口,用 `keep_alive: -1m` 预加载当前模型,并让后续请求继续保持常驻。
+设置面板的 **Keep loaded** 和 **Initialize local model** 会调用这个接口。请求会按照所选模型的来源发送到本机或项目服务器;`Initialize local model` 只接受本机模型,并用 `keep_alive: -1m` 预加载它。
### `POST /translate/model/unload` — 卸载当前 Ollama 模型
-设置面板的 **Unload** 会调用这个接口,用 `keep_alive: 0` 卸载当前模型并释放显存。
+设置面板的 **Unload** 会把 `keep_alive: 0` 发送到当前模型所属的本机或项目服务器,以卸载模型并释放相应机器的显存。
### `GET /` — 内置测试页面(浏览器访问 http://127.0.0.1:5000)
@@ -212,7 +236,7 @@ Invoke-WebRequest -Uri "http://127.0.0.1:5000/tts" `
## ❓ 常见问题
### Q:和 Ollama 有什么关系?
-A:当前版本里,Ollama 用于**本地翻译**,Kokoro TTS 用于**本地语音朗读**。两者都由本地 FastAPI 服务调度,浏览器脚本只请求 `127.0.0.1:5000`。
+A:Ollama 用于翻译和公式口语化,可以来自本机,也可以来自你在托盘程序中配置的项目服务器;Kokoro TTS 始终在本机负责朗读。两者都由本地 FastAPI 服务调度,浏览器脚本只请求 `127.0.0.1:5000`。
### Q:Greasy Fork 发布页的 GitHub 地址放哪里?
A:脚本元信息里使用 `@homepageURL` 指向 GitHub 项目主页,`@supportURL` 指向 GitHub Issues;发布页“附加信息”正文里也建议再放一次项目地址。可直接使用 `docs/greasyfork-additional-info.md` 的内容。
@@ -221,35 +245,72 @@ A:脚本元信息里使用 `@homepageURL` 指向 GitHub 项目主页,`@suppo
A:RTX 4070 Super 上,一句普通英文几乎瞬间生成(<0.5 秒)。Kokoro 只有 82M 参数,极其轻量。
### Q:如何更换声音?
-A:修改 `server.py` 顶部的 `VOICE` 变量,或在 API 请求中指定 `"voice": "am_liam"` 等。
+A:优先在网页齿轮面板或托盘菜单中选择声音;命令行启动时也可设置 `KOKORO_VOICE` 环境变量,单次 API 请求可指定 `"voice": "am_liam"` 等。
### Q:eSpeak-NG 安装后仍报错?
A:确认已将 `C:\Program Files\eSpeak NG` 添加到系统 Path 环境变量,且**重启了终端**。
### Q:油猴脚本不工作?
-A:确认服务器正在运行(浏览器访问 `http://127.0.0.1:5000/health`),以及 Tampermonkey 脚本已启用。
+A:先确认 Tampermonkey 脚本已启用,再点击设置面板中的 **Start local service**。如果浏览器没有弹出“打开外部应用”提示,或点击后服务仍未启动,可在项目目录运行 `conda run -n kokoro-tts python windows_protocol.py register` 修复协议注册,然后访问 `http://127.0.0.1:5000/health` 验证。
+
+### Q:为什么看不到项目服务器模型?
+A:浏览器不会直接连接远程服务器。请在托盘菜单 `Remote Service` 中保存并连接远程配置,确认连接检查成功,再回到网页点击 **Use project server**。如果没有可用远端模型,脚本会明确提示先完成托盘连接。
+
+### Q:移动项目目录后 **Start local service** 失效怎么办?
+A:协议注册记录的是环境 `pythonw.exe` 和项目 `tray_app.py` 的绝对路径。移动项目或环境后重新运行 `conda run -n kokoro-tts python windows_protocol.py register` 即可修复;注册在当前用户的 `HKCU` 下,不需要管理员权限。
---
## ⚙️ 高级配置
-在 `server.py` 顶部可修改以下配置:
+服务配置优先通过环境变量设置,例如:
-```python
-HOST = "127.0.0.1" # 监听地址
-PORT = 5000 # 监听端口
-VOICE = "af_bella" # 默认声音(甜美阳光女声)
-DEVICE = "auto" # auto / cuda / cpu
-# speed 默认 0.8(稍慢于正常语速,更自然连贯)
+```powershell
+$env:KOKORO_HOST = "127.0.0.1"
+$env:KOKORO_PORT = "5000"
+$env:KOKORO_VOICE = "af_bella"
+$env:KOKORO_SPEED = "0.8"
+$env:KOKORO_DEVICE = "auto" # auto / cuda / cpu
```
-在 `tts-userscript.js` 顶部可修改:
+油猴脚本固定访问本机回环服务。远程主机、凭据和 Ollama 地址只在托盘程序中配置,不应写进 `tts-userscript.js`。
-```javascript
-const API_URL = 'http://127.0.0.1:5000/tts'; // API 地址
-```
## 远程 Ollama(局域网服务器)
-右键点击 Kokoro TTS 托盘图标,选择 `Remote Service`。填写服务器名称、IP、SSH 端口、用户名、密码,以及远程 Ollama 的主机和端口(默认 `127.0.0.1:11434`)。连接成功后,网页端 Translation 模型选择里会出现 `服务器名称 / 模型名`。
+右键点击 Kokoro TTS 托盘图标,选择 `Remote Service`。程序预填了 `10.12.96.203`,但默认不开启远端连接,普通启动仍优先使用本地。连接方式可选:
+
+- `ssh`:优先使用 SSH agent、默认密钥或 `~/.ssh/config` 中匹配的密钥;也可明确填写 key 文件。只有密钥认证失败且填写了密码时,才回退到密码认证。客户端加载系统/OpenSSH 主机密钥并拒绝未知主机,验证通过后才为远端 Ollama 建立本地隧道。
+- `api`:直接填写原生 Ollama API 地址,例如 `http://10.12.96.203:11434`,不建立 SSH 隧道。当前实现不会添加 API key 或其他认证请求头,因此只应在可信局域网或 VPN 中使用,不要把未加密的 HTTP Ollama 端口暴露到公网。
+
+连接成功后,网页端 Translation 模型选择里会出现 `服务器名称 / 模型名`。脚本绝不会因为本地模型不可用而自动切换远端;只有用户点击 **Use project server**(保留当前可用远端选择,否则选列表中的第一个远端模型)或手动选择远端条目后,才会使用项目服务器。Ollama 请求会绕过系统 HTTP 代理,避免局域网地址被代理拦截,也避免选中文本经过无关代理。
+
+浏览器脚本只连接本机 `127.0.0.1:5000`,不会拿到 SSH 密码或密钥路径。为了使用方便,托盘程序会把远程配置保存在已被 Git 忽略的 `tray_settings.json`。如果填写了 SSH 密码,它会以明文保存在该文件中;请保护 Windows 账户和项目目录,优先使用 SSH agent 或密钥文件,不要同步、提交或分享该文件。
+
+SSH 主机身份采用失败即关闭策略:客户端调用 `load_system_host_keys()` 并使用 Paramiko `RejectPolicy`。只有通过可信渠道核对指纹后,才应把主机加入 `known_hosts`。本机已经存在 `10.12.96.203` 的记录,并已用此策略实机重连成功。
+
+Kokoro TTS 模型只在第一次点击 Read 时按需加载。仅启动 API 或使用远端翻译不会初始化 Torch/Kokoro,也不会占用本地 GPU;`/health` 会分别报告 `api_ready` 与 `tts_model_loaded`。
+
+## Tampermonkey 更新与发布
+
+仓库中的 `tts-userscript.js` 当前版本为 `1.13.0`。修改仓库文件不会自动替换浏览器里已经安装的副本,发布时应按以下顺序操作:
+
+1. 在 Tampermonkey 编辑器中加载或粘贴最新脚本进行本地验证。
+2. 每次发布都递增脚本头部的 `@version`,并运行下方完整验证命令。
+3. 推送到 GitHub 后,确认脚本的 Raw 地址已经返回新版本。
+4. 在 Tampermonkey 中执行“检查用户脚本更新”,确认已安装副本升级。
+5. Greasy Fork 发布页使用同一个版本号,并同步更新附加信息。
+
+## 发布前验证
+
+```powershell
+conda run -n kokoro-tts python -m pytest tests -v
+conda run -n kokoro-tts python -m py_compile server.py audio_encoding.py tray_app.py "Kokoro TTS.pyw" tts_catalog.py windows_protocol.py windows_runtime.py windows_startup.py scripts/sync_catalog.py
+node --check tts-userscript.js
+node --test tests/userscript-core.test.cjs
+conda run -n kokoro-tts python scripts/sync_catalog.py --check
+conda run -n kokoro-tts python -c "from audio_encoding import validate_ffmpeg; validate_ffmpeg()"
+conda run -n kokoro-tts python -m pip check
+git diff --check
+```
-浏览器脚本只连接本机 `127.0.0.1:5000`,不会拿到 SSH 密码。为了使用方便,托盘程序会把远程配置保存在 `tray_settings.json`。
+本次启动协议、远程控制与安全边界的发布记录见 [`docs/iteration-4-2026-07-18.md`](docs/iteration-4-2026-07-18.md)。