From 53ecb33da9aa0d1400e9c7aeef0479637c995263 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Sun, 25 Oct 2026 04:04:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=9C=A8=E8=BF=90=E8=A1=8C=E6=9C=9F=E9=97=B4=E6=AF=8F=204=20?= =?UTF-8?q?=E5=B0=8F=E6=97=B6=E6=A3=80=E6=9F=A5=E4=B8=80=E6=AC=A1=EF=BC=8C?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=A0=8F=E7=9A=84=E3=80=8C=E9=87=8D=E5=90=AF?= =?UTF-8?q?=E4=BB=A5=E6=9B=B4=E6=96=B0=E3=80=8D=E4=B8=80=E9=94=AE=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=AE=89=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开启自动更新后,启动 10 秒后检查一次,此后每次检查结束 4 小时再检查;之后的检查只下载,下载好后不再检查,等用户点标题栏的「重启以更新」。Windows 上照旧由安装程序在后台安装,不显示窗口。 feat: AI 润色新增 Kimi、智谱 GLM、火山方舟、腾讯混元、小米 MiMo 和 OpenRouter,Kimi 与智谱可切换中国站和国际站 feat: 识别、润色和测试连接失败时显示服务返回的原因,其中的密钥和账号已遮住 fix: Google AI Studio 繁忙或思考用完输出上限时,润色会说明原因,不再只显示「请求失败」 --- AGENTS.md | 9 +- Cargo.lock | 10 + Cargo.toml | 2 + README.md | 3 +- apps/desktop/src-tauri/Cargo.toml | 2 + apps/desktop/src-tauri/src/lib.rs | 11 +- apps/desktop/src-tauri/src/update.rs | 388 +++++++-- apps/desktop/src-tauri/tests/update.rs | 51 ++ .../src/features/history/RefineNote.tsx | 35 + .../src/features/update/UpdateDialog.test.tsx | 42 + .../src/features/update/UpdateDialog.tsx | 18 +- apps/desktop/src/pages/History.test.tsx | 52 ++ .../desktop/src/pages/history/DetailParts.tsx | 8 +- .../src/pages/history/HistoryDetail.tsx | 6 + .../settings/engines/EnginesPane.test.tsx | 6 + apps/mobile-rn/rust/src/commands.rs | 5 +- apps/mobile-rn/src/features/engines.test.tsx | 26 + apps/mobile-rn/src/features/engines.tsx | 34 +- apps/mobile-rn/src/screens/Entry.test.tsx | 22 + apps/mobile-rn/src/screens/Entry.tsx | 9 + apps/mobile/src-tauri/src/lib.rs | 12 +- apps/server/src/cli.rs | 3 +- crates/voltip-asr/Cargo.toml | 2 + crates/voltip-asr/src/client.rs | 30 +- crates/voltip-asr/src/dashscope.rs | 31 +- crates/voltip-asr/src/duplex.rs | 21 +- crates/voltip-asr/src/error.rs | 66 +- crates/voltip-cloud/src/lib.rs | 37 +- crates/voltip-core/src/dictation/engine.rs | 32 + crates/voltip-core/src/dictation/fakes.rs | 10 + crates/voltip-core/src/dictation/mod.rs | 10 +- crates/voltip-core/src/dictation/ports.rs | 10 + crates/voltip-core/src/engines.rs | 18 +- crates/voltip-core/src/history/mod.rs | 5 + crates/voltip-core/src/history/revs.rs | 1 + crates/voltip-core/src/history/tests.rs | 1 + crates/voltip-core/src/lib.rs | 6 +- crates/voltip-core/src/providers.rs | 355 +++++++-- crates/voltip-core/src/runtime.rs | 4 +- crates/voltip-core/src/runtime/takes.rs | 1 + crates/voltip-core/src/runtime/texts.rs | 1 + crates/voltip-core/src/ui.rs | 1 + crates/voltip-refine/Cargo.toml | 2 + crates/voltip-refine/src/client.rs | 386 ++++++++- crates/voltip-refine/src/error.rs | 83 +- crates/voltip-reply/Cargo.toml | 16 + crates/voltip-reply/src/lib.rs | 303 +++++++ crates/voltip-tauri-bridge/tests/contract.rs | 19 +- docs/acceptance.md | 6 +- docs/architecture.md | 3 +- docs/dictation.md | 92 ++- docs/frontend.md | 4 +- docs/site/guide/updates.md | 2 +- docs/site/index.md | 6 + docs/site/privacy.md | 2 +- docs/site/recognition/cloud.md | 33 +- docs/site/recognition/polish.md | 4 +- docs/site/zh/guide/updates.md | 2 +- docs/site/zh/index.md | 6 + docs/site/zh/privacy.md | 2 +- docs/site/zh/recognition/cloud.md | 33 +- docs/site/zh/recognition/polish.md | 4 +- packages/shared/src/backend.ts | 10 +- packages/shared/src/engine-drafts.test.ts | 57 ++ packages/shared/src/engine-drafts.ts | 33 +- packages/shared/src/fixtures/ipc/events.json | 751 ++++++++++++++++-- packages/shared/src/fixtures/ipc/state.json | 149 +++- packages/shared/src/i18n/en.ts | 61 +- packages/shared/src/i18n/zh-CN.ts | 60 +- packages/shared/src/labels.test.ts | 118 ++- packages/shared/src/labels.ts | 110 ++- packages/shared/src/mock-backend.test.ts | 12 +- packages/shared/src/mock-backend.ts | 16 +- packages/shared/src/provider-marks.test.ts | 2 +- packages/shared/src/provider-marks.ts | 89 ++- packages/shared/src/providers.ts | 79 +- packages/shared/src/schema.ts | 32 +- packages/shared/src/tauri-backend.ts | 16 +- packages/ui/src/components/Pill.test.tsx | 10 + packages/ui/src/components/Pill.tsx | 11 +- .../features/engines/ProviderCard.test.tsx | 64 +- .../ui/src/features/engines/ProviderCard.tsx | 32 +- 82 files changed, 3718 insertions(+), 398 deletions(-) create mode 100644 apps/desktop/src/features/history/RefineNote.tsx create mode 100644 crates/voltip-reply/Cargo.toml create mode 100644 crates/voltip-reply/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index e71c4caa..bcd0596c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,9 @@ How to work in this repository, for coding agents and people alike. `CONTRIBUTIN - `crates/` — the Rust workspace. `voltip-core` is the platform-free application core (commands in, events out, ports for everything native); the other crates are its adapters: audio, cloud and on-device recognition, clean-up, injection, platform tables, protocol, crypto, identity, pairing, - transport, relay, the network proxy every connection follows (`voltip-net`), the local speech - service's HTTP layer (`voltip-serve`), and the Tauri bridge. + transport, relay, the network proxy every connection follows (`voltip-net`), what a service said + when it refused a request (`voltip-reply`), the local speech service's HTTP layer + (`voltip-serve`), and the Tauri bridge. - `apps/desktop`, `apps/mobile` — the Tauri 2 shells (`src-tauri/`) and their React front ends. - `apps/mobile-rn` — the phone app on React Native (Expo, `docs/mobile-rn.md`): its own Rust shell (`rust/`, UniFFI), shipped beside the Tauri phone app as `Voltip-RN__android_arm64.apk`. @@ -61,6 +62,10 @@ make help # everything else (`docs/site/README.md`). - The mock backend (`@voltip/shared/mock`) and the dev-only pages load under `import.meta.env.DEV` only; `scripts/check-web-bundle.sh` fails a release bundle that contains them. +- Vendor and brand icons follow LobeHub Icons and its designs (user decision 2026-10-25): copy the + paths of its static SVGs at a pinned commit into `packages/shared/src/provider-marks.ts`, with the + MIT notice; never fetch them at run time. The brand colour tints the tile softly; no solid brand + blocks. ## Secrets and hosts diff --git a/Cargo.lock b/Cargo.lock index 74a8b7a9..609d6ac0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7367,6 +7367,7 @@ dependencies = [ "url", "uuid", "voltip-net", + "voltip-reply", "webpki-root-certs", "wiremock", ] @@ -7735,6 +7736,7 @@ dependencies = [ "tracing", "url", "voltip-net", + "voltip-reply", "webpki-root-certs", "wiremock", ] @@ -7760,6 +7762,14 @@ dependencies = [ "voltip-protocol", ] +[[package]] +name = "voltip-reply" +version = "0.0.0" +dependencies = [ + "regex", + "serde_json", +] + [[package]] name = "voltip-serve" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 90c8af2e..5ed7ce7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/voltip-asr", "crates/voltip-asr-local", "crates/voltip-refine", + "crates/voltip-reply", "crates/voltip-cloud", "crates/voltip-inject", "crates/voltip-platform", @@ -190,6 +191,7 @@ voltip-audio = { path = "crates/voltip-audio" } voltip-asr = { path = "crates/voltip-asr" } voltip-asr-local = { path = "crates/voltip-asr-local" } voltip-refine = { path = "crates/voltip-refine" } +voltip-reply = { path = "crates/voltip-reply" } voltip-cloud = { path = "crates/voltip-cloud" } voltip-inject = { path = "crates/voltip-inject" } voltip-serve = { path = "crates/voltip-serve" } diff --git a/README.md b/README.md index a9ddf20d..730ecb0e 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ https://github.com/user-attachments/assets/858ba275-ef2b-4864-ad8c-f189f354d6a9 CPU otherwise. - Live preview while you speak (a streaming Zipformer model), and output as one block, sentence by sentence, or from the stream directly. -- Clean-up by an LLM (the same providers, plus DeepSeek and a local Ollama), a personal dictionary +- Clean-up by an LLM (the same providers, plus Google AI Studio, DeepSeek, Kimi, Zhipu GLM, + Volcengine Ark, Tencent Hunyuan, Xiaomi MiMo, OpenRouter and a local Ollama), a personal dictionary that fixes words the recogniser keeps getting wrong, literal and regex replacement rules, and Simplified / Traditional Chinese normalisation. - Voice edit: select text, hold `Ctrl+Alt+E`, say "make it more formal" or "translate to English", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 61e8d072..abe82ae5 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -165,6 +165,8 @@ tauri-utils = { version = "=2.9.3", default-features = false } # already uses). json-patch = { version = "=4.2.0", default-features = false } tempfile.workspace = true +# The automatic update's looks hours apart, on a paused clock (src/update.rs). +tokio = { workspace = true, features = ["test-util"] } voltip-audio = { workspace = true, features = ["test-support"] } voltip-pairing.workspace = true # tests/ipc.rs: pairing runs only on a relay (docs/pairing.md 「只走中继」), so the pairing tests diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b2bd5b26..41f26091 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -38,7 +38,7 @@ use tauri::{Emitter as _, Manager as _, Runtime}; use voltip_core::ui::{GuidePage, ProjectLink, UI_EVENT_NAME, UiEvent, UiState, UpdateStatus}; use voltip_core::{ Activation, AppRef, ConsolePage, CoreConfig, DictionaryDraft, EdgeSource, EngineSettings, ImportMode, Locale, OverlayPlacement, PhoneTalkMode, PresetDraft, - PreviewDraft, ProviderId, RuleDraft, SceneDraft, ServiceKind, TakeKind, ThemeId, VocabularyPreview, + PreviewDraft, ProviderId, RuleDraft, SceneDraft, ServiceKind, SiteId, TakeKind, ThemeId, VocabularyPreview, }; use voltip_identity::{KeyringSecretStore, SecretStore}; use voltip_tauri_bridge::{Bridge, BridgeError, UiCommand}; @@ -511,11 +511,12 @@ fn provider_models_refresh(bridge: tauri::State<'_, Bridge>, provider: ProviderI Ok(bridge.dispatch(UiCommand::ProviderModelsRefresh { provider, kind })?) } -/// Open the vendor's API-key page, or its page of free quota and usage, in the browser. Only -/// catalogue URLs can be opened: the webview names a provider and a page, never a URL. +/// Open the vendor's API-key page (the given site's, docs/dictation.md §3.2), or its page of free +/// quota and usage, in the browser. Only catalogue URLs can be opened: the webview names a +/// provider, a page and a site, never a URL. #[tauri::command] -fn provider_console_open(provider: ProviderId, page: Option) -> Result<(), String> { - let url = provider.console_page(page.unwrap_or_default())?; +fn provider_console_open(provider: ProviderId, page: Option, site: Option) -> Result<(), String> { + let url = provider.console_page(page.unwrap_or_default(), site)?; tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string()) } diff --git a/apps/desktop/src-tauri/src/update.rs b/apps/desktop/src-tauri/src/update.rs index 74c13bb2..359a84d7 100644 --- a/apps/desktop/src-tauri/src/update.rs +++ b/apps/desktop/src-tauri/src/update.rs @@ -8,7 +8,9 @@ //! carries them. A build without both values has no updater: the status is [`UpdateStatus::Disabled`] //! and every update command answers [`NOT_CONFIGURED`]. The URL is a static `latest.json`-style //! manifest (the format the Tauri CLI writes); the `{{target}}`, `{{arch}}`, `{{current_version}}` -//! and `{{bundle_type}}` placeholders the plugin understands are allowed in it. An optional mirror +//! and `{{bundle_type}}` placeholders the plugin understands are allowed in it. It is https, +//! except in a test build whose manifest server is on this computer or the local network +//! ([`UpdaterConfig::local_http`]). An optional mirror //! ([`UPDATE_MIRROR_ENV`], the base of the docs site's `/updates/`) is asked first and the URL //! second: the plugin moves on to the next address when one fails or does not answer 2xx, and the //! packages come from wherever the manifest that answered points. The plugin refuses to @@ -25,15 +27,25 @@ //! **Automatic path** (`Settings.auto_update`, off by default). When it is on and the updater is //! configured, the shell checks [`AUTO_CHECK_DELAY`] after start; an available package is //! downloaded in the background and published as `Ready` — nothing is installed behind the user's -//! back: the settings page offers 「立即重启更新」, which is `update_install`. The version that -//! reached `Ready` is remembered in [`MARKER_FILE_NAME`] inside the app data directory. When the user -//! instead just quits and relaunches, the next startup check finds that same version again and, with -//! auto-update still on, downloads and installs it right away and relaunches — that is the "install -//! on next start" the toggle promises. A newer version than the remembered one goes through the -//! `Ready` step again, so a package the user has never seen is never installed unattended. Turning -//! the toggle on later triggers an immediate check (download only). The downloaded package is not -//! persisted across restarts (the plugin keeps it in memory), which is why the second start -//! downloads again. +//! back: the title bar's 「重启以更新」 and the settings page offer the restart, which is +//! `update_install`. The version that reached `Ready` is remembered in [`MARKER_FILE_NAME`] inside +//! the app data directory. When the user instead just quits and relaunches, the next startup check +//! finds that same version again and, with auto-update still on, downloads and installs it right +//! away and relaunches — that is the "install on next start" the toggle promises. A newer version +//! than the remembered one goes through the `Ready` step again, so a package the user has never +//! seen is never installed unattended. Turning the toggle on later triggers an immediate check +//! (download only). The downloaded package is not persisted across restarts (the plugin keeps it in +//! memory), which is why the second start downloads again. +//! +//! **Looking again while Voltip runs** (user request 2026-10-25, after Lockra 0.16.3). An app that +//! stays in the tray for days would otherwise hear of a release only at its next start. With the +//! toggle on, every run that ends (a check by hand included) schedules the next look +//! [`AUTO_CHECK_INTERVAL`] later ([`AutoSchedule`]), keeping a look that is due sooner (the one at +//! start). These later looks only download: only the look at start may install the remembered +//! version, so a look hours later never restarts the app under the user. A package waiting in +//! `Ready` stops the looks until the restart; a failed look is tried again an interval later, +//! never in a loop. Turning the toggle off drops the look that is due. The clock is monotonic, so +//! time the computer spends asleep does not count. //! //! Everything that touches the plugin lives in [`drive`] behind the production wiring //! (`ShellOptions::global_hotkey`); the mock runtime used by `tests/ipc.rs` never registers the @@ -51,6 +63,9 @@ use tauri::Manager as _; use tauri::utils::config::BundleType; use tauri::{AppHandle, Runtime, Url}; use tauri_plugin_updater::{Update, UpdaterExt as _}; +use tokio::sync::Notify; +use tokio::sync::broadcast::error::RecvError; +use tokio::time::Instant; use voltip_core::ui::{UiEvent, UpdateStatus}; use voltip_identity::Entries; use voltip_tauri_bridge::Bridge; @@ -69,6 +84,14 @@ pub const NOT_CONFIGURED: &str = "updater: 此构建未配置更新源"; pub const BUSY: &str = "updater: 正在检查或下载更新"; /// How long after start the automatic check runs (lets the window and the core settle first). pub const AUTO_CHECK_DELAY: Duration = Duration::from_secs(10); +/// How long after a run ends the automatic update looks again while Voltip runs. +pub const AUTO_CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60); +/// Compile-time environment variable that makes a test build look again sooner than +/// [`AUTO_CHECK_INTERVAL`], in seconds (at least [`MIN_AUTO_CHECK_INTERVAL`]). The release builds +/// leave it unset. +pub const UPDATE_INTERVAL_ENV: &str = "VOLTIP_UPDATE_INTERVAL_SECS"; +/// The shortest interval [`UPDATE_INTERVAL_ENV`] can set. +pub const MIN_AUTO_CHECK_INTERVAL: Duration = Duration::from_secs(60); /// File in the app data directory remembering the version that reached `Ready` in auto mode. pub const MARKER_FILE_NAME: &str = "update-ready.json"; /// Minimum number of bytes between two `Downloading` events (keeps the event bus quiet). @@ -92,6 +115,9 @@ pub struct UpdaterConfig { pub pubkey: String, /// Delay before the automatic check at startup ([`AUTO_CHECK_DELAY`] in production). pub auto_check_delay: Duration, + /// How long after a run the automatic update looks again ([`AUTO_CHECK_INTERVAL`] in + /// production, [`UPDATE_INTERVAL_ENV`] in a test build). + pub auto_check_interval: Duration, /// [`CONNECT_TIMEOUT`] in production. pub connect_timeout: Duration, /// [`READ_TIMEOUT`] in production. @@ -102,6 +128,7 @@ impl UpdaterConfig { /// The values baked into this binary, or `None` when the URL or the key is missing. pub fn from_build() -> Option { Self::from_values(option_env!("VOLTIP_UPDATE_URL"), option_env!("VOLTIP_UPDATE_MIRROR"), option_env!("VOLTIP_UPDATE_PUBKEY")) + .map(|config| Self { auto_check_interval: auto_check_interval(option_env!("VOLTIP_UPDATE_INTERVAL_SECS")), ..config }) } /// The URL and the key present, trimmed and non-empty, and the URL parses; anything else is @@ -114,7 +141,14 @@ impl UpdaterConfig { let endpoint = web(url)?; let mirror = mirror.map(str::trim).filter(|s| !s.is_empty()).and_then(|base| web(&format!("{}/latest.json", base.trim_end_matches('/')))); let endpoints = mirror.into_iter().chain([endpoint]).collect(); - Some(Self { endpoints, pubkey: pubkey.to_owned(), auto_check_delay: AUTO_CHECK_DELAY, connect_timeout: CONNECT_TIMEOUT, read_timeout: READ_TIMEOUT }) + Some(Self { + endpoints, + pubkey: pubkey.to_owned(), + auto_check_delay: AUTO_CHECK_DELAY, + auto_check_interval: AUTO_CHECK_INTERVAL, + connect_timeout: CONNECT_TIMEOUT, + read_timeout: READ_TIMEOUT, + }) } /// The `plugins.updater` block the plugin deserialises at initialisation (injected into the @@ -128,10 +162,44 @@ impl UpdaterConfig { /// the package ([`windows_install_mode`]); the plugin reads it on Windows only. pub fn plugin_config_for(&self, bundle: Option) -> serde_json::Value { let endpoints: Vec<&str> = self.endpoints.iter().map(Url::as_str).collect(); - serde_json::json!({ "pubkey": self.pubkey, "endpoints": endpoints, "windows": { "installMode": windows_install_mode(bundle) } }) + let mut config = serde_json::json!({ "pubkey": self.pubkey, "endpoints": endpoints, "windows": { "installMode": windows_install_mode(bundle) } }); + if self.local_http() { + config["dangerousInsecureTransportProtocol"] = true.into(); + } + config + } + + /// The source asks a manifest server over plain http, and every such server is on this + /// computer or the local network: a test build pointed at a server on the LAN + /// (docs/dictation.md §9). The plugin refuses http in a release build unless told otherwise, + /// which would stop such a build from starting; a public host still needs https, and the + /// package's signature is checked either way. + pub fn local_http(&self) -> bool { + self.endpoints.iter().any(|url| url.scheme() == "http") && self.endpoints.iter().all(|url| url.scheme() != "http" || on_this_network(url)) } } +/// `url` names this computer or an address of the local network: `localhost`, a loopback address, +/// or a private or link-local IPv4 address. Host names other than `localhost` do not count, since +/// any name can resolve to a public address. +fn on_this_network(url: &Url) -> bool { + let Some(host) = url.host_str() else { return false }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + match host.trim_start_matches('[').trim_end_matches(']').parse::() { + Ok(std::net::IpAddr::V4(ip)) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), + Ok(std::net::IpAddr::V6(ip)) => ip.is_loopback(), + Err(_) => false, + } +} + +/// The interval [`UPDATE_INTERVAL_ENV`] sets, in seconds and at least +/// [`MIN_AUTO_CHECK_INTERVAL`]; [`AUTO_CHECK_INTERVAL`] when it is unset or not a number. +pub fn auto_check_interval(secs: Option<&str>) -> Duration { + secs.and_then(|text| text.trim().parse::().ok()).map_or(AUTO_CHECK_INTERVAL, |secs| Duration::from_secs(secs).max(MIN_AUTO_CHECK_INTERVAL)) +} + /// How the plugin runs a Windows package (docs/dictation.md §9; user request 2026-10-17: an /// in-app update showed the installer's window). The NSIS installer Voltip ships installs for the /// current user and needs no elevation, so it runs silently (`/S`) and opens Voltip again when it @@ -194,6 +262,57 @@ impl ProgressGate { } } +/// When the automatic update looks next (module documentation, 「Looking again while Voltip +/// runs」). Times are `tokio::time::Instant`, so the tests run hours on a paused clock. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AutoSchedule { + /// `Settings.auto_update` as last read. + on: bool, + /// When the next look is due; `None` when none is. + at: Option, + /// The next look may install the version the marker remembers: true for the look at start + /// alone, which finishes what an earlier start downloaded. + installs: bool, +} + +impl AutoSchedule { + /// The setting as first read after start: on → a look `delay` from `now`, which may install. + pub fn start(&mut self, on: bool, delay: Duration, now: Instant) { + *self = Self { on, at: on.then(|| now + delay), installs: on }; + } + + /// The toggle moved: on → a look at once, download only; off → no look at all. + pub fn switch(&mut self, on: bool, now: Instant) { + *self = Self { on, at: on.then_some(now), installs: false }; + } + + /// A run ended on `status`: with the toggle on, look again `interval` from `now`, or keep a look + /// that is due sooner (the one at start). A package waiting for the restart needs no more looks, + /// and an install in progress none at all. + pub fn look_again(&mut self, status: &UpdateStatus, interval: Duration, now: Instant) { + if !self.on || matches!(status, UpdateStatus::Ready { .. } | UpdateStatus::Installing { .. }) { + return; + } + let next = now + interval; + self.at = Some(self.at.map_or(next, |at| at.min(next))); + } + + /// When the next look is due. + pub fn due_at(&self) -> Option { + self.at + } + + /// The look due at `now`, if one is: `Some(installs)`. Taking it spends the permission to + /// install, so only the look at start ever has it. + pub fn take_due(&mut self, now: Instant) -> Option { + if !self.at.is_some_and(|at| now >= at) { + return None; + } + self.at = None; + Some(std::mem::take(&mut self.installs)) + } +} + /// Unix time in seconds. pub fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or_default() @@ -227,6 +346,11 @@ pub struct UpdateSlot { status: Mutex, pending: Mutex>, busy: AtomicBool, + /// The automatic looks ([`follow_settings`] keeps them; every run's end moves them on). Taken + /// after `status` when both are held. + schedule: Mutex, + /// Wakes [`follow_settings`] when a run's end has moved the schedule. + wake: Notify, } impl std::fmt::Debug for UpdateSlot { @@ -246,6 +370,8 @@ impl UpdateSlot { status: Mutex::new(status), pending: Mutex::new(None), busy: AtomicBool::new(false), + schedule: Mutex::new(AutoSchedule::default()), + wake: Notify::new(), } } @@ -265,6 +391,16 @@ impl UpdateSlot { self.config.as_ref().map_or(Duration::ZERO, |c| c.auto_check_delay) } + /// How long after a run the automatic update looks again. + pub fn auto_check_interval(&self) -> Duration { + self.config.as_ref().map_or(AUTO_CHECK_INTERVAL, |c| c.auto_check_interval) + } + + /// The automatic looks as they stand. + pub fn schedule(&self) -> AutoSchedule { + self.schedule.lock().clone() + } + /// Last published status (what `update_status` answers). pub fn status(&self) -> UpdateStatus { self.status.lock().clone() @@ -273,15 +409,22 @@ impl UpdateSlot { /// Record `status` and broadcast it like a core event. The broadcast happens under the status /// lock, so the events go out in the order the statuses were recorded. pub fn publish(&self, bridge: &Bridge, status: UpdateStatus) { + self.publish_with(status, |status| bridge.publish(UiEvent::Update(status))); + } + + /// [`Self::publish`] with the broadcast handed in. + fn publish_with(&self, status: UpdateStatus, broadcast: impl FnOnce(UpdateStatus)) { let mut current = self.status.lock(); *current = status.clone(); - bridge.publish(UiEvent::Update(status)); + broadcast(status); } /// End a run: release the slot, then broadcast the run's final status (if it has one). Whoever /// reacts to that status may start the next run at once (CI 2026-09-29: 「安装」 right after /// 「已是最新」 was refused as busy); a run that starts in between waits for the lock, so its - /// `checking` still goes out after this status. + /// `checking` still goes out after this status. With auto-update on, the next automatic look is + /// scheduled from here ([`AutoSchedule::look_again`]); an install schedules none, and still + /// releases the slot and broadcasts. pub fn finish(&self, bridge: &Bridge, last: Option) { self.finish_with(last, |status| bridge.publish(UiEvent::Update(status))); } @@ -290,8 +433,12 @@ impl UpdateSlot { fn finish_with(&self, last: Option, broadcast: impl FnOnce(UpdateStatus)) { let mut current = self.status.lock(); self.end(); - if let Some(status) = last { + if let Some(status) = &last { *current = status.clone(); + } + self.schedule.lock().look_again(¤t, self.auto_check_interval(), Instant::now()); + self.wake.notify_one(); + if let Some(status) = last { broadcast(status); } } @@ -517,53 +664,63 @@ fn prepare_then_install( install(package) } -/// Follow `Settings.auto_update`: on at startup → check after [`AUTO_CHECK_DELAY`] (installing only -/// the version remembered as `Ready`); turned on later → check now, download only. +/// Follow `Settings.auto_update` and keep the automatic looks ([`AutoSchedule`]): on at startup → +/// a look after [`AUTO_CHECK_DELAY`], the only one that may install (the version remembered as +/// `Ready`); turned on later → a look now, download only; turned off → no look; each run that ends +/// while it is on schedules the next look ([`UpdateSlot::finish`]). One task: it sleeps until the +/// next look is due and wakes on every settings change and every run's end. pub fn follow_settings(app: AppHandle, bridge: Bridge, slot: Arc) { let mut events = bridge.events(); let initial = bridge.state(); // `Ready` may already have been folded before this subscription: read it from the cache. let mut current: Option = initial.identity.is_some().then_some(initial.settings.auto_update); - if current == Some(true) { - schedule_auto(&app, &bridge, &slot, true); + if let Some(on) = current { + slot.schedule.lock().start(on, slot.auto_check_delay(), Instant::now()); } tauri::async_runtime::spawn(async move { loop { - let next = match events.recv().await { - Ok(UiEvent::Settings(s)) => Some(s.auto_update), - Ok(UiEvent::State(state)) => Some(state.settings.auto_update), - Ok(_) => None, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => None, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + let due = slot.schedule.lock().due_at(); + let next = tokio::select! { + event = events.recv() => match event { + Ok(UiEvent::Settings(s)) => Some(s.auto_update), + Ok(UiEvent::State(state)) => Some(state.settings.auto_update), + Ok(_) | Err(RecvError::Lagged(_)) => None, + Err(RecvError::Closed) => break, + }, + () = slot.wake.notified() => None, + () = sleep_until(due) => { + look(&app, &bridge, &slot); + None + } }; let Some(next) = next else { continue }; - let startup = current.is_none(); - if current == Some(next) { - continue; + match current { + None => slot.schedule.lock().start(next, slot.auto_check_delay(), Instant::now()), + Some(was) if was != next => slot.schedule.lock().switch(next, Instant::now()), + Some(_) => {} } current = Some(next); - if next { - schedule_auto(&app, &bridge, &slot, startup); - } } }); } -/// Spawn the automatic run: delayed and marker-aware at startup, immediate after a toggle. -fn schedule_auto(app: &AppHandle, bridge: &Bridge, slot: &Arc, startup: bool) { - let (app, bridge, slot) = (app.clone(), bridge.clone(), slot.clone()); - tauri::async_runtime::spawn(async move { - let install_version = if startup { - tokio::time::sleep(slot.auto_check_delay()).await; - slot.read_marker() - } else { - None - }; - tracing::info!(startup, ?install_version, "automatic update check"); - if let Err(e) = request(&app, &bridge, &slot, Intent::Auto { install_version }) { - tracing::info!(error = %e, "automatic update check skipped"); - } - }); +/// Wait until `due`; for good when nothing is. +async fn sleep_until(due: Option) { + match due { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } +} + +/// Run the automatic look that is due, if one is. A look that falls due during another run (the +/// user's) is dropped: that run answers the same question and schedules the next look as it ends. +fn look(app: &AppHandle, bridge: &Bridge, slot: &Arc) { + let Some(installs) = slot.schedule.lock().take_due(Instant::now()) else { return }; + let install_version = if installs { slot.read_marker() } else { None }; + tracing::info!(installs, ?install_version, "automatic update check"); + if let Err(e) = request(app, bridge, slot, Intent::Auto { install_version }) { + tracing::info!(error = %e, "automatic update check skipped"); + } } #[cfg(test)] @@ -705,6 +862,145 @@ mod tests { assert_eq!(describe(&Layer("builder error", None)), "builder error"); } + /// User request 2026-10-25: with auto-update on, Voltip looks again while it runs, and only the + /// look at start may install. + #[tokio::test(start_paused = true)] + async fn the_automatic_looks_follow_the_toggle_and_each_runs_end() { + let up_to_date = UpdateStatus::UpToDate { version: "0.1.8".into(), checked_at: 1 }; + let mut schedule = AutoSchedule::default(); + // Off at start: nothing is due, and a run's end schedules nothing. + schedule.start(false, AUTO_CHECK_DELAY, Instant::now()); + schedule.look_again(&up_to_date, AUTO_CHECK_INTERVAL, Instant::now()); + assert_eq!(schedule.due_at(), None); + tokio::time::advance(3 * AUTO_CHECK_INTERVAL).await; + assert_eq!(schedule.take_due(Instant::now()), None); + + // On at start: one look after the delay, which may install. + let started = Instant::now(); + schedule.start(true, AUTO_CHECK_DELAY, started); + assert_eq!(schedule.due_at(), Some(started + AUTO_CHECK_DELAY)); + // A check by hand that ends first keeps the look at start, which is due sooner. + schedule.look_again(&up_to_date, AUTO_CHECK_INTERVAL, started + Duration::from_secs(1)); + assert_eq!(schedule.due_at(), Some(started + AUTO_CHECK_DELAY)); + tokio::time::advance(AUTO_CHECK_DELAY - Duration::from_millis(1)).await; + assert_eq!(schedule.take_due(Instant::now()), None, "not due yet"); + tokio::time::advance(Duration::from_millis(1)).await; + assert_eq!(schedule.take_due(Instant::now()), Some(true), "the look at start may install"); + assert_eq!(schedule.take_due(Instant::now()), None, "taken once"); + + // Its end schedules the next look an interval later, which only downloads. + let ended = Instant::now(); + schedule.look_again(&up_to_date, AUTO_CHECK_INTERVAL, ended); + assert_eq!(schedule.due_at(), Some(ended + AUTO_CHECK_INTERVAL)); + tokio::time::advance(AUTO_CHECK_INTERVAL - Duration::from_secs(1)).await; + assert_eq!(schedule.take_due(Instant::now()), None); + tokio::time::advance(Duration::from_secs(1)).await; + assert_eq!(schedule.take_due(Instant::now()), Some(false), "a later look never installs"); + + // A failed look is tried again an interval later, never at once. + let failed = UpdateStatus::Failed { message: "offline".into() }; + schedule.look_again(&failed, AUTO_CHECK_INTERVAL, Instant::now()); + assert_eq!(schedule.due_at(), Some(Instant::now() + AUTO_CHECK_INTERVAL)); + + // A package waiting for the restart, or an install, stops the looks. + for waiting in [UpdateStatus::Ready { version: "0.1.9".into() }, UpdateStatus::Installing { version: "0.1.9".into() }] { + schedule.switch(true, Instant::now()); + assert_eq!(schedule.take_due(Instant::now()), Some(false), "turned on: a look at once, download only"); + schedule.look_again(&waiting, AUTO_CHECK_INTERVAL, Instant::now()); + assert_eq!(schedule.due_at(), None, "{waiting:?}"); + } + + // Turned off: the look that is due goes, and so does the permission to install. + schedule.start(true, AUTO_CHECK_DELAY, Instant::now()); + schedule.switch(false, Instant::now()); + assert_eq!(schedule, AutoSchedule { on: false, at: None, installs: false }); + schedule.look_again(&up_to_date, AUTO_CHECK_INTERVAL, Instant::now()); + tokio::time::advance(3 * AUTO_CHECK_INTERVAL).await; + assert_eq!(schedule.take_due(Instant::now()), None); + schedule.switch(true, Instant::now()); + assert_eq!(schedule.take_due(Instant::now()), Some(false), "on again: it only downloads"); + } + + /// The end of every run moves the schedule on through the slot itself: the next look an + /// interval later, and nothing after an install, which still frees the slot. + #[tokio::test(start_paused = true)] + async fn a_runs_end_schedules_the_next_look_through_the_slot() { + let dir = tempfile::tempdir().unwrap(); + let mut config = UpdaterConfig::from_values(Some("https://x.example/latest.json"), None, Some("k")).unwrap(); + config.auto_check_interval = Duration::from_secs(90); + let slot = UpdateSlot::new(Some(config), dir.path()); + assert_eq!(slot.auto_check_interval(), Duration::from_secs(90)); + slot.schedule.lock().start(true, AUTO_CHECK_DELAY, Instant::now()); + tokio::time::advance(AUTO_CHECK_DELAY).await; + assert_eq!(slot.schedule.lock().take_due(Instant::now()), Some(true)); + assert!(slot.try_begin()); + slot.finish_with(Some(UpdateStatus::UpToDate { version: "0.1.8".into(), checked_at: 1 }), |_| {}); + assert_eq!(slot.schedule().due_at(), Some(Instant::now() + Duration::from_secs(90))); + // A run that installed: the status stands at `installing`, nothing more is scheduled. + slot.schedule.lock().switch(true, Instant::now()); + assert_eq!(slot.schedule.lock().take_due(Instant::now()), Some(false)); + slot.publish_with(UpdateStatus::Installing { version: "0.1.9".into() }, |_| {}); + assert!(slot.try_begin()); + let mut sent = false; + slot.finish_with(None, |_| sent = true); + assert!(!sent, "no final status: nothing more to send"); + assert_eq!(slot.schedule().due_at(), None); + assert!(slot.try_begin(), "and the slot is free"); + // Without an updater the interval is still the production one. + assert_eq!(UpdateSlot::new(None, dir.path()).auto_check_interval(), AUTO_CHECK_INTERVAL); + } + + #[test] + fn a_test_build_can_look_again_sooner_but_not_in_a_loop() { + assert_eq!(auto_check_interval(None), AUTO_CHECK_INTERVAL); + assert_eq!(auto_check_interval(Some("")), AUTO_CHECK_INTERVAL); + assert_eq!(auto_check_interval(Some("soon")), AUTO_CHECK_INTERVAL, "not a number"); + assert_eq!(auto_check_interval(Some("-5")), AUTO_CHECK_INTERVAL); + assert_eq!(auto_check_interval(Some(" 120 ")), Duration::from_secs(120)); + assert_eq!(auto_check_interval(Some("1")), MIN_AUTO_CHECK_INTERVAL, "at least a minute"); + assert_eq!(auto_check_interval(Some("0")), MIN_AUTO_CHECK_INTERVAL); + let cfg = UpdaterConfig::from_values(Some("https://updates.example.test/latest.json"), None, Some("key")).unwrap(); + assert_eq!(cfg.auto_check_interval, AUTO_CHECK_INTERVAL); + } + + /// A test build pointed at a manifest server on the LAN starts and checks; a public host + /// still needs https. + #[test] + fn plain_http_is_allowed_only_on_this_computer_and_the_local_network() { + let source = |url: &str, mirror: Option<&str>| UpdaterConfig::from_values(Some(url), mirror, Some("key")).unwrap(); + let insecure = |cfg: &UpdaterConfig| cfg.plugin_config().get("dangerousInsecureTransportProtocol").cloned(); + for local in [ + "http://192.168.157.161:18765/latest.json", + "http://10.0.0.2/latest.json", + "http://172.16.4.1/latest.json", + "http://127.0.0.1:8080/latest.json", + "http://localhost:8080/latest.json", + "http://LOCALHOST/latest.json", + "http://169.254.10.1/latest.json", + "http://[::1]:8080/latest.json", + ] { + let cfg = source(local, None); + assert!(cfg.local_http(), "{local}"); + assert_eq!(insecure(&cfg), Some(serde_json::Value::Bool(true)), "{local}"); + // The plugin takes the block, here and in a release build alike. + let read: tauri_plugin_updater::Config = serde_json::from_value(cfg.plugin_config()).unwrap(); + assert!(read.dangerous_insecure_transport_protocol, "{local}"); + } + for public in + ["http://8.8.8.8/latest.json", "http://updates.example.test/latest.json", "http://code-server:8080/latest.json", "http://[2001:db8::1]/latest.json"] + { + let cfg = source(public, None); + assert!(!cfg.local_http(), "{public}"); + assert_eq!(insecure(&cfg), None, "{public}"); + } + let https = source("https://updates.example.test/latest.json", None); + assert!(!https.local_http(), "https needs nothing"); + assert_eq!(insecure(&https), None); + // One public http endpoint among local ones keeps the plugin's rule. + assert!(!source("http://192.168.1.2/latest.json", Some("http://updates.example.test/updates")).local_http()); + assert!(source("http://192.168.1.2/latest.json", Some("https://mirror.example.test/updates")).local_http()); + } + #[test] fn intents_download_and_install_as_documented() { assert!(!Intent::Check.downloads()); diff --git a/apps/desktop/src-tauri/tests/update.rs b/apps/desktop/src-tauri/tests/update.rs index a69f0e19..d1cd928f 100644 --- a/apps/desktop/src-tauri/tests/update.rs +++ b/apps/desktop/src-tauri/tests/update.rs @@ -154,6 +154,8 @@ fn with_tuned_updater_app( endpoints: vec![format!("{}{MANIFEST_PATH}", server.uri()).parse().unwrap()], pubkey: PUBKEY.into(), auto_check_delay: Duration::from_millis(200), + // The looks while the app runs stay out of the way unless a test shortens them. + auto_check_interval: Duration::from_secs(3600), connect_timeout: voltip_desktop_lib::update::CONNECT_TIMEOUT, read_timeout: voltip_desktop_lib::update::READ_TIMEOUT, }; @@ -393,3 +395,52 @@ fn toggling_auto_update_on_checks_immediately_and_runs_one_at_a_time() { }), ); } + +/// User request 2026-10-25: with auto-update on, Voltip looks again while it runs, without any +/// command, an interval after each run ends; the look at start comes first. +#[test] +fn regression_auto_update_looks_again_while_voltip_runs() { + const INTERVAL: Duration = Duration::from_millis(600); + with_tuned_updater_app( + no_update, + |config| config.auto_check_interval = INTERVAL, + |_| Settings { auto_update: true, ..Settings::default() }, + Box::new(|_, webview, rx| { + wait_state(webview, |s| s.identity.is_some() && s.settings.auto_update); + wait_update(rx, "checking", &[]); + wait_update(rx, "up_to_date", &["checking"]); + let first = Instant::now(); + wait_update(rx, "checking", &[]); + // Scheduled an interval after the first look ended, not in a loop (the exact times are + // the unit tests' on a paused clock; the event reached this thread after that end). + assert!(first.elapsed() >= INTERVAL / 4, "looked again after {:?}", first.elapsed()); + wait_update(rx, "up_to_date", &["checking"]); + // And again: every look schedules the next. + wait_update(rx, "checking", &[]); + wait_update(rx, "up_to_date", &["checking"]); + }), + ); +} + +/// With auto-update off, a check by hand schedules nothing: Voltip goes online only when asked. +#[test] +fn a_check_by_hand_with_auto_update_off_schedules_no_look() { + const INTERVAL: Duration = Duration::from_millis(300); + with_tuned_updater_app( + no_update, + |config| config.auto_check_interval = INTERVAL, + |_| Settings::default(), + Box::new(|app, webview, rx| { + wait_state(webview, |s| s.identity.is_some()); + assert_eq!(invoke(webview, "update_check", json!({})), Ok(Value::Null)); + wait_update(rx, "checking", &[]); + wait_update(rx, "up_to_date", &["checking"]); + assert_eq!(app.state::>().schedule().due_at(), None); + std::thread::sleep(3 * INTERVAL); + while let Ok(raw) = rx.try_recv() { + let json: Value = serde_json::from_str(&raw).unwrap(); + assert!(json["type"] != "update" || json["state"] != "checking", "nothing looks on its own with the toggle off: {json}"); + } + }), + ); +} diff --git a/apps/desktop/src/features/history/RefineNote.tsx b/apps/desktop/src/features/history/RefineNote.tsx new file mode 100644 index 00000000..33d84240 --- /dev/null +++ b/apps/desktop/src/features/history/RefineNote.tsx @@ -0,0 +1,35 @@ +import { type HistoryEntry, serviceReasonText } from "@voltip/shared"; +import { useI18n } from "@voltip/ui"; + +/** The kinds of failure whose entry keeps the service's explanation (docs/dictation.md §3.6). */ +type RequestFailure = "rate_limited" | "quota" | "failed"; + +function requestFailure(entry: HistoryEntry): RequestFailure | undefined { + const failure = entry.refine_failure; + return failure === "rate_limited" || failure === "quota" || failure === "failed" + ? failure + : undefined; +} + +/** Under an entry whose clean-up request failed (docs/dictation.md §3.6): one sentence for the + * kind of failure and what to do about it, then why, with what the service said (§3.12). Nothing + * for an entry written before the reason was kept. */ +export function RefineNote({ entry }: { entry: HistoryEntry }) { + const { t, locale } = useI18n(); + const failure = requestFailure(entry); + if (failure === undefined || entry.refine_error === undefined) return null; + return ( +
+

{t(`history.refineNote.${failure}`)}

+

+ {t("history.refineNote.reason")} + + {serviceReasonText(entry.refine_error, locale)} + +

+
+ ); +} diff --git a/apps/desktop/src/features/update/UpdateDialog.test.tsx b/apps/desktop/src/features/update/UpdateDialog.test.tsx index c44cc364..b254a145 100644 --- a/apps/desktop/src/features/update/UpdateDialog.test.tsx +++ b/apps/desktop/src/features/update/UpdateDialog.test.tsx @@ -184,6 +184,48 @@ describe("UpdateDialog", () => { expect(screen.getByTestId("update-badge")).toHaveTextContent("下载中 21%"); }); + it("regression: a downloaded update restarts from the title bar in one click", async () => { + // User request 2026-10-25: with auto-update on, the package arrives in the background; the + // title bar's 重启以更新 is the go-ahead, and the dialog shows the install, not a second question. + vi.useFakeTimers({ shouldAdvanceTime: true }); + const core = backend(); + const invoke = vi.spyOn(core, "invoke"); + renderApp({ backend: core }); + const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) }); + await screen.findByRole("heading", { name: "首页", level: 1 }); + act(() => { + core.simulateUpdate({ state: "ready", version: "0.0.2" }); + }); + const badge = await screen.findByTestId("update-badge"); + expect(badge).toHaveTextContent("重启以更新"); + expect(badge).toHaveAttribute("title", "重启 Voltip,完成更新到 0.0.2"); + invoke.mockClear(); + await user.click(badge); + await waitFor(() => { + expect(screen.getByTestId("update-dialog")).toHaveAttribute("data-state", "installing"); + }); + expect(screen.getByRole("dialog", { name: "正在安装 0.0.2" })).toBeInTheDocument(); + expect(invoke.mock.calls.filter(([command]) => command === "update_install")).toHaveLength(1); + expect(screen.queryByTestId("update-badge")).toBeNull(); + }); + + it("the title bar's other notes only open the dialog", async () => { + const core = backend(); + const invoke = vi.spyOn(core, "invoke"); + renderApp({ backend: core }); + const user = userEvent.setup(); + await screen.findByRole("heading", { name: "首页", level: 1 }); + act(() => { + core.simulateUpdate({ state: "available", version: "0.0.2", current: "0.0.1" }); + }); + const badge = await screen.findByTestId("update-badge"); + expect(badge).toHaveAttribute("title", "查看更新"); + invoke.mockClear(); + await user.click(badge); + expect(screen.getByRole("dialog", { name: "发现新版本 0.0.2" })).toBeInTheDocument(); + expect(invoke.mock.calls.some(([command]) => command === "update_install")).toBe(false); + }); + it("a failed update says why and retries; the settings row opens the same dialog", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); const core = backend(); diff --git a/apps/desktop/src/features/update/UpdateDialog.tsx b/apps/desktop/src/features/update/UpdateDialog.tsx index 6fcf4770..dc84e2cf 100644 --- a/apps/desktop/src/features/update/UpdateDialog.tsx +++ b/apps/desktop/src/features/update/UpdateDialog.tsx @@ -219,12 +219,16 @@ export function UpdateDialog({ open, onClose }: UpdateDialogProps) { ); } -/** The title bar's note that an update is waiting: the version, the download's progress, or the - * restart it needs. Nothing when there is nothing to update. */ +/** The title bar's note that an update is waiting: the version or the download's progress, which + * open the dialog, or the restart a downloaded update needs, which one click starts: the dialog + * opens on its install (docs/frontend.md §7). Nothing when there is nothing to update. */ export function UpdateBadge({ onOpen }: { onOpen: () => void }) { const { t } = useI18n(); + const { backend } = useBackend(); const { update } = useUiState(); let label: string; + let title = t("update.badgeTitle"); + let onClick = onOpen; switch (update.state) { case "available": label = t("update.badge.available", { version: update.version }); @@ -236,6 +240,12 @@ export function UpdateBadge({ onOpen }: { onOpen: () => void }) { break; case "ready": label = t("update.badge.ready"); + title = t("update.badgeRestartTitle", { version: update.version }); + // The click is the user's go-ahead: no second question in the dialog. + onClick = () => { + onOpen(); + void backend.invoke("update_install"); + }; break; default: return null; @@ -244,8 +254,8 @@ export function UpdateBadge({ onOpen }: { onOpen: () => void }) { )} diff --git a/apps/mobile-rn/src/screens/Entry.test.tsx b/apps/mobile-rn/src/screens/Entry.test.tsx index b9f50c02..5e52517c 100644 --- a/apps/mobile-rn/src/screens/Entry.test.tsx +++ b/apps/mobile-rn/src/screens/Entry.test.tsx @@ -533,6 +533,28 @@ describe("纠正识别错误 on the phone", () => { }); }); +describe("a take whose polish failed", () => { + it("regression: says why in the interface's words and keeps the service's own (docs/dictation.md §3.12)", async () => { + // 2026-10-25: the row showed the client's English words, 「refine service overloaded (HTTP 503)」. + await openEntry({ + history: [ + take(1, RAW, RAW, { + refined: false, + refine_ms: undefined, + refine_failure: "rate_limited", + refine_error: + "服务繁忙:refine service overloaded (HTTP 503): This model is currently experiencing high demand.", + }), + ], + }); + expect(screen.getByText("润色失败原因")).toBeOnTheScreen(); + expect( + screen.getByText("服务繁忙:This model is currently experiencing high demand."), + ).toBeOnTheScreen(); + expect(screen.queryByText(/refine service/)).toBeNull(); + }); +}); + describe("the words' layout (wordUnits)", () => { it("sticks punctuation to its word, an opening bracket to the next, and breaks lines", () => { const text = "他说「你好」,然后 OK. (再见)\n下一行"; diff --git a/apps/mobile-rn/src/screens/Entry.tsx b/apps/mobile-rn/src/screens/Entry.tsx index aa8ac4b6..875661a1 100644 --- a/apps/mobile-rn/src/screens/Entry.tsx +++ b/apps/mobile-rn/src/screens/Entry.tsx @@ -23,6 +23,7 @@ import { presetRefLabel, refineModelText, sceneLabel, + serviceReasonText, shortClockLabel, takePolishPairs, tapPiece, @@ -486,6 +487,14 @@ export function Entry() { mono={entry.refine_model !== undefined} /> )} + {!sent && !entry.refined && entry.refine_error !== undefined && ( + // docs/dictation.md §3.12: why the polish failed, with what the service said. + + )} {!sent && entry.preset !== undefined && ( , provider: ProviderI Ok(bridge.dispatch(UiCommand::ProviderModelsRefresh { provider, kind })?) } -/// Open the vendor's API-key page, or its page of free quota and usage, in the phone's browser. -/// Only catalogue URLs can be opened: the webview names a provider and a page, never a URL (as on -/// the desktop). +/// Open the vendor's API-key page (the given site's), or its page of free quota and usage, in the +/// phone's browser. Only catalogue URLs can be opened: the webview names a provider, a page and a +/// site, never a URL (as on the desktop). #[tauri::command] -fn provider_console_open(app: AppHandle, provider: ProviderId, page: Option) -> Result<(), String> { - let url = provider.console_page(page.unwrap_or_default())?; +fn provider_console_open(app: AppHandle, provider: ProviderId, page: Option, site: Option) -> Result<(), String> { + let url = provider.console_page(page.unwrap_or_default(), site)?; open_in_browser(&app, url) } diff --git a/apps/server/src/cli.rs b/apps/server/src/cli.rs index 876be14b..65c921ab 100644 --- a/apps/server/src/cli.rs +++ b/apps/server/src/cli.rs @@ -60,7 +60,8 @@ pub struct Cli { /// The speech recognition provider (builtin, local, openai, groq, siliconflow, aliyun, custom). #[arg(long, value_name = "PROVIDER", value_parser = parse_provider)] pub asr: Option, - /// The AI polish provider (builtin, openai, groq, siliconflow, aliyun, deepseek, ollama, custom). + /// The AI polish provider (builtin, openai, groq, google, siliconflow, aliyun, deepseek, kimi, glm, + /// volcengine, hunyuan, mimo, openrouter, ollama, custom). #[arg(long, value_name = "PROVIDER", value_parser = parse_provider)] pub llm: Option, /// Recognise with this local model (see --list-models); implies --asr local. diff --git a/crates/voltip-asr/Cargo.toml b/crates/voltip-asr/Cargo.toml index 39e24b4e..bedf1e2e 100644 --- a/crates/voltip-asr/Cargo.toml +++ b/crates/voltip-asr/Cargo.toml @@ -30,6 +30,8 @@ url.workspace = true uuid.workspace = true # The proxy of the requests and of the realtime models' WebSocket (docs/dictation.md §24). voltip-net.workspace = true +# What the service said when it refused a request, for the errors (docs/dictation.md §3.12). +voltip-reply.workspace = true [target.'cfg(target_os = "android")'.dependencies] webpki-root-certs.workspace = true diff --git a/crates/voltip-asr/src/client.rs b/crates/voltip-asr/src/client.rs index fc4fb3b5..c81325ea 100644 --- a/crates/voltip-asr/src/client.rs +++ b/crates/voltip-asr/src/client.rs @@ -202,18 +202,20 @@ struct Upload { } /// A non-2xx answer: a used-up quota first, whatever the status (docs/dictation.md §3.5; some -/// services send OpenAI's `insufficient_quota` with 403, others with 429), then by status. +/// services send OpenAI's `insufficient_quota` with 403, others with 429), then by status, each +/// with what the service said (§3.12). fn http_error(status: StatusCode, retry_after_ms: Option, body: &str) -> AsrError { if let Some(quota) = quota_error(body) { return quota; } + let message = voltip_reply::summary(body); if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { - return AsrError::Unauthorized; + return AsrError::Unauthorized { message }; } if status == StatusCode::TOO_MANY_REQUESTS { - return AsrError::RateLimited { retry_after_ms }; + return AsrError::RateLimited { retry_after_ms, message }; } - AsrError::Server { status: status.as_u16(), body: truncate_chars(body, MAX_ERROR_BODY_CHARS) } + AsrError::Server { status: status.as_u16(), body: message } } /// A reqwest failure, split into timeout and everything else. @@ -486,7 +488,7 @@ mod tests { let server = MockServer::start().await; mount(&server, ResponseTemplate::new(status).set_body_string("nope")).await; let err = client(&server, Some("bad")).transcribe(WAV, None).await.unwrap_err(); - assert_eq!(err, AsrError::Unauthorized, "status {status}"); + assert_eq!(err, AsrError::Unauthorized { message: "nope".into() }, "status {status}"); assert!(!err.is_retryable()); } } @@ -512,10 +514,16 @@ mod tests { } let server = MockServer::start().await; mount(&server, ResponseTemplate::new(429).set_body_string(r#"{"error":{"code":"rate_limit_exceeded","message":"Rate limit reached"}}"#)).await; - assert_eq!(client(&server, None).transcribe(WAV, None).await.unwrap_err(), AsrError::RateLimited { retry_after_ms: None }); + assert_eq!( + client(&server, None).transcribe(WAV, None).await.unwrap_err(), + AsrError::RateLimited { retry_after_ms: None, message: "Rate limit reached (rate_limit_exceeded)".into() } + ); let server = MockServer::start().await; mount(&server, ResponseTemplate::new(403).set_body_string(r#"{"error":{"code":"invalid_api_key","message":"Incorrect API key provided"}}"#)).await; - assert_eq!(client(&server, None).transcribe(WAV, None).await.unwrap_err(), AsrError::Unauthorized); + assert_eq!( + client(&server, None).transcribe(WAV, None).await.unwrap_err(), + AsrError::Unauthorized { message: "Incorrect API key provided (invalid_api_key)".into() } + ); } #[tokio::test] @@ -523,17 +531,17 @@ mod tests { let server = MockServer::start().await; mount(&server, ResponseTemplate::new(429).insert_header("Retry-After", "3")).await; let err = client(&server, None).transcribe(WAV, None).await.unwrap_err(); - assert_eq!(err, AsrError::RateLimited { retry_after_ms: Some(3000) }); + assert_eq!(err, AsrError::RateLimited { retry_after_ms: Some(3000), message: String::new() }); assert!(err.is_retryable()); let server = MockServer::start().await; mount(&server, ResponseTemplate::new(429).insert_header("Retry-After", "Wed, 21 Oct 2015 07:28:00 GMT")).await; let err = client(&server, None).transcribe(WAV, None).await.unwrap_err(); - assert_eq!(err, AsrError::RateLimited { retry_after_ms: None }); + assert_eq!(err, AsrError::RateLimited { retry_after_ms: None, message: String::new() }); let server = MockServer::start().await; mount(&server, ResponseTemplate::new(429)).await; - assert_eq!(client(&server, None).transcribe(WAV, None).await.unwrap_err(), AsrError::RateLimited { retry_after_ms: None }); + assert_eq!(client(&server, None).transcribe(WAV, None).await.unwrap_err(), AsrError::RateLimited { retry_after_ms: None, message: String::new() }); } #[tokio::test] @@ -554,7 +562,7 @@ mod tests { let server = MockServer::start().await; mount(&server, ResponseTemplate::new(400).set_body_json(json!({ "error": "unsupported audio" }))).await; let err = client(&server, None).transcribe(WAV, None).await.unwrap_err(); - assert_eq!(err, AsrError::Server { status: 400, body: r#"{"error":"unsupported audio"}"#.into() }); + assert_eq!(err, AsrError::Server { status: 400, body: "unsupported audio".into() }, "the service's words, not its JSON"); assert!(!err.is_retryable()); } diff --git a/crates/voltip-asr/src/dashscope.rs b/crates/voltip-asr/src/dashscope.rs index 5acd49db..d9b2827c 100644 --- a/crates/voltip-asr/src/dashscope.rs +++ b/crates/voltip-asr/src/dashscope.rs @@ -339,20 +339,21 @@ pub(crate) fn service_error(status: StatusCode, retry_after_ms: Option, bod return quota; } let (code, message) = error_fields(body); + // What the service said (docs/dictation.md §3.12): its code and message, else the body. + let said = if code.is_empty() { voltip_reply::summary(body) } else { voltip_reply::clean(&format!("{code}: {message}")) }; if status == StatusCode::UNAUTHORIZED || code == "InvalidApiKey" { - return AsrError::Unauthorized; + return AsrError::Unauthorized { message: said }; } if status == StatusCode::TOO_MANY_REQUESTS || code.starts_with("Throttling") { - return AsrError::RateLimited { retry_after_ms }; + return AsrError::RateLimited { retry_after_ms, message: said }; } if status.is_server_error() || code.is_empty() { if status == StatusCode::FORBIDDEN && code.is_empty() { - return AsrError::Unauthorized; + return AsrError::Unauthorized { message: said }; } - let shown = if code.is_empty() { body.to_owned() } else { format!("{code}: {message}") }; - return AsrError::Server { status: status.as_u16(), body: truncate_chars(&shown, MAX_ERROR_BODY_CHARS) }; + return AsrError::Server { status: status.as_u16(), body: said }; } - AsrError::Service { code, message: truncate_chars(&message, MAX_ERROR_BODY_CHARS) } + AsrError::Service { code, message: voltip_reply::clean(&message) } } /// A WAV's sample format: where its PCM is and at what rate. @@ -604,7 +605,11 @@ pub(crate) mod tests { #[tokio::test] async fn service_errors_are_sorted() { let cases = [ - (401, json!({ "code": "InvalidApiKey", "message": "Invalid API-key provided." }), AsrError::Unauthorized), + ( + 401, + json!({ "code": "InvalidApiKey", "message": "Invalid API-key provided." }), + AsrError::Unauthorized { message: "InvalidApiKey: Invalid API-key provided.".into() }, + ), ( 403, json!({ "code": "AllocationQuota.FreeTierOnly", "message": "The free tier of the model has been exhausted." }), @@ -625,10 +630,14 @@ pub(crate) mod tests { json!({ "code": "InvalidParameter", "message": "Model not exist.", "request_id": "r" }), AsrError::Service { code: "InvalidParameter".into(), message: "Model not exist.".into() }, ), - (429, json!({ "code": "Throttling.RateQuota", "message": "Requests rate limit exceeded" }), AsrError::RateLimited { retry_after_ms: None }), - (400, json!({ "code": "Throttling", "message": "busy" }), AsrError::RateLimited { retry_after_ms: None }), + ( + 429, + json!({ "code": "Throttling.RateQuota", "message": "Requests rate limit exceeded" }), + AsrError::RateLimited { retry_after_ms: None, message: "Throttling.RateQuota: Requests rate limit exceeded".into() }, + ), + (400, json!({ "code": "Throttling", "message": "busy" }), AsrError::RateLimited { retry_after_ms: None, message: "Throttling: busy".into() }), (500, json!({ "code": "InternalError", "message": "oops" }), AsrError::Server { status: 500, body: "InternalError: oops".into() }), - (403, json!({}), AsrError::Unauthorized), + (403, json!({}), AsrError::Unauthorized { message: String::new() }), ( 403, json!({ "code": "Model.AccessDenied", "message": "no access" }), @@ -650,7 +659,7 @@ pub(crate) mod tests { let server = MockServer::start().await; Mock::given(method("POST")).respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "2")).mount(&server).await; let err = client(&server, "qwen3-asr-flash", DashscopeMode::Chat).transcribe(&wav(10, 16_000), None, &[]).await.unwrap_err(); - assert_eq!(err, AsrError::RateLimited { retry_after_ms: Some(2000) }); + assert_eq!(err, AsrError::RateLimited { retry_after_ms: Some(2000), message: String::new() }); let server = MockServer::start().await; Mock::given(method("POST")).respond_with(ResponseTemplate::new(200).set_body_string("not json")).mount(&server).await; let err = client(&server, "qwen3-asr-flash", DashscopeMode::Chat).transcribe(&wav(10, 16_000), None, &[]).await.unwrap_err(); diff --git a/crates/voltip-asr/src/duplex.rs b/crates/voltip-asr/src/duplex.rs index f2c242c2..a7273390 100644 --- a/crates/voltip-asr/src/duplex.rs +++ b/crates/voltip-asr/src/duplex.rs @@ -215,14 +215,15 @@ fn bad_event(e: &serde_json::Error, text: &str) -> AsrError { fn task_failed(value: &Value) -> AsrError { let field = |key: &str| value.pointer(&format!("/header/{key}")).and_then(Value::as_str).unwrap_or_default().to_owned(); let (code, message) = (field("error_code"), field("error_message")); + let said = || voltip_reply::clean(&if message.is_empty() { code.clone() } else { format!("{code}: {message}") }); if is_quota_exhausted(&code, &message) { - AsrError::QuotaExhausted { code, message: truncate_chars(&message, MAX_ERROR_BODY_CHARS) } + AsrError::QuotaExhausted { message: voltip_reply::clean(&message), code } } else if code.starts_with("Throttling") { - AsrError::RateLimited { retry_after_ms: None } + AsrError::RateLimited { retry_after_ms: None, message: said() } } else if code == "InvalidApiKey" { - AsrError::Unauthorized + AsrError::Unauthorized { message: said() } } else { - AsrError::Service { code, message: truncate_chars(&message, MAX_ERROR_BODY_CHARS) } + AsrError::Service { message: voltip_reply::clean(&message), code } } } @@ -494,7 +495,10 @@ pub(crate) mod tests { #[tokio::test] async fn refusals_and_failures_are_sorted() { let (url, _) = serve(Script::Refuse(401, r#"{"code":"InvalidApiKey","message":"Invalid API-key provided."}"#)).await; - assert_eq!(connect(&options(&url, Some("bad"))).await.err(), Some(AsrError::Unauthorized)); + assert_eq!( + connect(&options(&url, Some("bad"))).await.err(), + Some(AsrError::Unauthorized { message: "InvalidApiKey: Invalid API-key provided.".into() }) + ); let (url, _) = serve(Script::Refuse(403, r#"{"code":"AllocationQuota.FreeTierOnly","message":"free tier exhausted"}"#)).await; assert_eq!( connect(&options(&url, Some("k"))).await.err(), @@ -506,14 +510,17 @@ pub(crate) mod tests { Some(AsrError::Service { code: "ModelNotFound".into(), message: "Model not found (qwen3-asr-flash-realtime)!".into() }) ); let (url, _) = serve(Script::FailAtStart("Throttling.RateQuota", "slow down")).await; - assert_eq!(connect(&options(&url, Some("k"))).await.err(), Some(AsrError::RateLimited { retry_after_ms: None })); + assert_eq!( + connect(&options(&url, Some("k"))).await.err(), + Some(AsrError::RateLimited { retry_after_ms: None, message: "Throttling.RateQuota: slow down".into() }) + ); let (url, _) = serve(Script::FailAtStart("AllocationQuota.FreeTierOnly", "x")).await; assert_eq!( connect(&options(&url, Some("k"))).await.err(), Some(AsrError::QuotaExhausted { code: "AllocationQuota.FreeTierOnly".into(), message: "x".into() }) ); let (url, _) = serve(Script::FailAtStart("InvalidApiKey", "no")).await; - assert_eq!(connect(&options(&url, None)).await.err(), Some(AsrError::Unauthorized)); + assert_eq!(connect(&options(&url, None)).await.err(), Some(AsrError::Unauthorized { message: "InvalidApiKey: no".into() })); // A whole take on a task that fails reports the task's reason. let (url, _) = serve(Script::FailAtStart("InvalidParameter", "sample_rate must be 16000")).await; let err = client_at(&url, "qwen-audio-3.1-asr-flash-message").transcribe(&crate::dashscope::tests::wav(1600, 8_000), None, &[]).await.unwrap_err(); diff --git a/crates/voltip-asr/src/error.rs b/crates/voltip-asr/src/error.rs index 4e6a701a..650db30d 100644 --- a/crates/voltip-asr/src/error.rs +++ b/crates/voltip-asr/src/error.rs @@ -1,26 +1,36 @@ //! What can go wrong talking to the ASR service. /// ASR failures, sorted by what the caller can do about them. +/// +/// The interface shows the opening of each message in its own language and the service's words +/// after it (`serviceReasonText` in packages/shared/src/labels.ts, docs/dictation.md §3.12): a +/// changed `#[error]` text changes that table too. #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum AsrError { /// The configuration cannot produce a request (bad URL, empty model, zero timeout). #[error("invalid ASR configuration: {0}")] InvalidConfig(String), /// The service answered 401 or 403: the token is missing, wrong or expired. - #[error("ASR rejected the credentials")] - Unauthorized, + #[error("ASR rejected the credentials{}", said(.message))] + Unauthorized { + /// What the service said ([`voltip_reply::summary`]); empty when it said nothing. + message: String, + }, /// The service answered 429. - #[error("ASR rate limited{}", retry_suffix(.retry_after_ms))] + #[error("ASR rate limited{}{}", retry_suffix(.retry_after_ms), said(.message))] RateLimited { /// `Retry-After` in milliseconds when the service sent one as a delay in seconds. retry_after_ms: Option, + /// What the service said ([`voltip_reply::summary`]); empty when it said nothing. + message: String, }, /// Any other non-2xx answer. - #[error("ASR server error {status}: {body}")] + #[error("ASR server error {status}{}", said(.body))] Server { /// HTTP status code. status: u16, - /// Response body, cut to [`crate::MAX_ERROR_BODY_CHARS`] characters. + /// What the service said ([`voltip_reply::summary`]): its error's message and code, a + /// page's title or the text, cut to [`crate::MAX_ERROR_BODY_CHARS`] characters. body: String, }, /// DNS, TCP, TLS or a connection dropped mid-request. @@ -63,7 +73,9 @@ impl AsrError { match self { Self::RateLimited { .. } | Self::Network(_) | Self::Timeout => true, Self::Server { status, .. } => *status >= 500, - Self::InvalidConfig(_) | Self::Unauthorized | Self::BadResponse(_) | Self::Service { .. } | Self::QuotaExhausted { .. } | Self::Audio(_) => false, + Self::InvalidConfig(_) | Self::Unauthorized { .. } | Self::BadResponse(_) | Self::Service { .. } | Self::QuotaExhausted { .. } | Self::Audio(_) => { + false + } } } } @@ -76,32 +88,28 @@ pub fn is_quota_exhausted(code: &str, message: &str) -> bool { code.contains("FreeTierOnly") || message.contains("FreeTierOnly") || code == "insufficient_quota" } -/// The `code` and `message` of an error body, OpenAI's form (`{"error": {"code", "message"}}`) or -/// Model Studio's native one (`{"code", "message"}`); empty strings when the body has neither. +/// The `code` and `message` of an error body in every shape the providers send +/// ([`voltip_reply::error_fields`]); empty strings when the body has neither. pub(crate) fn error_fields(body: &str) -> (String, String) { - let Ok(value) = serde_json::from_str::(body) else { return (String::new(), String::new()) }; - let field = |v: &serde_json::Value, key: &str| match v.get(key) { - Some(serde_json::Value::String(s)) => s.clone(), - Some(serde_json::Value::Null) | None => String::new(), - Some(other) => other.to_string(), - }; - match value.get("error") { - Some(inner @ serde_json::Value::Object(_)) => (field(inner, "code"), field(inner, "message")), - _ => (field(&value, "code"), field(&value, "message")), - } + let fields = voltip_reply::error_fields(body); + (fields.code, fields.message) } /// [`AsrError::QuotaExhausted`] when the error body says so, whatever the HTTP status. pub(crate) fn quota_error(body: &str) -> Option { let (code, message) = error_fields(body); - is_quota_exhausted(&code, &message) - .then(|| AsrError::QuotaExhausted { code, message: crate::client::truncate_chars(&message, crate::MAX_ERROR_BODY_CHARS) }) + is_quota_exhausted(&code, &message).then(|| AsrError::QuotaExhausted { code, message: voltip_reply::clean(&message) }) } fn retry_suffix(retry_after_ms: &Option) -> String { retry_after_ms.map_or_else(String::new, |ms| format!(" (retry after {ms} ms)")) } +/// `: `, or nothing when it said nothing. +fn said(message: &str) -> String { + if message.is_empty() { String::new() } else { format!(": {message}") } +} + #[cfg(test)] mod tests { use super::*; @@ -109,10 +117,18 @@ mod tests { #[test] fn messages_are_plain() { assert_eq!(AsrError::InvalidConfig("base_url is empty".into()).to_string(), "invalid ASR configuration: base_url is empty"); - assert_eq!(AsrError::Unauthorized.to_string(), "ASR rejected the credentials"); - assert_eq!(AsrError::RateLimited { retry_after_ms: None }.to_string(), "ASR rate limited"); - assert_eq!(AsrError::RateLimited { retry_after_ms: Some(3000) }.to_string(), "ASR rate limited (retry after 3000 ms)"); + assert_eq!(AsrError::Unauthorized { message: String::new() }.to_string(), "ASR rejected the credentials"); + assert_eq!( + AsrError::Unauthorized { message: "Invalid API Key (invalid_api_key)".into() }.to_string(), + "ASR rejected the credentials: Invalid API Key (invalid_api_key)" + ); + assert_eq!(AsrError::RateLimited { retry_after_ms: None, message: String::new() }.to_string(), "ASR rate limited"); + assert_eq!( + AsrError::RateLimited { retry_after_ms: Some(3000), message: "slow down".into() }.to_string(), + "ASR rate limited (retry after 3000 ms): slow down" + ); assert_eq!(AsrError::Server { status: 502, body: "bad gateway".into() }.to_string(), "ASR server error 502: bad gateway"); + assert_eq!(AsrError::Server { status: 500, body: String::new() }.to_string(), "ASR server error 500"); assert_eq!(AsrError::Network("connection refused".into()).to_string(), "ASR network error: connection refused"); assert_eq!(AsrError::Timeout.to_string(), "ASR request timed out"); assert_eq!(AsrError::BadResponse("not json".into()).to_string(), "ASR returned an unexpected response: not json"); @@ -130,14 +146,14 @@ mod tests { #[test] fn retryable_classification() { - assert!(AsrError::RateLimited { retry_after_ms: None }.is_retryable()); + assert!(AsrError::RateLimited { retry_after_ms: None, message: String::new() }.is_retryable()); assert!(AsrError::Network("x".into()).is_retryable()); assert!(AsrError::Timeout.is_retryable()); assert!(AsrError::Server { status: 500, body: String::new() }.is_retryable()); assert!(AsrError::Server { status: 503, body: String::new() }.is_retryable()); assert!(!AsrError::Server { status: 400, body: String::new() }.is_retryable()); assert!(!AsrError::Server { status: 404, body: String::new() }.is_retryable()); - assert!(!AsrError::Unauthorized.is_retryable()); + assert!(!AsrError::Unauthorized { message: String::new() }.is_retryable()); assert!(!AsrError::InvalidConfig(String::new()).is_retryable()); assert!(!AsrError::BadResponse(String::new()).is_retryable()); assert!(!AsrError::Service { code: String::new(), message: String::new() }.is_retryable()); diff --git a/crates/voltip-cloud/src/lib.rs b/crates/voltip-cloud/src/lib.rs index 5c785348..417e1ddb 100644 --- a/crates/voltip-cloud/src/lib.rs +++ b/crates/voltip-cloud/src/lib.rs @@ -147,12 +147,14 @@ pub fn asr_error(error: voltip_asr::AsrError) -> DictationError { DictationError::Asr(error.to_string()) } -/// The core's error for a failed clean-up or edit: a used-up quota (docs/dictation.md §3.5) and a -/// rate limit (§3.6) keep their kind; anything else is `Refine` with the reason. +/// The core's error for a failed clean-up or edit: a used-up quota (docs/dictation.md §3.5), a +/// rate limit and an overloaded service (§3.6) keep their kind; anything else is `Refine` with the +/// reason. pub fn refine_error(error: voltip_refine::RefineError) -> DictationError { match error { voltip_refine::RefineError::QuotaExhausted { .. } => DictationError::QuotaExhausted { service: ServiceKind::Llm, detail: error.to_string() }, voltip_refine::RefineError::RateLimited { .. } => DictationError::RateLimited { service: ServiceKind::Llm, detail: error.to_string() }, + voltip_refine::RefineError::Overloaded { .. } => DictationError::Overloaded { service: ServiceKind::Llm, detail: error.to_string() }, _ => DictationError::Refine(error.to_string()), } } @@ -336,14 +338,17 @@ impl ServiceProbe for HttpServiceProbe { async fn list_models(&self, base_url: &str, key: Option<&str>) -> Result, ProbeError> { voltip_refine::list_models(&openai_compatible_base(base_url), key, PROBE_TIMEOUT, &self.network).await.map_err(|e| { use voltip_refine::RefineError as E; + // What the service said, or the connection's error, goes with the reason (§3.12). + let status = |status: u16, said: String| ProbeError { reason: ProbeFailure::HttpStatus, status: Some(status), detail: None }.with_detail(said); match e { E::InvalidConfig(_) => ProbeError::new(ProbeFailure::InvalidUrl), - E::Unauthorized => ProbeError::new(ProbeFailure::Unauthorized), - E::RateLimited { .. } => ProbeError { reason: ProbeFailure::HttpStatus, status: Some(429) }, - E::Server { status, .. } => ProbeError { reason: ProbeFailure::HttpStatus, status: Some(status) }, - E::Network(_) => ProbeError::new(ProbeFailure::Unreachable), + E::Unauthorized { message } => ProbeError::new(ProbeFailure::Unauthorized).with_detail(message), + E::RateLimited { message, .. } => status(429, message), + E::Overloaded { message } => status(503, message), + E::Server { status: code, body } => status(code, body), + E::Network(message) => ProbeError::new(ProbeFailure::Unreachable).with_detail(message), E::Timeout => ProbeError::new(ProbeFailure::Timeout), - _ => ProbeError::new(ProbeFailure::BadResponse), + other => ProbeError::new(ProbeFailure::BadResponse).with_detail(other.to_string()), } }) } @@ -456,16 +461,28 @@ mod tests { let asr = asr_error(AsrError::QuotaExhausted { code: "AllocationQuota.FreeTierOnly".into(), message: "free tier exhausted".into() }); assert!(matches!(&asr, DictationError::QuotaExhausted { service: ServiceKind::Asr, detail } if detail.contains("FreeTierOnly")), "{asr:?}"); assert!(asr.is_quota_exhausted()); - assert_eq!(asr_error(AsrError::Unauthorized), DictationError::Asr("ASR rejected the credentials".into())); + assert_eq!( + asr_error(AsrError::Unauthorized { message: "Invalid API Key (invalid_api_key)".into() }), + DictationError::Asr("ASR rejected the credentials: Invalid API Key (invalid_api_key)".into()), + "the service's words reach the take's failure (§3.12)" + ); let llm = refine_error(RefineError::QuotaExhausted { code: "insufficient_quota".into(), message: "m".into() }); assert!(matches!(&llm, DictationError::QuotaExhausted { service: ServiceKind::Llm, .. }), "{llm:?}"); assert_eq!(refine_error(RefineError::Timeout), DictationError::Refine("refine request timed out".into())); // docs/dictation.md §3.6: a clean-up's 429 keeps its kind (the interface says the service is // busy), and it is not a used-up quota: no fallback model is tried for it. - let busy = refine_error(RefineError::RateLimited { retry_after_ms: Some(355_000) }); + let busy = refine_error(RefineError::RateLimited { retry_after_ms: Some(355_000), message: String::new() }); assert_eq!(busy, DictationError::RateLimited { service: ServiceKind::Llm, detail: "refine service rate limited (retry after 355000 ms)".into() }); assert!(!busy.is_quota_exhausted()); - assert_eq!(asr_error(AsrError::RateLimited { retry_after_ms: None }), DictationError::Asr("ASR rate limited".into()), "recognition's is unchanged"); + // 2026-10-24: so does a 503 (Gemini's "high demand"), as an overloaded service. + let overloaded = refine_error(RefineError::Overloaded { message: "high demand".into() }); + assert_eq!(overloaded, DictationError::Overloaded { service: ServiceKind::Llm, detail: "refine service overloaded (HTTP 503): high demand".into() }); + assert!(!overloaded.is_quota_exhausted()); + assert_eq!( + asr_error(AsrError::RateLimited { retry_after_ms: None, message: String::new() }), + DictationError::Asr("ASR rate limited".into()), + "recognition's is unchanged" + ); let used_up = serde_json::json!({ "error": { "code": "insufficient_quota", "message": "You exceeded your current quota" } }); let server = MockServer::start().await; diff --git a/crates/voltip-core/src/dictation/engine.rs b/crates/voltip-core/src/dictation/engine.rs index 1123e3fe..6ed6e4fb 100644 --- a/crates/voltip-core/src/dictation/engine.rs +++ b/crates/voltip-core/src/dictation/engine.rs @@ -2491,6 +2491,11 @@ impl DictationEngine { asr_ms, refine_ms, refine_failure, + // The fixed sentences of a clean-up that never ran (unconfigured, too long) and of an + // empty answer say no more than their kind; a failed request carries the service's words. + refine_error: refine_failure + .filter(|f| matches!(f, RefineFailure::RateLimited | RefineFailure::Quota | RefineFailure::Failed)) + .and(refine_error.clone()), outcome: Outcome::Failed { reason: String::new() }, starred: false, mode, @@ -3352,6 +3357,33 @@ mod tests { assert_eq!(r.injector.injected(), vec!["原文".to_owned()]); } + /// Regression (2026-10-24, Google AI Studio): a clean-up the service refused left only its kind + /// in the history (「未润色 · 请求失败」), and the desktop writes no log file, so nothing said + /// why. The entry keeps the service's explanation for a failed request; a clean-up that never + /// ran or came back empty has nothing to add to its kind. + #[tokio::test(start_paused = true)] + async fn regression_the_history_keeps_why_the_clean_up_failed() { + for (refiner, kind, reason) in [ + (Some(FakeRefiner::overloaded()), Some(RefineFailure::RateLimited), Some("服务繁忙:fake: more demand than the model can serve")), + ( + Some(FakeRefiner::err("refine server error 404: no longer available")), + Some(RefineFailure::Failed), + Some("refine: refine server error 404: no longer available"), + ), + (Some(FakeRefiner::quota()), Some(RefineFailure::Quota), Some("额度已用完:fake: the model's quota is used up")), + (Some(FakeRefiner::ok(" ")), Some(RefineFailure::Empty), None), + (None, Some(RefineFailure::Unconfigured), None), + (Some(FakeRefiner::ok("整理过。")), None, None), + ] { + let mut r = rig(FakeAudio::speech(), FakeTranscriber::ok("原文"), refiner, FakeInjector::paste(), true); + r.start_open().await; + r.engine.stop().unwrap(); + let fx = r.run_to_terminal().await; + let entry = record(&fx).unwrap(); + assert_eq!((entry.refine_failure, entry.refine_error.as_deref()), (kind, reason), "{fx:?}"); + } + } + /// docs/dictation.md §3.6: the take says why its clean-up's text was not used by kind, on the /// phase and in the history; a take that asked for none names nothing. #[tokio::test(start_paused = true)] diff --git a/crates/voltip-core/src/dictation/fakes.rs b/crates/voltip-core/src/dictation/fakes.rs index 2543370a..90fdf2b2 100644 --- a/crates/voltip-core/src/dictation/fakes.rs +++ b/crates/voltip-core/src/dictation/fakes.rs @@ -547,6 +547,9 @@ pub const FAKE_QUOTA_DETAIL: &str = "fake: the model's quota is used up"; /// The explanation a fake's rate limit carries (docs/dictation.md §3.6). pub const FAKE_RATE_LIMIT_DETAIL: &str = "fake: too many requests"; +/// The explanation a fake's overloaded service carries (docs/dictation.md §3.6). +pub const FAKE_OVERLOADED_DETAIL: &str = "fake: more demand than the model can serve"; + impl FakeRefiner { fn with_reply(reply: Reply) -> Self { Self { reply, calls: AtomicUsize::new(0), inputs: Mutex::new(Vec::new()), edits: Mutex::new(Vec::new()), model: FAKE_REFINE_MODEL.to_owned() } @@ -562,6 +565,12 @@ impl FakeRefiner { Self::with_reply(Reply::Err(DictationError::RateLimited { service: crate::providers::ServiceKind::Llm, detail: FAKE_RATE_LIMIT_DETAIL.into() })) } + /// Always fails as a service with more requests than it can serve (HTTP 503, docs/dictation.md + /// §3.6). + pub fn overloaded() -> Self { + Self::with_reply(Reply::Err(DictationError::Overloaded { service: crate::providers::ServiceKind::Llm, detail: FAKE_OVERLOADED_DETAIL.into() })) + } + /// Its answers report `model` as the model that wrote them. pub fn with_model(self, model: &str) -> Self { Self { model: model.to_owned(), ..self } @@ -1204,6 +1213,7 @@ pub fn history_entry(text: &str) -> crate::HistoryEntry { asr_ms: FAKE_LATENCY_MS, refine_ms: None, refine_failure: None, + refine_error: None, outcome: crate::Outcome::Inserted { via: Via::Paste }, starred: false, mode: crate::OutputMode::WholeTake, diff --git a/crates/voltip-core/src/dictation/mod.rs b/crates/voltip-core/src/dictation/mod.rs index 440ee0ee..2163743a 100644 --- a/crates/voltip-core/src/dictation/mod.rs +++ b/crates/voltip-core/src/dictation/mod.rs @@ -184,6 +184,8 @@ impl From<&DictationError> for FailureCode { DictationError::QuotaExhausted { .. } => Self::Quota, DictationError::RateLimited { service: crate::providers::ServiceKind::Asr, .. } => Self::Asr, DictationError::RateLimited { service: crate::providers::ServiceKind::Llm, .. } => Self::Refine, + DictationError::Overloaded { service: crate::providers::ServiceKind::Asr, .. } => Self::Asr, + DictationError::Overloaded { service: crate::providers::ServiceKind::Llm, .. } => Self::Refine, DictationError::Busy | DictationError::Idle => Self::Unknown, } } @@ -194,7 +196,8 @@ impl From<&DictationError> for FailureCode { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RefineFailure { - /// The service turned the request down for now: too many requests (HTTP 429). + /// The service turned the request down for now: too many requests (HTTP 429), or more than it + /// can serve (HTTP 503). RateLimited, /// The model's quota is used up and no fallback model had any left (§3.5). Quota, @@ -212,7 +215,7 @@ impl RefineFailure { /// The kind of a clean-up that failed with `error`. pub fn of(error: &DictationError) -> Self { match error { - DictationError::RateLimited { .. } => Self::RateLimited, + DictationError::RateLimited { .. } | DictationError::Overloaded { .. } => Self::RateLimited, DictationError::QuotaExhausted { .. } => Self::Quota, _ => Self::Failed, } @@ -561,6 +564,7 @@ mod tests { fn refine_failures_sort_the_error_and_serialize_snake_case() { use crate::providers::ServiceKind::Llm; assert_eq!(RefineFailure::of(&DictationError::RateLimited { service: Llm, detail: "429".into() }), RefineFailure::RateLimited); + assert_eq!(RefineFailure::of(&DictationError::Overloaded { service: Llm, detail: "503".into() }), RefineFailure::RateLimited, "a busy service"); assert_eq!(RefineFailure::of(&DictationError::QuotaExhausted { service: Llm, detail: "x".into() }), RefineFailure::Quota); assert_eq!(RefineFailure::of(&DictationError::Refine("timed out".into())), RefineFailure::Failed); let wire = [ @@ -600,6 +604,8 @@ mod tests { (DictationError::QuotaExhausted { service: crate::providers::ServiceKind::Llm, detail: "x".into() }, FailureCode::Quota, "quota"), (DictationError::RateLimited { service: crate::providers::ServiceKind::Asr, detail: "x".into() }, FailureCode::Asr, "asr"), (DictationError::RateLimited { service: crate::providers::ServiceKind::Llm, detail: "x".into() }, FailureCode::Refine, "refine"), + (DictationError::Overloaded { service: crate::providers::ServiceKind::Asr, detail: "x".into() }, FailureCode::Asr, "asr"), + (DictationError::Overloaded { service: crate::providers::ServiceKind::Llm, detail: "x".into() }, FailureCode::Refine, "refine"), (DictationError::Busy, FailureCode::Unknown, "unknown"), (DictationError::Idle, FailureCode::Unknown, "unknown"), ]; diff --git a/crates/voltip-core/src/dictation/ports.rs b/crates/voltip-core/src/dictation/ports.rs index 76a274bc..0dae9e3b 100644 --- a/crates/voltip-core/src/dictation/ports.rs +++ b/crates/voltip-core/src/dictation/ports.rs @@ -72,6 +72,16 @@ pub enum DictationError { /// The adapter's explanation. detail: String, }, + /// The service has more requests than it can serve right now (HTTP 503, docs/dictation.md §3.6): + /// Gemini's "This model is currently experiencing high demand". Like a rate limit it passes, + /// and it is not a used-up quota: a fallback model list does not move on for it. + #[error("服务繁忙:{detail}")] + Overloaded { + /// The service that refused. + service: crate::providers::ServiceKind, + /// The adapter's explanation. + detail: String, + }, /// The text could not be handed to the foreground application. #[error("inject: {0}")] Inject(String), diff --git a/crates/voltip-core/src/engines.rs b/crates/voltip-core/src/engines.rs index 6923307d..54889726 100644 --- a/crates/voltip-core/src/engines.rs +++ b/crates/voltip-core/src/engines.rs @@ -13,7 +13,7 @@ use crate::dictation::fallback::{QuotaKey, QuotaLedger, open_order}; use crate::models::{DEFAULT_LOCAL_MODEL_ID, ModelState}; use crate::presets::PresetId; pub use crate::providers::{AsrProtocol, KeyPolicy, LlmApi, ProviderId, ReasoningEffort, ServiceKind}; -use crate::providers::{PROVIDERS, key_entry}; +use crate::providers::{PROVIDERS, SiteId, key_entry}; /// The built-in recognition model's name when the build names none (`VOLTIP_ASR_MODEL`). pub const DEFAULT_ASR_MODEL: &str = "Qwen/Qwen3-ASR-1.7B"; @@ -1028,6 +1028,7 @@ fn provider_statuses( issue: None, active, listed: Vec::new(), + sites: Vec::new(), }) } ProviderId::Local => Some(ServiceStatus { @@ -1039,6 +1040,7 @@ fn provider_statuses( issue: (!local.installed).then_some(EngineIssue::ModelNotInstalled), active, listed: Vec::new(), + sites: Vec::new(), }), _ => { let target = service_target(provider, kind, settings, secrets, built_in); @@ -1058,6 +1060,7 @@ fn provider_statuses( issue, active, listed: Vec::new(), + sites: preset.sites.iter().map(|site| SiteStatus { id: site.id, base_url: site.base_url.to_owned() }).collect(), }) } } @@ -1131,6 +1134,19 @@ pub struct ServiceStatus { /// values; empty until a list arrived. Every model select offers them after `presets`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub listed: Vec, + /// The vendor's sites, the default first (docs/dictation.md §3.2); empty for a vendor with one + /// address. The card's site switch writes one of their URLs into the URL field. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sites: Vec, +} + +/// One of a vendor's sites, as the card's site switch offers it ([`crate::providers::ProviderSite`]). +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +pub struct SiteStatus { + /// Which site. + pub id: SiteId, + /// The service's base URL there. + pub base_url: String, } /// `UiState.engines`: the resolved configuration without any key, and without the built-in diff --git a/crates/voltip-core/src/history/mod.rs b/crates/voltip-core/src/history/mod.rs index 94535b85..74599c20 100644 --- a/crates/voltip-core/src/history/mod.rs +++ b/crates/voltip-core/src/history/mod.rs @@ -116,6 +116,11 @@ pub struct HistoryEntry { /// it was, when none was asked for, and in entries written before it. #[serde(default, skip_serializing_if = "Option::is_none")] pub refine_failure: Option, + /// The service's explanation when the clean-up's request failed (`refine_failure` is + /// `rate_limited`, `quota` or `failed`), as the client reported it: the history detail's + /// technical details (docs/dictation.md §3.6). Absent otherwise and in entries written before it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refine_error: Option, /// How it ended. pub outcome: Outcome, /// User flag. diff --git a/crates/voltip-core/src/history/revs.rs b/crates/voltip-core/src/history/revs.rs index a1653c9f..7e10a414 100644 --- a/crates/voltip-core/src/history/revs.rs +++ b/crates/voltip-core/src/history/revs.rs @@ -268,6 +268,7 @@ fn shallow(entry: &HistoryEntry) -> HistoryEntry { asr_ms: entry.asr_ms, refine_ms: entry.refine_ms, refine_failure: None, + refine_error: None, outcome: Outcome::Failed { reason: String::new() }, starred: entry.starred, mode: entry.mode, diff --git a/crates/voltip-core/src/history/tests.rs b/crates/voltip-core/src/history/tests.rs index 1cf5d7ed..f6c10cdc 100644 --- a/crates/voltip-core/src/history/tests.rs +++ b/crates/voltip-core/src/history/tests.rs @@ -22,6 +22,7 @@ pub(super) fn entry(text: &str) -> HistoryEntry { asr_ms: 420, refine_ms: Some(300), refine_failure: None, + refine_error: None, outcome: Outcome::Inserted { via: Via::Paste }, starred: false, mode: OutputMode::WholeTake, diff --git a/crates/voltip-core/src/lib.rs b/crates/voltip-core/src/lib.rs index fd5edf56..6589b473 100644 --- a/crates/voltip-core/src/lib.rs +++ b/crates/voltip-core/src/lib.rs @@ -40,7 +40,7 @@ pub use dictation::{ pub use engines::{ BuiltIn, ChineseScript, EngineIssue, EngineSettings, EngineStatus, FallbackModel, FallbackModelStatus, FallbackPlan, FallbackRow, FallbackRowState, FallbackSettings, FallbackSkip, FallbackStatus, FallbackTarget, InjectMode, LiveSource, LocalDevice, LocalModelRef, MAX_FALLBACK_MODELS, MAX_LOCAL_THREADS, - OutputMode, ProviderSettings, ProviderStatus, RemoteService, ResolvedEngines, SecretSource, SecretState, ServiceStatus, UserSecrets, + OutputMode, ProviderSettings, ProviderStatus, RemoteService, ResolvedEngines, SecretSource, SecretState, ServiceStatus, SiteStatus, UserSecrets, }; pub use history::{ EditRecord, EntryOrigin, HistoryEntry, HistoryHits, HistoryPage, HistoryQuery, HistoryReader, HistoryStats, HistoryStatsBucket, HistoryStore, OriginKind, @@ -53,8 +53,8 @@ pub use models::{ }; pub use presets::{BuiltinPreset, CustomPreset, PresetDraft, PresetError, PresetId, PresetRef, PresetTrial, PresetTryOutcome, TakePreset}; pub use providers::{ - AsrProtocol, ConsolePage, KeyPolicy, LlmApi, PROVIDERS, ProbeError, ProbeFailure, ProbeOutcome, ProbeReport, ProviderId, ProviderSpec, ReasoningEffort, - ServiceKind, ServicePreset, key_entries, key_env_var, load_user_secrets, peek_user_secrets, + AsrProtocol, ConsolePage, KeyPolicy, LlmApi, PROVIDERS, ProbeError, ProbeFailure, ProbeOutcome, ProbeReport, ProviderId, ProviderSite, ProviderSpec, + ReasoningEffort, ServiceKind, ServicePreset, SiteId, key_entries, key_env_var, load_user_secrets, peek_user_secrets, }; pub use runtime::{ AppCore, CoreCommand, CoreConfig, CoreEvent, CoreHandle, EXTRA_RECORDING_POLL, MAX_ACTIVATION_MS, PRESET_TRY_UNCONFIGURED, SERVE_UNAVAILABLE, TestHooks, diff --git a/crates/voltip-core/src/providers.rs b/crates/voltip-core/src/providers.rs index f5f60f9d..84476759 100644 --- a/crates/voltip-core/src/providers.rs +++ b/crates/voltip-core/src/providers.rs @@ -1,8 +1,10 @@ //! The service providers the engines can talk to (docs/dictation.md §3): the service compiled into -//! the build, the on-device models, a few OpenAI-compatible vendors and a custom endpoint. Each -//! provider offers speech recognition, text clean-up (LLM) or both; the catalogue carries the -//! public base URL and a short list of suggested models per service. A provider's key is shared by -//! its services, except for the custom provider, whose two endpoints may be two different servers. +//! the build, the on-device models, OpenAI-compatible vendors and a custom endpoint. Each provider +//! offers speech recognition, text clean-up (LLM) or both; the catalogue carries the public base URL +//! of each service, the vendor's other sites ([`ProviderSite`]) and two or three suggested models +//! (user decision 2026-10-25: the rest comes from the provider's own list once a key is saved). A +//! provider's key is shared by its services, except for the custom provider, whose two endpoints may +//! be two different servers. //! [`AsrProtocol`] says how a recognition request reaches an endpoint: most speak OpenAI's //! `/audio/transcriptions`, Alibaba Cloud Model Studio speaks its own protocols (§3.4). @@ -32,6 +34,18 @@ pub enum ProviderId { Aliyun, /// DeepSeek (clean-up only). Deepseek, + /// Kimi (Moonshot AI), on its Chinese or its international site (clean-up only). + Kimi, + /// Zhipu's GLM: bigmodel.cn, or Z.ai abroad (clean-up only). + Glm, + /// Volcengine Ark (火山方舟), the Doubao models (clean-up only). + Volcengine, + /// Tencent Hunyuan through Tencent Cloud's TokenHub (clean-up only). + Hunyuan, + /// Xiaomi MiMo (clean-up only). + Mimo, + /// OpenRouter, one key for many vendors' models (clean-up only). + Openrouter, /// Ollama on this machine (clean-up only, no key). Ollama, /// Any OpenAI-compatible endpoint the user enters (a Model Studio address speaks Model @@ -41,8 +55,24 @@ pub enum ProviderId { impl ProviderId { /// Every provider in display order. - pub const ALL: [Self; 10] = - [Self::Builtin, Self::Local, Self::Openai, Self::Groq, Self::Google, Self::Siliconflow, Self::Aliyun, Self::Deepseek, Self::Ollama, Self::Custom]; + pub const ALL: [Self; 16] = [ + Self::Builtin, + Self::Local, + Self::Openai, + Self::Groq, + Self::Google, + Self::Siliconflow, + Self::Aliyun, + Self::Deepseek, + Self::Kimi, + Self::Glm, + Self::Volcengine, + Self::Hunyuan, + Self::Mimo, + Self::Openrouter, + Self::Ollama, + Self::Custom, + ]; /// Wire name. pub fn as_str(self) -> &'static str { @@ -55,6 +85,12 @@ impl ProviderId { Self::Siliconflow => "siliconflow", Self::Aliyun => "aliyun", Self::Deepseek => "deepseek", + Self::Kimi => "kimi", + Self::Glm => "glm", + Self::Volcengine => "volcengine", + Self::Hunyuan => "hunyuan", + Self::Mimo => "mimo", + Self::Openrouter => "openrouter", Self::Ollama => "ollama", Self::Custom => "custom", } @@ -66,9 +102,11 @@ impl ProviderId { } /// The vendor page `provider_console_open` opens, or the shells' answer when there is none. - /// Only catalogue URLs are opened: the webview names a provider and a page, never a URL. - pub fn console_page(self, page: ConsolePage) -> Result<&'static str, String> { - self.spec().console(page).ok_or_else(|| match page { + /// Only catalogue URLs are opened: the webview names a provider, a page and a site, never a + /// URL. A site the provider does not have, or one without a page of its own, opens the + /// provider's own page. + pub fn console_page(self, page: ConsolePage, site: Option) -> Result<&'static str, String> { + self.spec().console(page, site).ok_or_else(|| match page { ConsolePage::Key => format!("{}: no key page", self.as_str()), ConsolePage::Quota => format!("{}: no quota page", self.as_str()), }) @@ -141,14 +179,41 @@ pub enum KeyPolicy { None, } -/// A service a vendor offers: its public base URL and the models suggested first (the first one is -/// the default). The user may type any other model id or fetch the provider's list. +/// A service a vendor offers: its public base URL, its other sites and the two or three models +/// suggested first (the first one is the default; docs/dictation.md §3.3). The user may type any +/// other model id or pick one from the provider's list. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct ServicePreset { - /// Public base URL (OpenAI-compatible, including `/v1` where the vendor uses it). + /// Public base URL (OpenAI-compatible, including `/v1` where the vendor uses it): the first + /// site's when the vendor has several. pub base_url: &'static str, /// Suggested model ids, default first. pub models: &'static [&'static str], + /// The vendor's sites, the default one first, when it has more than one (Kimi and GLM have a + /// Chinese and an international one, user decision 2026-10-25: one card that switches between + /// them). Each site has its own accounts and keys; empty for a vendor with one address. + pub sites: &'static [ProviderSite], +} + +/// Which of a vendor's sites (docs/dictation.md §3.2). +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SiteId { + /// The site in mainland China (moonshot.cn, bigmodel.cn). + Cn, + /// The international site (moonshot.ai, z.ai). + Intl, +} + +/// One site of a vendor: where its requests go and where its keys are made. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ProviderSite { + /// Id. + pub id: SiteId, + /// The service's base URL on this site. + pub base_url: &'static str, + /// The page where a key for this site is created. + pub console_url: &'static str, } /// One provider of the catalogue. @@ -184,14 +249,19 @@ pub enum ConsolePage { } impl ProviderSpec { - /// The vendor's `page`, if it has one. - pub fn console(&self, page: ConsolePage) -> Option<&'static str> { + /// The vendor's `page`, if it has one; the key page of `site` when that site has its own. + pub fn console(&self, page: ConsolePage, site: Option) -> Option<&'static str> { match page { - ConsolePage::Key => self.console_url, + ConsolePage::Key => site.and_then(|id| self.site(id)).map(|s| s.console_url).or(self.console_url), ConsolePage::Quota => self.quota_url, } } + /// The site `id` of whichever service has it. + pub fn site(&self, id: SiteId) -> Option<&'static ProviderSite> { + [self.asr.as_ref(), self.llm.as_ref()].into_iter().flatten().flat_map(|preset| preset.sites.iter()).find(|s| s.id == id) + } + /// The preset of `kind`, if the provider offers it. pub fn service(&self, kind: ServiceKind) -> Option<&ServicePreset> { match kind { @@ -206,7 +276,7 @@ impl ProviderSpec { } } -const NO_PRESET: ServicePreset = ServicePreset { base_url: "", models: &[] }; +const NO_PRESET: ServicePreset = ServicePreset { base_url: "", models: &[], sites: &[] }; /// How a recognition request reaches a service (docs/dictation.md §3.4), decided by the endpoint and /// the model: an Alibaba Cloud Model Studio address serves models that speak three different @@ -278,8 +348,9 @@ pub fn is_dashscope(url: &str) -> bool { url::Url::parse(url.trim()).ok().and_then(|u| u.host_str().map(str::to_ascii_lowercase)).is_some_and(|host| host.ends_with(".aliyuncs.com")) } -/// The catalogue, in display order. Model ids checked against the vendors' documentation on -/// 2026-09-27; lists go stale, so the UI also offers the provider's own `GET /models`. +/// The catalogue, in display order. Two or three suggested models per service (user decision +/// 2026-10-25), checked against the vendors' documentation on the date their comment gives; the +/// provider's own `GET /models` lists the rest once a key is saved (docs/dictation.md §3.3). pub const PROVIDERS: &[ProviderSpec] = &[ ProviderSpec { id: ProviderId::Builtin, @@ -293,8 +364,8 @@ pub const PROVIDERS: &[ProviderSpec] = &[ ProviderSpec { id: ProviderId::Local, asr: Some(NO_PRESET), llm: None, key: KeyPolicy::None, on_device: true, console_url: None, quota_url: None }, ProviderSpec { id: ProviderId::Openai, - asr: Some(ServicePreset { base_url: "https://api.openai.com/v1", models: &["gpt-transcribe", "gpt-4o-mini-transcribe", "whisper-1"] }), - llm: Some(ServicePreset { base_url: "https://api.openai.com/v1", models: &["gpt-6-luna", "gpt-6-sol"] }), + asr: Some(ServicePreset { base_url: "https://api.openai.com/v1", models: &["gpt-transcribe", "gpt-4o-mini-transcribe", "whisper-1"], sites: &[] }), + llm: Some(ServicePreset { base_url: "https://api.openai.com/v1", models: &["gpt-6-luna", "gpt-6-sol"], sites: &[] }), key: KeyPolicy::Required, on_device: false, console_url: Some("https://platform.openai.com/api-keys"), @@ -302,21 +373,29 @@ pub const PROVIDERS: &[ProviderSpec] = &[ }, ProviderSpec { id: ProviderId::Groq, - asr: Some(ServicePreset { base_url: "https://api.groq.com/openai/v1", models: &["whisper-large-v3-turbo", "whisper-large-v3"] }), - llm: Some(ServicePreset { base_url: "https://api.groq.com/openai/v1", models: &["qwen/qwen3.8-27b", "openai/gpt-oss-120b", "openai/gpt-oss-20b"] }), + asr: Some(ServicePreset { base_url: "https://api.groq.com/openai/v1", models: &["whisper-large-v3-turbo", "whisper-large-v3"], sites: &[] }), + llm: Some(ServicePreset { + base_url: "https://api.groq.com/openai/v1", + models: &["qwen/qwen3.8-27b", "openai/gpt-oss-120b", "openai/gpt-oss-20b"], + sites: &[], + }), key: KeyPolicy::Required, on_device: false, console_url: Some("https://console.groq.com/keys"), quota_url: None, }, - // Google AI Studio (checked 2026-10-08): the Gemini models with a free tier, the newest Flash - // first. Its `GET /models` names them `models/gemini-…` (see `usable_models`). + // Google AI Studio (checked 2026-10-08, again 2026-10-24): the Gemini models with a free tier, + // Flash-Lite first. On 2026-10-24 `gemini-3.8-flash` turned clean-ups down with 503 "high + // demand" for 40 minutes, as did `gemini-3.1-flash-lite`, while `gemini-3.5-flash-lite` answered + // in 1–6 s; the 2.5 models answer 404 "no longer available to new users". Its `GET /models` + // names them `models/gemini-…` (see `usable_models`). ProviderSpec { id: ProviderId::Google, asr: None, llm: Some(ServicePreset { base_url: "https://generativelanguage.googleapis.com/v1beta/openai", - models: &["gemini-3.8-flash", "gemini-3.5-flash-lite", "gemini-3.1-flash-lite", "gemini-2.5-flash", "gemini-2.5-pro"], + models: &["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-flash-lite"], + sites: &[], }), key: KeyPolicy::Required, on_device: false, @@ -325,30 +404,30 @@ pub const PROVIDERS: &[ProviderSpec] = &[ }, ProviderSpec { id: ProviderId::Siliconflow, - asr: Some(ServicePreset { base_url: "https://api.siliconflow.cn/v1", models: &["FunAudioLLM/SenseVoiceSmall", "TeleAI/TeleSpeechASR"] }), - llm: Some(ServicePreset { base_url: "https://api.siliconflow.cn/v1", models: &["Qwen/Qwen3-8B", "deepseek-ai/DeepSeek-V3"] }), + asr: Some(ServicePreset { base_url: "https://api.siliconflow.cn/v1", models: &["FunAudioLLM/SenseVoiceSmall", "TeleAI/TeleSpeechASR"], sites: &[] }), + llm: Some(ServicePreset { base_url: "https://api.siliconflow.cn/v1", models: &["Qwen/Qwen3-8B", "deepseek-ai/DeepSeek-V3"], sites: &[] }), key: KeyPolicy::Required, on_device: false, console_url: Some("https://cloud.siliconflow.cn/account/ak"), quota_url: None, }, // Model Studio (checked 2026-10-04): the realtime `…-streaming` model first, since it recognises - // while the user speaks (§3.4); `…-asr-flash` takes a whole file over HTTP. A workspace's own - // address (`https://.cn-beijing.maas.aliyuncs.com/compatible-mode/v1`) replaces the - // public one in the URL field. + // while the user speaks (§3.4); `…-asr-flash` takes a whole file over HTTP, `fun-asr-realtime` + // is the other realtime engine. A workspace's own address + // (`https://.cn-beijing.maas.aliyuncs.com/compatible-mode/v1`) replaces the public one + // in the URL field. ProviderSpec { id: ProviderId::Aliyun, asr: Some(ServicePreset { base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", - models: &[ - "qwen-audio-3.1-asr-flash-streaming", - "qwen-audio-3.1-asr-flash", - "qwen-audio-3.1-asr-flash-message", - "qwen3-asr-flash", - "fun-asr-realtime", - ], + models: &["qwen-audio-3.1-asr-flash-streaming", "qwen-audio-3.1-asr-flash", "fun-asr-realtime"], + sites: &[], + }), + llm: Some(ServicePreset { + base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", + models: &["qwen3.8-flash", "qwen3.8-max", "qwen3.7-flash"], + sites: &[], }), - llm: Some(ServicePreset { base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", models: &["qwen3.8-flash", "qwen3.8-max", "qwen3.7-flash"] }), key: KeyPolicy::Required, on_device: false, console_url: Some("https://bailian.console.aliyun.com/cn-beijing/model/settings/api-key"), @@ -357,16 +436,110 @@ pub const PROVIDERS: &[ProviderSpec] = &[ ProviderSpec { id: ProviderId::Deepseek, asr: None, - llm: Some(ServicePreset { base_url: "https://api.deepseek.com", models: &["deepseek-flash", "deepseek-v4-pro"] }), + llm: Some(ServicePreset { base_url: "https://api.deepseek.com", models: &["deepseek-flash", "deepseek-v4-pro"], sites: &[] }), key: KeyPolicy::Required, on_device: false, console_url: Some("https://platform.deepseek.com/api_keys"), quota_url: None, }, + // Kimi (platform.kimi.com, checked 2026-10-25): the K3 models always reason and take + // `reasoning_effort`; K2 closes to new accounts on 2026-10-31. The two sites keep separate + // accounts and keys. + ProviderSpec { + id: ProviderId::Kimi, + asr: None, + llm: Some(ServicePreset { + base_url: "https://api.moonshot.cn/v1", + models: &["kimi-k3.1-pro-highspeed", "kimi-k3.1-pro", "kimi-k3"], + sites: &[ + ProviderSite { id: SiteId::Cn, base_url: "https://api.moonshot.cn/v1", console_url: "https://platform.kimi.com/console/api-keys" }, + ProviderSite { id: SiteId::Intl, base_url: "https://api.moonshot.ai/v1", console_url: "https://platform.kimi.ai/console/api-keys" }, + ], + }), + key: KeyPolicy::Required, + on_device: false, + console_url: Some("https://platform.kimi.com/console/api-keys"), + quota_url: None, + }, + // GLM (docs.bigmodel.cn and docs.z.ai, checked 2026-10-25): `glm-5.3-flash` is the newest fast + // model, `glm-4.7-flash` costs nothing on either site. The two sites keep separate accounts and + // keys. + ProviderSpec { + id: ProviderId::Glm, + asr: None, + llm: Some(ServicePreset { + base_url: "https://open.bigmodel.cn/api/paas/v4", + models: &["glm-5.3-flash", "glm-4.7-flash", "glm-5.3"], + sites: &[ + ProviderSite { + id: SiteId::Cn, + base_url: "https://open.bigmodel.cn/api/paas/v4", + console_url: "https://bigmodel.cn/usercenter/proj-mgmt/apikeys", + }, + ProviderSite { id: SiteId::Intl, base_url: "https://api.z.ai/api/paas/v4", console_url: "https://z.ai/manage-apikey/apikey-list" }, + ], + }), + key: KeyPolicy::Required, + on_device: false, + console_url: Some("https://bigmodel.cn/usercenter/proj-mgmt/apikeys"), + quota_url: None, + }, + // Volcengine Ark (docs.volcengine.com/docs/ark/model-list, checked 2026-10-25): its model ids + // carry their release date, and a model answers only once it is enabled in the console. + ProviderSpec { + id: ProviderId::Volcengine, + asr: None, + llm: Some(ServicePreset { + base_url: "https://ark.cn-beijing.volces.com/api/v3", + models: &["doubao-seed-2-1-lite-260915", "doubao-seed-2-1-turbo-260628", "doubao-seed-2-0-mini-260428"], + sites: &[], + }), + key: KeyPolicy::Required, + on_device: false, + console_url: Some("https://ark.volcengine.com/region:cn-beijing/apikey"), + quota_url: None, + }, + // Tencent Hunyuan (cloud.tencent.com/document/product/1823, checked 2026-10-25): Tencent Cloud + // retired the old Hunyuan platform's models on 2026-06-22 for TokenHub, which also serves other + // vendors' models. A model answers only once it is enabled in the console (402 otherwise). + ProviderSpec { + id: ProviderId::Hunyuan, + asr: None, + llm: Some(ServicePreset { base_url: "https://tokenhub.tencentmaas.com/v1", models: &["hy3", "hy4-preview"], sites: &[] }), + key: KeyPolicy::Required, + on_device: false, + console_url: Some("https://console.cloud.tencent.com/tokenhub/apikey"), + quota_url: None, + }, + // Xiaomi MiMo (mimo.mi.com/docs, checked 2026-10-25): the 2.5 models were retired on 2026-10-21. + ProviderSpec { + id: ProviderId::Mimo, + asr: None, + llm: Some(ServicePreset { base_url: "https://api.xiaomimimo.com/v1", models: &["mimo-v2.6-flash", "mimo-v2.6-pro"], sites: &[] }), + key: KeyPolicy::Required, + on_device: false, + console_url: Some("https://platform.xiaomimimo.com/#/console/api-keys"), + quota_url: None, + }, + // OpenRouter (openrouter.ai/api/v1/models, checked 2026-10-25): three fast, inexpensive models + // of different vendors; its model list needs no key. + ProviderSpec { + id: ProviderId::Openrouter, + asr: None, + llm: Some(ServicePreset { + base_url: "https://openrouter.ai/api/v1", + models: &["google/gemini-3.5-flash-lite", "deepseek/deepseek-v4-flash", "openai/gpt-6-luna"], + sites: &[], + }), + key: KeyPolicy::Required, + on_device: false, + console_url: Some("https://openrouter.ai/settings/keys"), + quota_url: None, + }, ProviderSpec { id: ProviderId::Ollama, asr: None, - llm: Some(ServicePreset { base_url: "http://127.0.0.1:11434/v1", models: &[] }), + llm: Some(ServicePreset { base_url: "http://127.0.0.1:11434/v1", models: &[], sites: &[] }), key: KeyPolicy::None, on_device: true, console_url: None, @@ -387,9 +560,9 @@ pub const PROVIDERS: &[ProviderSpec] = &[ const SPEECH_TO_TEXT: [&str; 5] = ["whisper", "transcribe", "asr", "sensevoice", "paraformer"]; /// Words in the id of a model that does not chat in text: speech in or out, embeddings and -/// reranking, moderation, images and video, realtime sessions, the models only the Responses -/// interface serves, and the omni models, which answer only as a stream. -const NOT_CHAT: [&str; 30] = [ +/// reranking, moderation, images, video and text recognition in images, realtime sessions, the +/// models only the Responses interface serves, and the omni models, which answer only as a stream. +const NOT_CHAT: [&str; 36] = [ "whisper", "transcribe", "asr", @@ -415,6 +588,12 @@ const NOT_CHAT: [&str; 30] = [ "t2v", "i2v", "video", + "seedream", + "seedance", + "vidu", + "cogview", + "cogvideo", + "ocr", "realtime", "codex", "computer-use", @@ -475,6 +654,12 @@ pub fn key_entry(provider: ProviderId, kind: ServiceKind) -> Option<&'static str (ProviderId::Siliconflow, _) => Some("provider-key.siliconflow"), (ProviderId::Aliyun, _) => Some("provider-key.aliyun"), (ProviderId::Deepseek, _) => Some("provider-key.deepseek"), + (ProviderId::Kimi, _) => Some("provider-key.kimi"), + (ProviderId::Glm, _) => Some("provider-key.glm"), + (ProviderId::Volcengine, _) => Some("provider-key.volcengine"), + (ProviderId::Hunyuan, _) => Some("provider-key.hunyuan"), + (ProviderId::Mimo, _) => Some("provider-key.mimo"), + (ProviderId::Openrouter, _) => Some("provider-key.openrouter"), (ProviderId::Custom, ServiceKind::Asr) => Some("provider-key.custom-asr"), (ProviderId::Custom, ServiceKind::Llm) => Some("provider-key.custom-llm"), } @@ -548,18 +733,27 @@ pub enum ProbeFailure { } /// A failed probe as the shell's [`crate::dictation::ServiceProbe`] reports it. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Eq, Debug)] pub struct ProbeError { /// Why. pub reason: ProbeFailure, /// The HTTP status of [`ProbeFailure::HttpStatus`]. pub status: Option, + /// What the service said, or the connection's error, short and without credentials + /// (docs/dictation.md §3.12); `None` when there is nothing to add to the reason. + pub detail: Option, } impl ProbeError { /// A failure without an HTTP status. pub const fn new(reason: ProbeFailure) -> Self { - Self { reason, status: None } + Self { reason, status: None, detail: None } + } + + /// `self` with `detail` (nothing when it is blank). + pub fn with_detail(self, detail: impl Into) -> Self { + let detail = detail.into(); + Self { detail: (!detail.trim().is_empty()).then_some(detail), ..self } } } @@ -581,6 +775,9 @@ pub enum ProbeOutcome { /// The HTTP status of [`ProbeFailure::HttpStatus`]. #[serde(default, skip_serializing_if = "Option::is_none")] status: Option, + /// What the service said, or the connection's error ([`ProbeError::detail`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, }, } @@ -642,7 +839,9 @@ mod tests { _ => { assert!(preset.base_url.starts_with("https://"), "{:?} {kind:?}", spec.id); assert!(url::Url::parse(preset.base_url).is_ok()); - assert!(!preset.models.is_empty(), "{:?} {kind:?} has a default model", spec.id); + // User decision 2026-10-25: two or three suggestions, the provider lists the rest. + assert!((2..=3).contains(&preset.models.len()), "{:?} {kind:?} suggests 2–3 models: {:?}", spec.id, preset.models); + assert!(spec.console_url.is_some(), "{:?} has a key page", spec.id); } } } @@ -654,6 +853,39 @@ mod tests { assert!(ProviderId::Local.spec().on_device && ProviderId::Ollama.spec().on_device); } + /// docs/dictation.md §3.2: a vendor with a Chinese and an international site is one card that + /// switches between them; each site has its own address and key page, the first is the default. + #[test] + fn a_vendors_sites_start_with_its_default_and_have_their_own_key_pages() { + for spec in PROVIDERS { + for kind in [ServiceKind::Asr, ServiceKind::Llm] { + let Some(preset) = spec.service(kind) else { continue }; + if preset.sites.is_empty() { + continue; + } + assert!(preset.sites.len() >= 2, "{:?}: one site is no choice", spec.id); + assert_eq!(preset.sites[0].base_url, preset.base_url, "{:?}: the first site is the default", spec.id); + assert_eq!(Some(preset.sites[0].console_url), spec.console_url, "{:?}", spec.id); + let mut ids: Vec = preset.sites.iter().map(|s| s.id).collect(); + ids.dedup(); + assert_eq!(ids.len(), preset.sites.len(), "{:?}: sites are distinct", spec.id); + for site in preset.sites { + assert!(site.base_url.starts_with("https://") && url::Url::parse(site.base_url).is_ok(), "{site:?}"); + assert!(site.console_url.starts_with("https://"), "{site:?}"); + assert_eq!(spec.console(ConsolePage::Key, Some(site.id)), Some(site.console_url)); + } + } + } + let kimi = ProviderId::Kimi.spec(); + assert_eq!(kimi.site(SiteId::Intl).map(|s| s.base_url), Some("https://api.moonshot.ai/v1")); + assert_eq!(ProviderId::Glm.spec().site(SiteId::Intl).map(|s| s.base_url), Some("https://api.z.ai/api/paas/v4")); + assert_eq!(ProviderId::Kimi.console_page(ConsolePage::Key, Some(SiteId::Intl)), Ok("https://platform.kimi.ai/console/api-keys")); + // A vendor with one site opens its own page whatever site is named. + assert_eq!(ProviderId::Groq.console_page(ConsolePage::Key, Some(SiteId::Intl)), Ok("https://console.groq.com/keys")); + assert!(ProviderId::Kimi.console_page(ConsolePage::Quota, Some(SiteId::Cn)).is_err()); + assert_eq!(serde_json::to_string(&SiteId::Intl).unwrap(), r#""intl""#); + } + #[test] fn vendor_keys_are_shared_and_custom_keys_are_per_service() { assert_eq!(key_entry(ProviderId::Groq, ServiceKind::Asr), key_entry(ProviderId::Groq, ServiceKind::Llm)); @@ -665,7 +897,7 @@ mod tests { assert_eq!(key_entry(ProviderId::Aliyun, ServiceKind::Asr), Some("provider-key.aliyun")); assert_eq!(key_entry(ProviderId::Aliyun, ServiceKind::Asr), key_entry(ProviderId::Aliyun, ServiceKind::Llm)); let entries = key_entries(); - assert_eq!(entries.len(), 8, "{entries:?}"); + assert_eq!(entries.len(), 14, "{entries:?}"); assert!(entries.iter().all(|e| e.starts_with("provider-key."))); } @@ -836,6 +1068,23 @@ mod tests { ); assert_eq!(usable_models(ProviderId::Aliyun, ServiceKind::Llm, base, studio), ["qwen3.8-flash"]); + // Ark, TokenHub and Zhipu list their image, video and text-in-image models beside the chat ones + // (2026-10-25). + let ark = ids(&[ + "doubao-seed-2-1-lite-260915", + "doubao-seedream-5-0-260128", + "doubao-seedance-2-5-260628", + "doubao-embedding-vision-251215", + "glm-5-3-flash-260828", + ]); + assert_eq!( + usable_models(ProviderId::Volcengine, ServiceKind::Llm, "https://ark.cn-beijing.volces.com/api/v3", ark), + ["doubao-seed-2-1-lite-260915", "glm-5-3-flash-260828"] + ); + let tokenhub = ids(&["hy3", "hy4-preview", "hy-image-v3", "vidu-image-q2", "viduq2", "seedream-image-v5.0-pro"]); + assert_eq!(usable_models(ProviderId::Hunyuan, ServiceKind::Llm, "https://tokenhub.tencentmaas.com/v1", tokenhub), ["hy3", "hy4-preview"]); + let zhipu = ids(&["glm-5.3-flash", "cogview-4", "cogvideox-3", "glm-ocr", "glm-asr-2512", "embedding-3", "glm-realtime"]); + assert_eq!(usable_models(ProviderId::Glm, ServiceKind::Llm, "https://open.bigmodel.cn/api/paas/v4", zhipu), ["glm-5.3-flash"]); // Ollama's pulled models, without the embedding ones. let ollama = ids(&["qwen3:8b", "nomic-embed-text:latest", "llava:7b"]); assert_eq!(usable_models(ProviderId::Ollama, ServiceKind::Llm, "http://127.0.0.1:11434/v1", ollama), ["llava:7b", "qwen3:8b"]); @@ -857,10 +1106,20 @@ mod tests { let failed = ProbeReport { provider: ProviderId::Builtin, kind: ServiceKind::Asr, - outcome: ProbeOutcome::Failed { reason: ProbeFailure::HttpStatus, status: Some(502) }, + outcome: ProbeOutcome::Failed { reason: ProbeFailure::HttpStatus, status: Some(502), detail: None }, }; let json = serde_json::to_string(&failed).unwrap(); assert_eq!(json, r#"{"provider":"builtin","kind":"asr","result":"failed","reason":"http_status","status":502}"#); assert_eq!(serde_json::from_str::(&json).unwrap(), failed); + // docs/dictation.md §3.12: what the service said goes with the reason. + let said = ProbeReport { + provider: ProviderId::Openai, + kind: ServiceKind::Llm, + outcome: ProbeOutcome::Failed { reason: ProbeFailure::Unauthorized, status: None, detail: Some("Incorrect API key provided".into()) }, + }; + let json = serde_json::to_string(&said).unwrap(); + assert_eq!(json, r#"{"provider":"openai","kind":"llm","result":"failed","reason":"unauthorized","detail":"Incorrect API key provided"}"#); + assert_eq!(serde_json::from_str::(&json).unwrap(), said); + assert_eq!(ProbeError::new(ProbeFailure::Timeout).with_detail(" ").detail, None, "a blank detail adds nothing"); } } diff --git a/crates/voltip-core/src/runtime.rs b/crates/voltip-core/src/runtime.rs index 8fabf4a8..e3d9ae6e 100644 --- a/crates/voltip-core/src/runtime.rs +++ b/crates/voltip-core/src/runtime.rs @@ -2116,7 +2116,7 @@ impl Runtime { /// a task and answer with `ProviderProbe`. Problems found before any request (no URL, no key, /// no such service, no probe on this shell) are answered at once. fn probe_provider(&mut self, provider: ProviderId, kind: ServiceKind, base_url: Option, key: Option) -> Result<(), CoreError> { - let refused = |reason| ProbeReport { provider, kind, outcome: ProbeOutcome::Failed { reason, status: None } }; + let refused = |reason| ProbeReport { provider, kind, outcome: ProbeOutcome::Failed { reason, status: None, detail: None } }; let (url, key) = match self.probe_target(provider, kind, base_url, key) { Ok(target) => target, Err(reason) => { @@ -2150,7 +2150,7 @@ impl Runtime { } let outcome = match models { Ok(models) => ProbeOutcome::Ok { models, latency_ms }, - Err(ProbeError { reason, status }) => ProbeOutcome::Failed { reason, status }, + Err(ProbeError { reason, status, detail }) => ProbeOutcome::Failed { reason, status, detail }, }; if evt.send(CoreEvent::ProviderProbe(ProbeReport { provider, kind, outcome })).await.is_err() { tracing::debug!("provider probe answered after the UI went away"); diff --git a/crates/voltip-core/src/runtime/takes.rs b/crates/voltip-core/src/runtime/takes.rs index 073c94b0..b5bc1bb8 100644 --- a/crates/voltip-core/src/runtime/takes.rs +++ b/crates/voltip-core/src/runtime/takes.rs @@ -525,6 +525,7 @@ impl Runtime { asr_ms: 0, refine_ms: None, refine_failure: None, + refine_error: None, outcome: Outcome::Inserted { via: if pasted { crate::dictation::Via::Paste } else { crate::dictation::Via::Clipboard } }, starred: false, mode: crate::dictation::OutputMode::WholeTake, diff --git a/crates/voltip-core/src/runtime/texts.rs b/crates/voltip-core/src/runtime/texts.rs index c4a276df..db93bbf7 100644 --- a/crates/voltip-core/src/runtime/texts.rs +++ b/crates/voltip-core/src/runtime/texts.rs @@ -147,6 +147,7 @@ impl Runtime { asr_ms: 0, refine_ms: None, refine_failure: None, + refine_error: None, outcome, starred: false, mode: OutputMode::WholeTake, diff --git a/crates/voltip-core/src/ui.rs b/crates/voltip-core/src/ui.rs index 5981322f..5d247ffd 100644 --- a/crates/voltip-core/src/ui.rs +++ b/crates/voltip-core/src/ui.rs @@ -799,6 +799,7 @@ mod tests { asr_ms: 3, refine_ms: Some(4), refine_failure: None, + refine_error: None, outcome: crate::Outcome::Inserted { via: crate::dictation::Via::Paste }, starred: false, mode: crate::OutputMode::WholeTake, diff --git a/crates/voltip-refine/Cargo.toml b/crates/voltip-refine/Cargo.toml index 4e194173..9547376b 100644 --- a/crates/voltip-refine/Cargo.toml +++ b/crates/voltip-refine/Cargo.toml @@ -21,6 +21,8 @@ tracing.workspace = true url.workspace = true # The proxy of the requests (docs/dictation.md §24). voltip-net.workspace = true +# What the service said when it refused a request, for the errors (docs/dictation.md §3.12). +voltip-reply.workspace = true [target.'cfg(target_os = "android")'.dependencies] webpki-root-certs.workspace = true diff --git a/crates/voltip-refine/src/client.rs b/crates/voltip-refine/src/client.rs index f8eb4e5f..1d7dcacf 100644 --- a/crates/voltip-refine/src/client.rs +++ b/crates/voltip-refine/src/client.rs @@ -76,29 +76,168 @@ pub struct Refined { #[derive(Serialize)] struct ChatRequest<'a> { model: &'a str, - temperature: f32, + /// Absent for a model that fixes its own ([`ChatControls::temperature`]). + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, max_tokens: u32, messages: [Message<'a>; 2], #[serde(skip_serializing_if = "Option::is_none")] enable_thinking: Option, - /// Only for a model that reasons first ([`reasons_first`]). + /// Only for a model that reasons first ([`chat_controls`]). #[serde(skip_serializing_if = "Option::is_none")] reasoning_effort: Option<&'static str>, + /// `{"type": "disabled"}` for a model that reasons unless told not to. + #[serde(skip_serializing_if = "Option::is_none")] + thinking: Option, + /// OpenRouter's own form of the effort, in place of the vendors' fields. + #[serde(skip_serializing_if = "Option::is_none")] + reasoning: Option, +} + +#[derive(Serialize)] +struct Thinking { + #[serde(rename = "type")] + kind: &'static str, } -/// The effort a chat completion asks of a model that reasons before it answers (docs/dictation.md -/// §3.10). Its reasoning counts against `max_tokens`: at the default effort a short clean-up's 128 -/// tokens all went to it on Groq and the answer came back empty (2026-10-12, `openai/gpt-oss-120b`, -/// 126 reasoning tokens); at low effort the reasoning took 5–86. +/// The effort a chat completion asks of a model that reasons before it answers and cannot be told +/// not to (docs/dictation.md §3.10). Its reasoning counts against `max_tokens`: at the default +/// effort a short clean-up's 128 tokens all went to it on Groq and the answer came back empty +/// (2026-10-12, `openai/gpt-oss-120b`, 126 reasoning tokens); at low effort the reasoning took 5–86. +/// Gemini behaves the same: on 2026-10-24 `gemini-3.8-flash` spent 146 of a short take's 152 tokens +/// on it and stopped at `length` after 「请问现在」; at low effort it reasoned 0–110 tokens +/// (`gemini-3.1-flash-lite`). `minimal` is refused by `gemini-3.8-flash` and `none` by every Gemini 3 +/// model, so low it is; Kimi K3 and GLM-5.3 take `low` | `high` | `max` (default `max`). const REASONING_EFFORT: &str = "low"; -/// Tokens such a request gets on top of the answer's budget, for the reasoning, under the same +/// OpenRouter's effort that turns reasoning off, for the models that allow it. +const NO_REASONING: &str = "none"; +/// Tokens a gpt-oss request gets on top of the answer's budget, for the reasoning, under the same /// ceiling. const REASONING_TOKENS: u32 = 256; +/// The same for the other models that reason whatever they are asked: low effort is a thinking +/// budget of 1 024 tokens on Gemini 2.5 (ai.google.dev/gemini-api/docs/openai, 2026-10-24). +const LONG_REASONING_TOKENS: u32 = 1024; + +/// What a chat completion sends to make `model` answer at once (docs/dictation.md §3.10). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct ChatControls { + /// The request carries [`TEMPERATURE`]. Kimi fixes its models' temperature and answers any + /// other value with 400 (platform.kimi.com/docs/api/models-overview, 2026-10-25), through + /// OpenRouter too. + temperature: bool, + /// Top-level `reasoning_effort`. + reasoning_effort: Option<&'static str>, + /// `thinking: {"type": "disabled"}`. + no_thinking: bool, + /// OpenRouter's `reasoning: {"effort": …}`. + openrouter_effort: Option<&'static str>, + /// Tokens on top of the answer's budget for the reasoning, under the same ceiling. + room: Option, +} + +/// How a model reasons, by the families the vendors document (2026-10-25). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Reasoner { + /// Answers without reasoning, or reasons in a way no request field changes. + No, + /// Always reasons and takes `reasoning_effort` (gpt-oss, Gemini, Kimi K3, GLM-5.3), with this + /// much room for it. + Effort(u32), + /// Always reasons and takes no effort (Kimi K2.7 Code). + Always, + /// Reasons unless told `thinking: {"type": "disabled"}` (GLM-4.5 to GLM-5.2, Kimi K2.5 and + /// K2.6, Xiaomi MiMo, Tencent Hy3 and Hy4, the Doubao Seed models). + Toggle, +} + +/// What a chat completion sends for `model` at `base_url`: the vendors name their models alike +/// wherever they are served (`moonshotai/kimi-k3` on OpenRouter, `kimi-k3` on TokenHub), so the +/// model decides, and OpenRouter takes its own form of the effort instead of the vendors' fields. +fn chat_controls(base_url: &str, model: &str) -> ChatControls { + let name = model.trim().rsplit('/').next().unwrap_or_default().to_ascii_lowercase(); + let kimi = name.starts_with("kimi-"); + let reasoner = reasoner(&name); + let mut controls = ChatControls { temperature: !kimi, ..ChatControls::default() }; + let openrouter = url::Url::parse(base_url.trim()) + .ok() + .and_then(|u| u.host_str().map(str::to_ascii_lowercase)) + .is_some_and(|host| host == "openrouter.ai" || host.ends_with(".openrouter.ai")); + match (reasoner, openrouter) { + (Reasoner::No, _) => {} + (Reasoner::Effort(room), false) => { + controls.reasoning_effort = Some(REASONING_EFFORT); + controls.room = Some(room); + } + (Reasoner::Effort(room), true) => { + controls.openrouter_effort = Some(REASONING_EFFORT); + controls.room = Some(room); + } + (Reasoner::Always, true) => { + controls.openrouter_effort = Some(REASONING_EFFORT); + controls.room = Some(LONG_REASONING_TOKENS); + } + (Reasoner::Always, false) => controls.room = Some(LONG_REASONING_TOKENS), + (Reasoner::Toggle, false) => controls.no_thinking = true, + (Reasoner::Toggle, true) => controls.openrouter_effort = Some(NO_REASONING), + } + controls +} + +/// The family of `name` (a model id without its vendor prefix, lowercase). +fn reasoner(name: &str) -> Reasoner { + if name.starts_with("gpt-oss") { + return Reasoner::Effort(REASONING_TOKENS); + } + if name.starts_with("gemini-") || name.starts_with("kimi-k3") { + return Reasoner::Effort(LONG_REASONING_TOKENS); + } + if name.starts_with("kimi-k2") { + return if name.contains("code") { + Reasoner::Always + } else if ["kimi-k2.5", "kimi-k2.6", "kimi-k2-5", "kimi-k2-6"].iter().any(|p| name.starts_with(p)) { + Reasoner::Toggle + } else { + Reasoner::No + }; + } + if let Some(version) = glm_version(name) { + // GLM-5.3 always reasons (docs.z.ai, thinking mode); the thinking switch came with GLM-4.5. + return if version >= (5, 3) { + Reasoner::Effort(LONG_REASONING_TOKENS) + } else if version >= (4, 5) { + Reasoner::Toggle + } else { + Reasoner::No + }; + } + if name.starts_with("mimo-") && name != "mimo-v2.5-pro-ultraspeed" { + return Reasoner::Toggle; + } + if name.starts_with("hy3") || name.starts_with("hy4") { + return Reasoner::Toggle; + } + // Ark's `doubao-seed-…`, OpenRouter's `seed-…`; the character and translation models do not reason. + if (name.starts_with("doubao-seed-") || name.starts_with("seed-")) && !name.contains("character") && !name.contains("translation") { + return Reasoner::Toggle; + } + Reasoner::No +} -/// `model` reasons before it answers and takes `reasoning_effort`: OpenAI's gpt-oss, with or -/// without a vendor prefix (`openai/gpt-oss-120b` on Groq, `gpt-oss:20b` on Ollama). -fn reasons_first(model: &str) -> bool { - model.trim().rsplit('/').next().is_some_and(|name| name.to_ascii_lowercase().starts_with("gpt-oss")) +/// `glm-5.3-flash` → (5, 3), `glm-5-turbo` → (5, 0), Ark's `glm-5-3-flash-260828` → (5, 3). +fn glm_version(name: &str) -> Option<(u32, u32)> { + let rest = name.strip_prefix("glm-")?; + let major_len = rest.bytes().take_while(u8::is_ascii_digit).count(); + let major = rest.get(..major_len)?.parse().ok()?; + let after = &rest[major_len..]; + let minor = match after.strip_prefix('.').or_else(|| after.strip_prefix('-')) { + Some(tail) => { + let digits = tail.bytes().take_while(u8::is_ascii_digit).count(); + // Two digits at most: Ark's date suffix (`glm-5-260211`) is not a version. + if (1..=2).contains(&digits) { tail[..digits].parse().ok()? } else { 0 } + } + None => 0, + }; + Some((major, minor)) } /// A Responses request (docs/dictation.md §3.7): no temperature and no output limit. @@ -345,26 +484,31 @@ impl RefineClient { let retry_after_ms = retry_after_ms(response.headers()); // The body of a refusal is only read to sort it: one that cannot be read sorts by status. let body = response.text().await.unwrap_or_default(); - return Err(completion_error(status, retry_after_ms, &body)); + return Err(refusal(status, retry_after_ms, &body)); } response.text().await.map_err(map_reqwest) } /// One chat completion ([`TEMPERATURE`], `max_tokens`): the first choice's content and finish - /// reason. A model that reasons first gets [`REASONING_EFFORT`] and [`REASONING_TOKENS`] more, - /// up to the configured ceiling (the built-in service's free tier refuses more, - /// [`BUILTIN_OUTPUT_CAP`]). + /// reason. A model that reasons first is asked not to, or to reason little, and gets room for + /// it ([`chat_controls`]), up to the configured ceiling (the built-in service's free tier + /// refuses more, [`BUILTIN_OUTPUT_CAP`]). async fn complete_chat(&self, system: &str, user: &str, max_tokens: u32) -> Result { let started = Instant::now(); - let reasoning = reasons_first(&self.config.model); - let max_tokens = if reasoning { max_tokens.saturating_add(REASONING_TOKENS).min(self.config.output_cap.max(max_tokens)) } else { max_tokens }; + let controls = chat_controls(&self.config.base_url, &self.config.model); + let max_tokens = match controls.room { + Some(room) => max_tokens.saturating_add(room).min(self.config.output_cap.max(max_tokens)), + None => max_tokens, + }; let payload = ChatRequest { model: &self.config.model, - temperature: TEMPERATURE, + temperature: controls.temperature.then_some(TEMPERATURE), max_tokens, messages: [Message { role: "system", content: system }, Message { role: "user", content: user }], enable_thinking: self.config.enable_thinking, - reasoning_effort: reasoning.then_some(REASONING_EFFORT), + reasoning_effort: controls.reasoning_effort, + thinking: controls.no_thinking.then_some(Thinking { kind: "disabled" }), + reasoning: controls.openrouter_effort.map(|effort| Reasoning { effort }), }; let body = self.post(&payload).await?; let parsed: ChatResponse = @@ -408,20 +552,25 @@ impl RefineClient { } } -/// A refused completion: a used-up quota first, whatever the status (docs/dictation.md §3.5; Model -/// Studio answers 免费额度用完即停 with 403, which used to read as a wrong key), then by status. -fn completion_error(status: StatusCode, retry_after_ms: Option, body: &str) -> RefineError { +/// A refused request: a used-up quota first, whatever the status (docs/dictation.md §3.5; Model +/// Studio answers 免费额度用完即停 with 403, which used to read as a wrong key), then by status, +/// each with what the service said (§3.12): the pill, the history and 测试连接 show it. +fn refusal(status: StatusCode, retry_after_ms: Option, body: &str) -> RefineError { let (code, message) = error_fields(body); if is_quota_exhausted(&code, &message) { - return RefineError::QuotaExhausted { code, message: truncate_chars(&message, MAX_ERROR_BODY_CHARS) }; + return RefineError::QuotaExhausted { code, message: voltip_reply::clean(&message) }; } + let message = voltip_reply::summary(body); if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { - return RefineError::Unauthorized; + return RefineError::Unauthorized { message }; } if status == StatusCode::TOO_MANY_REQUESTS { - return RefineError::RateLimited { retry_after_ms }; + return RefineError::RateLimited { retry_after_ms, message }; + } + if status == StatusCode::SERVICE_UNAVAILABLE { + return RefineError::Overloaded { message }; } - RefineError::Server { status: status.as_u16(), body: truncate_chars(body, MAX_ERROR_BODY_CHARS) } + RefineError::Server { status: status.as_u16(), body: message } } /// The models an OpenAI-compatible service lists (`GET {base}/models`, each `data[].id`): the @@ -463,16 +612,17 @@ pub async fn list_models( }; let response = send(request).await?; let status = response.status(); - if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { - return Err(RefineError::Unauthorized); - } - if status == StatusCode::TOO_MANY_REQUESTS { - return Err(RefineError::RateLimited { retry_after_ms: retry_after_ms(response.headers()) }); - } - let body = response.text().await.map_err(map_reqwest)?; if !status.is_success() { - return Err(RefineError::Server { status: status.as_u16(), body: truncate_chars(&body, MAX_ERROR_BODY_CHARS) }); + let retry_after_ms = retry_after_ms(response.headers()); + // The body of a refusal is only read to say why: one that cannot be read sorts by status. + let message = voltip_reply::summary(&response.text().await.unwrap_or_default()); + return Err(match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => RefineError::Unauthorized { message }, + StatusCode::TOO_MANY_REQUESTS => RefineError::RateLimited { retry_after_ms, message }, + _ => RefineError::Server { status: status.as_u16(), body: message }, + }); } + let body = response.text().await.map_err(map_reqwest)?; let parsed: ModelList = serde_json::from_str(&body).map_err(|e| RefineError::BadResponse(format!("{e}: {}", truncate_chars(&body, MAX_ERROR_BODY_CHARS))))?; Ok(parsed.data.into_iter().map(|m| m.id).filter(|id| !id.trim().is_empty()).collect()) @@ -721,7 +871,7 @@ mod tests { assert_eq!(err, RefineError::Truncated); server.reset().await; Mock::given(method("POST")).and(path("/v1/responses")).respond_with(ResponseTemplate::new(429).insert_header("retry-after", "7")).mount(&server).await; - assert_eq!(client.refine("x", None).await.unwrap_err(), RefineError::RateLimited { retry_after_ms: Some(7000) }); + assert_eq!(client.refine("x", None).await.unwrap_err(), RefineError::RateLimited { retry_after_ms: Some(7000), message: String::new() }); server.reset().await; Mock::given(method("POST")) .and(path("/v1/responses")) @@ -900,20 +1050,26 @@ mod tests { for status in [401_u16, 403] { let server = MockServer::start().await; mount(&server, ResponseTemplate::new(status)).await; - assert_eq!(client(&server, Some("k")).refine("x", None).await.unwrap_err(), RefineError::Unauthorized); + assert_eq!(client(&server, Some("k")).refine("x", None).await.unwrap_err(), RefineError::Unauthorized { message: String::new() }); } let server = MockServer::start().await; mount(&server, ResponseTemplate::new(429).insert_header("Retry-After", "7")).await; - assert_eq!(client(&server, None).refine("x", None).await.unwrap_err(), RefineError::RateLimited { retry_after_ms: Some(7000) }); + assert_eq!(client(&server, None).refine("x", None).await.unwrap_err(), RefineError::RateLimited { retry_after_ms: Some(7000), message: String::new() }); let server = MockServer::start().await; mount(&server, ResponseTemplate::new(429)).await; - assert_eq!(client(&server, None).refine("x", None).await.unwrap_err(), RefineError::RateLimited { retry_after_ms: None }); + assert_eq!(client(&server, None).refine("x", None).await.unwrap_err(), RefineError::RateLimited { retry_after_ms: None, message: String::new() }); + + let server = MockServer::start().await; + mount(&server, ResponseTemplate::new(502).set_body_string("y".repeat(500))).await; + let err = client(&server, None).refine("x", None).await.unwrap_err(); + assert!(matches!(&err, RefineError::Server { status: 502, body } if body.chars().count() == MAX_ERROR_BODY_CHARS), "{err:?}"); + assert!(err.is_retryable()); let server = MockServer::start().await; mount(&server, ResponseTemplate::new(503).set_body_string("y".repeat(500))).await; let err = client(&server, None).refine("x", None).await.unwrap_err(); - assert!(matches!(&err, RefineError::Server { status: 503, body } if body.chars().count() == MAX_ERROR_BODY_CHARS), "{err:?}"); + assert!(matches!(&err, RefineError::Overloaded { message } if message.chars().count() == MAX_ERROR_BODY_CHARS), "{err:?}"); assert!(err.is_retryable()); let server = MockServer::start().await; @@ -948,10 +1104,37 @@ mod tests { } let server = MockServer::start().await; mount(&server, ResponseTemplate::new(403).set_body_json(json!({ "error": { "code": "invalid_api_key", "message": "Incorrect API key" } }))).await; - assert_eq!(client(&server, Some("k")).refine("x", None).await.unwrap_err(), RefineError::Unauthorized); + assert_eq!( + client(&server, Some("k")).refine("x", None).await.unwrap_err(), + RefineError::Unauthorized { message: "Incorrect API key (invalid_api_key)".into() } + ); let server = MockServer::start().await; mount(&server, ResponseTemplate::new(429).set_body_json(json!({ "code": "Throttling.RateQuota", "message": "Requests rate limit exceeded" }))).await; - assert_eq!(client(&server, Some("k")).refine("x", None).await.unwrap_err(), RefineError::RateLimited { retry_after_ms: None }); + assert_eq!( + client(&server, Some("k")).refine("x", None).await.unwrap_err(), + RefineError::RateLimited { retry_after_ms: None, message: "Requests rate limit exceeded (Throttling.RateQuota)".into() } + ); + } + + /// Regression (2026-10-24, Google AI Studio): `gemini-3.8-flash` answered every clean-up for 40 + /// minutes with a 503 whose body is an array (`[{"error": {"code": 503, "message": "This model + /// is currently experiencing high demand…"}}]`); it read as a plain server error with the raw + /// JSON, and the interface only said 请求失败. A 503 is now an overloaded service with the + /// service's own message, and every other refusal keeps the message rather than the JSON. + #[tokio::test] + async fn regression_an_overloaded_model_is_told_apart_with_the_services_message() { + let high_demand = "This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later."; + let server = MockServer::start().await; + let body = json!([{ "error": { "code": 503, "message": high_demand, "status": "UNAVAILABLE" } }]); + mount(&server, ResponseTemplate::new(503).set_body_json(body)).await; + let err = client(&server, Some("k")).refine("x", None).await.unwrap_err(); + assert_eq!(err, RefineError::Overloaded { message: format!("{high_demand} (UNAVAILABLE)") }); + assert_eq!(client(&server, Some("k")).edit("你好", "改成英文", &PromptHints::default()).await.unwrap_err(), err); + + let gone = "This model models/gemini-2.5-flash is no longer available to new users."; + let server = MockServer::start().await; + mount(&server, ResponseTemplate::new(404).set_body_json(json!([{ "error": { "code": 404, "message": gone, "status": "NOT_FOUND" } }]))).await; + assert_eq!(client(&server, Some("k")).refine("x", None).await.unwrap_err(), RefineError::Server { status: 404, body: format!("{gone} (NOT_FOUND)") }); } #[tokio::test] @@ -1092,7 +1275,10 @@ mod tests { let denied = MockServer::start().await; Mock::given(method("GET")).respond_with(ResponseTemplate::new(401)).mount(&denied).await; - assert_eq!(list_models(&denied.uri(), Some("bad"), Duration::from_secs(5), &voltip_net::Network::new()).await.unwrap_err(), RefineError::Unauthorized); + assert_eq!( + list_models(&denied.uri(), Some("bad"), Duration::from_secs(5), &voltip_net::Network::new()).await.unwrap_err(), + RefineError::Unauthorized { message: String::new() } + ); let broken = MockServer::start().await; Mock::given(method("GET")).respond_with(ResponseTemplate::new(502).set_body_string("bad gateway")).mount(&broken).await; assert!(matches!( @@ -1191,6 +1377,120 @@ mod tests { assert_eq!(last["max_tokens"], 864 + 256); } + /// Regression (2026-10-24, Google AI Studio): Gemini thinks before it answers, and the thinking + /// counts against `max_tokens`. `gemini-3.8-flash` spent 146 of a short take's 152 tokens on it + /// and stopped at `length` after 「请问现在」, so the clean-up failed even when the model had + /// room to serve. Gemini gets low effort and room to think, with or without a prefix, on the + /// ceiling of the user's own service. + #[tokio::test] + async fn regression_gemini_gets_low_effort_and_room_to_think() { + let server = MockServer::start().await; + mount(&server, answer("好。")).await; + let long = "很".repeat(400); + for model in ["gemini-3.8-flash", "models/gemini-3.5-flash-lite", "google/gemini-2.5-pro"] { + let config = RefineConfig::new(server.uri(), model).with_output_cap(crate::USER_OUTPUT_CAP); + let client = RefineClient::new(config).unwrap(); + client.refine("好", None).await.unwrap(); + client.refine(&long, None).await.unwrap(); + client.edit("好", "改一下", &PromptHints::default()).await.unwrap(); + } + let gemma = RefineClient::new(RefineConfig::new(server.uri(), "gemma-3-27b-it").with_output_cap(crate::USER_OUTPUT_CAP)).unwrap(); + gemma.refine("好", None).await.unwrap(); + let bodies: Vec = server.received_requests().await.unwrap().iter().map(|r| serde_json::from_slice(&r.body).unwrap()).collect(); + for gemini in bodies[..9].chunks(3) { + let budgets: Vec = gemini.iter().map(|b| b["max_tokens"].as_u64().unwrap()).collect(); + assert_eq!(budgets, [128 + 1024, 864 + 1024, 256 + 1024], "{}", gemini[0]["model"]); + assert!(gemini.iter().all(|b| b["reasoning_effort"] == "low"), "{}", gemini[0]["model"]); + } + assert!(bodies[9].get("reasoning_effort").is_none(), "Gemma does not think: {}", bodies[9]); + assert_eq!(bodies[9]["max_tokens"], 128); + } + + /// docs/dictation.md §3.10 (2026-10-25): each vendor's way of making its models answer at once, + /// by the model's name wherever it is served, and OpenRouter's own form of it. + #[test] + fn each_family_is_asked_to_answer_at_once_its_own_way() { + let native = "https://api.moonshot.cn/v1"; + let openrouter = "https://openrouter.ai/api/v1"; + let effort = |room| ChatControls { temperature: true, reasoning_effort: Some("low"), room: Some(room), ..ChatControls::default() }; + let toggle = ChatControls { temperature: true, no_thinking: true, ..ChatControls::default() }; + let plain = ChatControls { temperature: true, ..ChatControls::default() }; + let kimi = |c: ChatControls| ChatControls { temperature: false, ..c }; + let table = [ + ("openai/gpt-oss-120b", effort(256)), + ("models/gemini-3.8-flash", effort(1024)), + ("kimi-k3", kimi(effort(1024))), + ("kimi-k3.1-pro-highspeed", kimi(effort(1024))), + ("kimi-k2.7-code", ChatControls { temperature: false, room: Some(1024), ..ChatControls::default() }), + ("kimi-k2.6", kimi(toggle)), + ("kimi-k2-0905-preview", kimi(plain)), + ("glm-5.3-flash", effort(1024)), + ("glm-5.3", effort(1024)), + ("glm-5.2", toggle), + ("glm-5-turbo", toggle), + ("glm-4.7-flash", toggle), + ("glm-4.5-air", toggle), + ("glm-4-plus", plain), + ("glm-5-3-flash-260828", effort(1024)), + ("glm-4-7-251222", toggle), + ("glm-5-260211", toggle), + ("mimo-v2.6-flash", toggle), + ("mimo-v2.5-pro-ultraspeed", plain), + ("hy3", toggle), + ("HY4-Preview", toggle), + ("hy-mt2-pro", plain), + ("doubao-seed-2-1-lite-260915", toggle), + ("doubao-seed-character-251128", plain), + ("qwen3.8-flash", plain), + ("deepseek-flash", plain), + ]; + for (model, want) in table { + assert_eq!(chat_controls(native, model), want, "{model}"); + } + // OpenRouter: `reasoning.effort` instead, `none` where the model can stop reasoning. + let routed = |effort, room| ChatControls { temperature: true, openrouter_effort: Some(effort), room, ..ChatControls::default() }; + let table = [ + ("moonshotai/kimi-k3", ChatControls { temperature: false, ..routed("low", Some(1024)) }), + ("google/gemini-3.5-flash-lite", routed("low", Some(1024))), + ("z-ai/glm-4.7", routed("none", None)), + ("bytedance-seed/seed-2.0-mini", routed("none", None)), + ("xiaomi/mimo-v2.6-flash", routed("none", None)), + ("openai/gpt-6-luna", plain), + ("deepseek/deepseek-v4-flash", plain), + ]; + for (model, want) in table { + assert_eq!(chat_controls(openrouter, model), want, "{model}"); + } + assert_eq!(glm_version("glm-5v-turbo"), Some((5, 0))); + assert_eq!(glm_version("glm-4.6v"), Some((4, 6))); + assert_eq!(glm_version("chatglm3-6b"), None); + } + + /// Regression (2026-10-25, before Kimi was added): every Kimi model fixes its temperature and + /// answers another one with 400, and K3 reasons at `max` unless asked; GLM-4.7 reasons unless + /// told not to. The requests carry what each vendor documents, and nothing it refuses. + #[tokio::test] + async fn regression_kimi_gets_no_temperature_and_glm_no_thinking() { + let server = MockServer::start().await; + mount(&server, answer("好。")).await; + for model in ["kimi-k3.1-pro-highspeed", "kimi-k2.6", "glm-4.7-flash", "glm-5.3-flash"] { + let config = RefineConfig::new(server.uri(), model).with_output_cap(crate::USER_OUTPUT_CAP); + RefineClient::new(config).unwrap().refine("好", None).await.unwrap(); + } + let bodies: Vec = server.received_requests().await.unwrap().iter().map(|r| serde_json::from_slice(&r.body).unwrap()).collect(); + let [k3, k26, glm47, glm53] = [&bodies[0], &bodies[1], &bodies[2], &bodies[3]]; + assert!(k3.get("temperature").is_none() && k26.get("temperature").is_none(), "{k3} {k26}"); + assert_eq!((k3["reasoning_effort"].as_str(), k3["max_tokens"].as_u64()), (Some("low"), Some(128 + 1024))); + assert!(k3.get("thinking").is_none(), "K3 refuses the K2 switch: {k3}"); + assert_eq!(k26["thinking"], json!({ "type": "disabled" })); + assert_eq!((glm47["thinking"].clone(), glm47["max_tokens"].as_u64()), (json!({ "type": "disabled" }), Some(128))); + assert!((glm47["temperature"].as_f64().unwrap() - f64::from(TEMPERATURE)).abs() < 1e-6); + assert_eq!((glm53["reasoning_effort"].as_str(), glm53.get("thinking")), (Some("low"), None), "GLM-5.3 cannot stop reasoning"); + for body in &bodies { + assert!(body.get("reasoning").is_none(), "OpenRouter's field is for OpenRouter: {body}"); + } + } + /// docs/dictation.md §3.10: a clean-up cut off at `max_tokens` is refused as an edit's is, so the /// take keeps its own text instead of losing its end; so is one whose reasoning took the whole /// budget and left no answer. @@ -1226,7 +1526,7 @@ mod tests { // The shared transport still classifies HTTP failures for the edit. let server = MockServer::start().await; mount(&server, ResponseTemplate::new(401)).await; - assert_eq!(client(&server, Some("k")).edit("x", "y", &PromptHints::default()).await.unwrap_err(), RefineError::Unauthorized); + assert_eq!(client(&server, Some("k")).edit("x", "y", &PromptHints::default()).await.unwrap_err(), RefineError::Unauthorized { message: String::new() }); // Without a finish reason the answer is taken (older gateways omit it). let server = MockServer::start().await; mount(&server, ResponseTemplate::new(200).set_body_json(json!({ "choices": [{ "message": { "content": "Hello" } }] }))).await; diff --git a/crates/voltip-refine/src/error.rs b/crates/voltip-refine/src/error.rs index ecc2d7b4..32681824 100644 --- a/crates/voltip-refine/src/error.rs +++ b/crates/voltip-refine/src/error.rs @@ -2,26 +2,45 @@ /// Refinement failures, sorted by what the caller can do about them. None of them should stop /// a dictation: the caller falls back to the raw transcript. +/// +/// The interface shows the opening of each message in its own language and the service's words +/// after it (`serviceReasonText` in packages/shared/src/labels.ts, docs/dictation.md §3.12): a +/// changed `#[error]` text changes that table too. #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum RefineError { /// The configuration cannot produce a request (bad URL, empty model, zero timeout). #[error("invalid refine configuration: {0}")] InvalidConfig(String), /// The service answered 401 or 403: the key is missing, wrong or expired. - #[error("refine service rejected the credentials")] - Unauthorized, + #[error("refine service rejected the credentials{}", said(.message))] + Unauthorized { + /// What the service said ([`voltip_reply::summary`]); empty when it said nothing. + message: String, + }, /// The service answered 429. - #[error("refine service rate limited{}", retry_suffix(.retry_after_ms))] + #[error("refine service rate limited{}{}", retry_suffix(.retry_after_ms), said(.message))] RateLimited { /// `Retry-After` in milliseconds when the service sent one as a delay in seconds. retry_after_ms: Option, + /// What the service said ([`voltip_reply::summary`]); empty when it said nothing. + message: String, + }, + /// The service has more requests than it can serve right now (HTTP 503): Gemini answers + /// `UNAVAILABLE` "This model is currently experiencing high demand" (Google AI Studio, + /// 2026-10-24, for 40 minutes on end). Like a rate limit it passes, and it is not a used-up + /// quota (docs/dictation.md §3.6). + #[error("refine service overloaded (HTTP 503){}", said(.message))] + Overloaded { + /// What the service said ([`voltip_reply::summary`]); empty when it said nothing. + message: String, }, /// Any other non-2xx answer. - #[error("refine server error {status}: {body}")] + #[error("refine server error {status}{}", said(.body))] Server { /// HTTP status code. status: u16, - /// Response body, cut to [`crate::MAX_ERROR_BODY_CHARS`] characters. + /// What the service said ([`voltip_reply::summary`]): its error's message and code, a + /// page's title or the text, cut to [`crate::MAX_ERROR_BODY_CHARS`] characters. body: String, }, /// DNS, TCP, TLS or a connection dropped mid-request. @@ -61,29 +80,25 @@ pub fn is_quota_exhausted(code: &str, message: &str) -> bool { code.contains("FreeTierOnly") || message.contains("FreeTierOnly") || code == "insufficient_quota" } -/// The `code` and `message` of an error body, OpenAI's form (`{"error": {"code", "message"}}`) or -/// Model Studio's native one (`{"code", "message"}`); empty strings when the body has neither. +/// The `code` and `message` of an error body in every shape the providers send +/// ([`voltip_reply::error_fields`]: OpenAI's, Model Studio's, Google AI Studio's array, …); empty +/// strings when the body has neither. pub(crate) fn error_fields(body: &str) -> (String, String) { - let Ok(value) = serde_json::from_str::(body) else { return (String::new(), String::new()) }; - let field = |v: &serde_json::Value, key: &str| match v.get(key) { - Some(serde_json::Value::String(s)) => s.clone(), - Some(serde_json::Value::Null) | None => String::new(), - Some(other) => other.to_string(), - }; - match value.get("error") { - Some(inner @ serde_json::Value::Object(_)) => (field(inner, "code"), field(inner, "message")), - _ => (field(&value, "code"), field(&value, "message")), - } + let fields = voltip_reply::error_fields(body); + (fields.code, fields.message) } impl RefineError { - /// Whether the same request may succeed if sent again: rate limits, 5xx, network and - /// timeout. Configuration, credential, malformed-response and empty-answer errors are not. + /// Whether the same request may succeed if sent again: rate limits, an overloaded service, 5xx, + /// network and timeout. Configuration, credential, malformed-response and empty-answer errors + /// are not. pub fn is_retryable(&self) -> bool { match self { - Self::RateLimited { .. } | Self::Network(_) | Self::Timeout => true, + Self::RateLimited { .. } | Self::Overloaded { .. } | Self::Network(_) | Self::Timeout => true, Self::Server { status, .. } => *status >= 500, - Self::InvalidConfig(_) | Self::Unauthorized | Self::BadResponse(_) | Self::EmptyAnswer | Self::Truncated | Self::QuotaExhausted { .. } => false, + Self::InvalidConfig(_) | Self::Unauthorized { .. } | Self::BadResponse(_) | Self::EmptyAnswer | Self::Truncated | Self::QuotaExhausted { .. } => { + false + } } } } @@ -92,6 +107,11 @@ fn retry_suffix(retry_after_ms: &Option) -> String { retry_after_ms.map_or_else(String::new, |ms| format!(" (retry after {ms} ms)")) } +/// `: `, or nothing when it said nothing. +fn said(message: &str) -> String { + if message.is_empty() { String::new() } else { format!(": {message}") } +} + #[cfg(test)] mod tests { use super::*; @@ -99,10 +119,15 @@ mod tests { #[test] fn messages_are_plain() { assert_eq!(RefineError::InvalidConfig("x".into()).to_string(), "invalid refine configuration: x"); - assert_eq!(RefineError::Unauthorized.to_string(), "refine service rejected the credentials"); - assert_eq!(RefineError::RateLimited { retry_after_ms: None }.to_string(), "refine service rate limited"); - assert_eq!(RefineError::RateLimited { retry_after_ms: Some(1500) }.to_string(), "refine service rate limited (retry after 1500 ms)"); - assert_eq!(RefineError::Server { status: 503, body: "down".into() }.to_string(), "refine server error 503: down"); + assert_eq!(RefineError::Unauthorized { message: String::new() }.to_string(), "refine service rejected the credentials"); + assert_eq!(RefineError::Unauthorized { message: "bad key".into() }.to_string(), "refine service rejected the credentials: bad key"); + assert_eq!(RefineError::RateLimited { retry_after_ms: None, message: String::new() }.to_string(), "refine service rate limited"); + assert_eq!( + RefineError::RateLimited { retry_after_ms: Some(1500), message: "slow down".into() }.to_string(), + "refine service rate limited (retry after 1500 ms): slow down" + ); + assert_eq!(RefineError::Server { status: 502, body: "down".into() }.to_string(), "refine server error 502: down"); + assert_eq!(RefineError::Overloaded { message: "busy".into() }.to_string(), "refine service overloaded (HTTP 503): busy"); assert_eq!(RefineError::Network("reset".into()).to_string(), "refine network error: reset"); assert_eq!(RefineError::Timeout.to_string(), "refine request timed out"); assert_eq!(RefineError::BadResponse("no choices".into()).to_string(), "refine service returned an unexpected response: no choices"); @@ -116,12 +141,13 @@ mod tests { #[test] fn retryable_classification() { - assert!(RefineError::RateLimited { retry_after_ms: Some(1) }.is_retryable()); + assert!(RefineError::RateLimited { retry_after_ms: Some(1), message: String::new() }.is_retryable()); assert!(RefineError::Network(String::new()).is_retryable()); assert!(RefineError::Timeout.is_retryable()); assert!(RefineError::Server { status: 502, body: String::new() }.is_retryable()); + assert!(RefineError::Overloaded { message: String::new() }.is_retryable()); assert!(!RefineError::Server { status: 422, body: String::new() }.is_retryable()); - assert!(!RefineError::Unauthorized.is_retryable()); + assert!(!RefineError::Unauthorized { message: String::new() }.is_retryable()); assert!(!RefineError::InvalidConfig(String::new()).is_retryable()); assert!(!RefineError::BadResponse(String::new()).is_retryable()); assert!(!RefineError::EmptyAnswer.is_retryable()); @@ -138,6 +164,9 @@ mod tests { assert!(!is_quota_exhausted("rate_limit_exceeded", "")); assert_eq!(error_fields(r#"{"error":{"code":"insufficient_quota","message":"m"}}"#), ("insufficient_quota".into(), "m".into())); assert_eq!(error_fields(r#"{"code":"AllocationQuota.FreeTierOnly","message":"m"}"#), ("AllocationQuota.FreeTierOnly".into(), "m".into())); + // Google AI Studio wraps its error in an array, with a numeric code and a status (2026-10-24). + assert_eq!(error_fields(r#"[{"error":{"code":503,"message":"high demand","status":"UNAVAILABLE"}}]"#), ("UNAVAILABLE".into(), "high demand".into())); + assert_eq!(error_fields("[]"), (String::new(), String::new())); assert_eq!(error_fields(r#"{"code":7}"#), ("7".into(), String::new())); assert_eq!(error_fields(""), (String::new(), String::new())); } diff --git a/crates/voltip-reply/Cargo.toml b/crates/voltip-reply/Cargo.toml new file mode 100644 index 00000000..d6b3f165 --- /dev/null +++ b/crates/voltip-reply/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "voltip-reply" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "What a speech or AI service said when it refused a request (docs/dictation.md §3.12): the code and message of its error body, cleaned for the interface, shared by the recognition and AI polish clients." + +[lints] +workspace = true + +[dependencies] +# Credentials and account ids masked out of a service's words. +regex.workspace = true +serde_json.workspace = true diff --git a/crates/voltip-reply/src/lib.rs b/crates/voltip-reply/src/lib.rs new file mode 100644 index 00000000..8f08eee9 --- /dev/null +++ b/crates/voltip-reply/src/lib.rs @@ -0,0 +1,303 @@ +//! What a speech or AI service said when it refused a request (docs/dictation.md §3.12). The +//! recognition and AI polish clients (`voltip-asr`, `voltip-refine`) put it in their errors, so the +//! pill, the history and 测试连接 show the service's own words, not just 请求失败. +//! +//! * [`error_fields`] — the error's `code` and `message`, in every shape the providers send: +//! OpenAI's `{"error": {"code", "message"}}` (Groq, DeepSeek, Ollama's compatible API, vLLM), +//! Model Studio's and SiliconFlow's `{"code", "message"}`, Google AI Studio's +//! `[{"error": {"code", "message", "status"}}]`, Ollama's `{"error": "…"}` and FastAPI's +//! `{"detail": …}`. +//! * [`summary`] — the words to show: the message with its code, an HTML page's title, or the +//! text, through [`clean`]. +//! * [`clean`] — whitespace collapsed, keys, tokens and account ids masked, cut to +//! [`MAX_SUMMARY_CHARS`]. A refusal reaches the interface and the history, and the built-in +//! service's account must not. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] + +use std::sync::LazyLock; + +use regex::Regex; +use serde_json::Value; + +/// Longest text [`clean`] keeps, in characters (the ellipsis included). +pub const MAX_SUMMARY_CHARS: usize = 200; + +/// The `code` and `message` of an error body ([`error_fields`]); empty strings when it has none. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ErrorFields { + /// The service's error code: a string `code`, else Google's `status` (`UNAVAILABLE`), else + /// OpenAI's `type`, else a numeric `code` as digits. + pub code: String, + /// The service's error message, as it sent it. + pub message: String, +} + +/// The `code` and `message` of an error `body`, in the shapes the module documentation lists. A +/// body that is not JSON, or JSON without them, gives empty strings. +pub fn error_fields(body: &str) -> ErrorFields { + serde_json::from_str::(body.trim()).map(|value| fields_of(&value)).unwrap_or_default() +} + +fn fields_of(value: &Value) -> ErrorFields { + // Google AI Studio wraps its error in an array (2026-10-24). + let value = match value { + Value::Array(items) => match items.first() { + Some(first) => first, + None => return ErrorFields::default(), + }, + other => other, + }; + match value.get("error") { + Some(inner @ Value::Object(_)) => ErrorFields { code: code_of(inner), message: message_of(inner) }, + // Ollama's native API: `{"error": "model 'x' not found"}`. + Some(Value::String(message)) => ErrorFields { code: code_of(value), message: message.clone() }, + _ => ErrorFields { code: code_of(value), message: message_of(value) }, + } +} + +fn code_of(value: &Value) -> String { + let text = |key: &str| value.get(key).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned); + text("code").or_else(|| text("status")).or_else(|| text("type")).unwrap_or_else(|| match value.get("code") { + Some(Value::Number(n)) => n.to_string(), + _ => String::new(), + }) +} + +fn message_of(value: &Value) -> String { + for key in ["message", "msg", "error_message", "errorMessage"] { + if let Some(message) = value.get(key).and_then(Value::as_str) { + return message.to_owned(); + } + } + match value.get("detail") { + Some(Value::String(detail)) => detail.clone(), + // FastAPI's validation errors: `[{"loc": […], "msg": "…", "type": "…"}]`. + Some(Value::Array(items)) => items.iter().filter_map(|item| item.get("msg").and_then(Value::as_str)).collect::>().join("; "), + _ => String::new(), + } +} + +/// The service's own words in an error `body`, through [`clean`]: the message of a JSON error, +/// with its code when the message does not name it (an HTTP-like three-digit code says nothing +/// the status does not); the title of an HTML page, or its text; other text as it is. Empty when +/// the body says nothing. +pub fn summary(body: &str) -> String { + let body = body.trim(); + let said = match serde_json::from_str::(body) { + Ok(value) => { + let ErrorFields { code, message } = fields_of(&value); + let message = message.trim(); + let named = code.is_empty() || message.contains(code.as_str()) || is_status_like(&code); + match (message.is_empty(), named) { + (true, _) if !code.is_empty() => code, + // JSON without a message: shown as it came, compact, unless it holds nothing. + (true, _) => match &value { + Value::Null => String::new(), + Value::Object(map) if map.is_empty() => String::new(), + Value::Array(items) if items.is_empty() => String::new(), + other => other.to_string(), + }, + (false, true) => message.to_owned(), + (false, false) => format!("{message} ({code})"), + } + } + Err(_) => page_text(body), + }; + clean(&said) +} + +/// A numeric code of at most three digits: Google's `code: 503`, vLLM's `code: 400`. +fn is_status_like(code: &str) -> bool { + code.len() <= 3 && code.bytes().all(|b| b.is_ascii_digit()) +} + +/// The title of an HTML page (an nginx 502, a captive portal), else its text without tags; other +/// text as it is. +fn page_text(body: &str) -> String { + let lower = body.to_ascii_lowercase(); + if !["")) + && let Some(start) = lower[open..].find('>').map(|i| open + i + 1) + && start <= close + { + let title = body[start..close].trim(); + if !title.is_empty() { + return title.to_owned(); + } + } + PATTERNS.as_ref().map_or_else(String::new, |patterns| patterns.tags.replace_all(body, " ").into_owned()) +} + +/// `text` for the interface: whitespace collapsed, credentials and account ids masked (`sk-***`), +/// cut to [`MAX_SUMMARY_CHARS`] characters with an ellipsis. +pub fn clean(text: &str) -> String { + let collapsed = text.split_whitespace().collect::>().join(" "); + mask(&collapsed).map_or_else(String::new, |masked| shorten(&masked, MAX_SUMMARY_CHARS)) +} + +/// The patterns [`clean`] masks with, and the tags of an HTML page; `None` only if one of these +/// literals did not compile, which a test rules out. +struct Patterns { + tags: Regex, + /// `Bearer `. + bearer: Regex, + /// Provider keys by their prefixes (OpenAI and its copies `sk-`, Groq `gsk_`, xAI `xai-`, + /// Xiaomi MiMo's plan keys `tp-` and `ttp-`) and Google's `AIza…`, masked keys included + /// (`sk-proj-ab****yz`). + keys: Regex, + /// Account ids: Groq's `org_01…` names the built-in service's account in its rate-limit + /// message; OpenAI's `org-…`. + orgs: Regex, + /// A credential in a URL's query: `?key=…`, `&token=…`. + query: Regex, + /// Any other opaque token of 32 or more letters, digits, `-` and `_`, with the dotted parts + /// that follow it: Zhipu's keys are `<32 hex>.<16 letters and digits>`, a JWT has three parts. + opaque: Regex, +} + +impl Patterns { + fn compile() -> Option { + Some(Self { + tags: Regex::new(r"<[^>]*>").ok()?, + bearer: Regex::new(r"(?i)\b(bearer)\s+[A-Za-z0-9._~+/=-]{8,}").ok()?, + keys: Regex::new(r"\b(sk|gsk|xai|rk|pk|tp|ttp)([-_])[A-Za-z0-9*_-]{6,}|\b(AIza)[0-9A-Za-z_*-]{10,}").ok()?, + orgs: Regex::new(r"\b(org)([-_])[A-Za-z0-9]{6,}").ok()?, + query: Regex::new(r"(?i)([?&](?:key|api_key|apikey|token|access_token|signature|sig)=)[^&\s]+").ok()?, + opaque: Regex::new(r"\b[A-Za-z0-9_-]{32,}(?:\.[A-Za-z0-9_-]{8,})*\b").ok()?, + }) + } +} + +static PATTERNS: LazyLock> = LazyLock::new(Patterns::compile); + +/// `text` with credentials and account ids masked; `None` when the patterns are missing, so +/// nothing unmasked is ever shown. +fn mask(text: &str) -> Option { + let patterns = PATTERNS.as_ref()?; + let text = patterns.bearer.replace_all(text, "$1 ***"); + let text = patterns.keys.replace_all(&text, |caps: ®ex::Captures<'_>| match caps.get(3) { + Some(google) => format!("{}***", google.as_str()), + None => format!("{}{}***", &caps[1], &caps[2]), + }); + let text = patterns.orgs.replace_all(&text, "$1$2***"); + let text = patterns.query.replace_all(&text, "$1***"); + // Mixed letters and digits only: a long word or a path segment of letters stays readable. + let text = patterns.opaque.replace_all(&text, |caps: ®ex::Captures<'_>| { + let token = &caps[0]; + let mixed = token.bytes().any(|b| b.is_ascii_digit()) && token.bytes().any(|b| b.is_ascii_alphabetic()); + if mixed { "***".to_owned() } else { token.to_owned() } + }); + Some(text.into_owned()) +} + +fn shorten(text: &str, max: usize) -> String { + if text.chars().count() <= max { + return text.to_owned(); + } + let mut cut: String = text.chars().take(max.saturating_sub(1)).collect(); + cut.truncate(cut.trim_end().len()); + cut.push('…'); + cut +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fields(code: &str, message: &str) -> ErrorFields { + ErrorFields { code: code.into(), message: message.into() } + } + + #[test] + fn every_pattern_compiles() { + assert!(PATTERNS.is_some()); + } + + #[test] + fn the_providers_error_shapes_give_their_code_and_message() { + // OpenAI and the services copying it. + let openai = r#"{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}"#; + assert_eq!(error_fields(openai), fields("invalid_api_key", "Incorrect API key provided")); + assert_eq!(error_fields(r#"{"error":{"message":"m","type":"insufficient_quota","code":null}}"#), fields("insufficient_quota", "m")); + // Model Studio, SiliconFlow. + assert_eq!(error_fields(r#"{"code":"AllocationQuota.FreeTierOnly","message":"m","request_id":"r"}"#), fields("AllocationQuota.FreeTierOnly", "m")); + assert_eq!(error_fields(r#"{"code":20012,"message":"Model does not exist.","data":null}"#), fields("20012", "Model does not exist.")); + // Google AI Studio (2026-10-24). + let google = r#"[{"error":{"code":503,"message":"This model is currently experiencing high demand.","status":"UNAVAILABLE"}}]"#; + assert_eq!(error_fields(google), fields("UNAVAILABLE", "This model is currently experiencing high demand.")); + // Ollama's native API, FastAPI, vLLM's older form. + assert_eq!(error_fields(r#"{"error":"model 'x' not found"}"#), fields("", "model 'x' not found")); + assert_eq!(error_fields(r#"{"detail":"Not Found"}"#), fields("", "Not Found")); + assert_eq!(error_fields(r#"{"detail":[{"loc":["body","file"],"msg":"field required","type":"missing"}]}"#), fields("", "field required")); + assert_eq!( + error_fields(r#"{"object":"error","message":"bad audio","type":"BadRequestError","param":null,"code":400}"#), + fields("BadRequestError", "bad audio") + ); + // Nothing to read. + assert_eq!(error_fields(r#"{"code":7,"message":null}"#), fields("7", "")); + assert_eq!(error_fields("bad gateway"), ErrorFields::default()); + assert_eq!(error_fields("[]"), ErrorFields::default()); + } + + #[test] + fn a_summary_is_the_services_words_with_their_code() { + let openai = r#"{"error":{"message":"Incorrect API key provided.","type":"invalid_request_error","code":"invalid_api_key"}}"#; + assert_eq!(summary(openai), "Incorrect API key provided. (invalid_api_key)"); + let google = r#"[{"error":{"code":503,"message":"This model is currently experiencing high demand.","status":"UNAVAILABLE"}}]"#; + assert_eq!(summary(google), "This model is currently experiencing high demand. (UNAVAILABLE)"); + assert_eq!(summary(r#"{"code":"InvalidParameter","message":"InvalidParameter: the audio is too long"}"#), "InvalidParameter: the audio is too long"); + assert_eq!(summary(r#"{"object":"error","message":"bad audio","code":400}"#), "bad audio", "a status-like code says nothing new"); + assert_eq!(summary(r#"{"code":"Throttling"}"#), "Throttling"); + assert_eq!(summary(r#"{"ok":false}"#), r#"{"ok":false}"#); + assert_eq!((summary("{}"), summary("[]"), summary("null")), (String::new(), String::new(), String::new())); + assert_eq!(summary(" upstream connect error\n\n or disconnect "), "upstream connect error or disconnect"); + assert_eq!(summary(""), ""); + } + + #[test] + fn an_html_page_gives_its_title_or_its_text() { + let nginx = "\r\n502 Bad Gateway\r\n\r\n

502 Bad Gateway

\r\n
nginx
\r\n\r\n"; + assert_eq!(summary(nginx), "502 Bad Gateway"); + assert_eq!(summary("

Service down

"), "Service down"); + } + + /// Regression (2026-10-24): a refusal reaches the pill and the history now, and services name + /// keys and accounts in theirs: OpenAI repeats a wrong key half-masked, Groq names the + /// organization, which for the built-in service is the project's own account. + #[test] + fn regression_keys_tokens_and_accounts_are_masked() { + assert_eq!( + clean("Incorrect API key provided: sk-proj-abcd****************wxyz. You can find your API key at https://platform.openai.com/account/api-keys."), + "Incorrect API key provided: sk-***. You can find your API key at https://platform.openai.com/account/api-keys." + ); + assert_eq!(clean("key gsk_abcdefghijklmnop rejected"), "key gsk_*** rejected"); + assert_eq!(clean("API key AIzaSyA1234567890abcdefghij is invalid"), "API key AIza*** is invalid"); + assert_eq!( + clean("Rate limit reached for model `qwen/qwen3.8-27b` in organization `org_01jabcdefgh12345` service tier `on_demand`"), + "Rate limit reached for model `qwen/qwen3.8-27b` in organization `org_***` service tier `on_demand`" + ); + assert_eq!(clean("Authorization: Bearer abc.def-123456789"), "Authorization: Bearer ***"); + assert_eq!(clean("see https://example.test/v1/x?key=secret&lang=zh"), "see https://example.test/v1/x?key=***&lang=zh"); + assert_eq!(clean("request 3f9c2a7b1e4d8f6a0b5c9d2e7f1a3b4c failed"), "request *** failed"); + // Zhipu's `.` keys whole, and MiMo's plan keys (2026-10-25). + assert_eq!(clean("key 0123456789abcdef0123456789abcdef.AbCdEfGh12345678 is invalid"), "key *** is invalid"); + assert_eq!(clean("token tp-abcdef123456 expired"), "token tp-*** expired"); + // Words are not tokens: a task id stays, and so do model names and long words. + assert_eq!(clean("task-failed for qwen3-asr-flash-realtime"), "task-failed for qwen3-asr-flash-realtime"); + assert_eq!(clean("internationalizationlocalizationaccessibility"), "internationalizationlocalizationaccessibility"); + } + + #[test] + fn a_long_text_is_cut_with_an_ellipsis() { + let long = "字".repeat(300); + let cut = clean(&long); + assert_eq!(cut.chars().count(), MAX_SUMMARY_CHARS); + assert!(cut.ends_with('…')); + assert_eq!(clean(&"a".repeat(MAX_SUMMARY_CHARS)).chars().count(), MAX_SUMMARY_CHARS, "exactly the limit stays whole"); + } +} diff --git a/crates/voltip-tauri-bridge/tests/contract.rs b/crates/voltip-tauri-bridge/tests/contract.rs index 8584cf91..e2799a69 100644 --- a/crates/voltip-tauri-bridge/tests/contract.rs +++ b/crates/voltip-tauri-bridge/tests/contract.rs @@ -509,6 +509,7 @@ fn history_entries() -> Vec { asr_ms: 640, refine_ms: Some(2400), refine_failure: None, + refine_error: None, outcome: Outcome::Inserted { via: Via::Paste }, starred: true, mode: OutputMode::WholeTake, @@ -534,8 +535,10 @@ fn history_entries() -> Vec { duration_ms: 900, asr_ms: 410, refine_ms: None, - // docs/dictation.md §3.6: the clean-up was turned down, the raw text went in. + // docs/dictation.md §3.6: the clean-up was turned down, the raw text went in; the + // service's words stay for the technical details. refine_failure: Some(RefineFailure::RateLimited), + refine_error: Some("服务繁忙:refine service overloaded (HTTP 503): This model is currently experiencing high demand.".into()), // Written before the fallback codes (2026-09-29): no `code`. outcome: Outcome::Clipboard { reason: "没有可粘贴的前台窗口".into(), code: None }, starred: false, @@ -564,6 +567,7 @@ fn history_entries() -> Vec { asr_ms: 380, refine_ms: Some(900), refine_failure: None, + refine_error: None, outcome: Outcome::Inserted { via: Via::Paste }, starred: false, mode: OutputMode::WholeTake, @@ -592,6 +596,7 @@ fn history_entries() -> Vec { asr_ms: 0, refine_ms: None, refine_failure: None, + refine_error: None, outcome: Outcome::Inserted { via: Via::Paste }, starred: false, mode: OutputMode::WholeTake, @@ -1590,7 +1595,17 @@ fn all_events() -> Vec { UiEvent::ProviderProbe(ProbeReport { provider: ProviderId::Builtin, kind: ServiceKind::Asr, - outcome: ProbeOutcome::Failed { reason: ProbeFailure::HttpStatus, status: Some(502) }, + outcome: ProbeOutcome::Failed { reason: ProbeFailure::HttpStatus, status: Some(502), detail: None }, + }), + // docs/dictation.md §3.12: what the service said goes with the reason. + UiEvent::ProviderProbe(ProbeReport { + provider: ProviderId::Openai, + kind: ServiceKind::Llm, + outcome: ProbeOutcome::Failed { + reason: ProbeFailure::Unauthorized, + status: None, + detail: Some("Incorrect API key provided: sk-***. (invalid_api_key)".into()), + }, }), // What the local engines can run on (§10.6): a CPU-only build and one with GPUs. UiEvent::Hardware(HardwareStatus { cpu_threads: 8, gpus: Vec::new() }), diff --git a/docs/acceptance.md b/docs/acceptance.md index 082d9d38..ba305364 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -32,7 +32,10 @@ | 润色未生效的原因与内置服务繁忙提示(2026-10-05,docs/dictation.md §3.6,用户要求内置服务限流时提示,并引导改用自己的 Groq 密钥):`RefineFailure` 六类(服务繁忙、额度用完、未配置、超过 2000 字、空结果、请求失败)写进 `Done` 与历史记录;润色的 429 为 `RateLimited`,不触发候补模型;内置服务限流或额度用完时,桌面首页与手机说话页显示提示并可打开 AI 模型页,润色成功、改用其他服务商或关闭后消失,关闭后一天内不再出现;历史详情显示未润色的原因(桌面与手机);悬浮窗插入后注明「未润色 · 原因」(2026-10-05 用户要求);Groq 预设模型去掉仅企业账号可用的 `llama-3.3-70b-versatile` | `crates/voltip-core/src/dictation/{mod,ports,steps,engine,fakes}.rs`、`crates/voltip-core/src/runtime/notice.rs`、`crates/voltip-core/src/{runtime,ui,providers}.rs`、`crates/voltip-core/src/history/mod.rs`、`crates/voltip-core/src/serve/service.rs`、`crates/voltip-cloud/src/lib.rs`、`crates/voltip-tauri-bridge/src/lib.rs`、`apps/{desktop,mobile}/src-tauri/src/lib.rs`、`packages/shared/src/{schema,labels,providers,mock-backend}.ts`、`packages/ui/src/features/engines/RefineNotice.tsx`、`apps/desktop/src/pages/{Home,History,Overlay}.tsx`、`packages/ui/src/components/Pill.tsx`、`apps/mobile/src/screens/{Devices,Welcome,HistoryEntry,MirrorEntry}.tsx` | `crates/voltip-core/src/dictation/mod.rs@refine_failures_sort_the_error_and_serialize_snake_case`、`crates/voltip-core/src/dictation/steps.rs@a_failed_or_empty_clean_up_keeps_the_text`、`crates/voltip-core/src/dictation/engine.rs@the_kind_of_a_failed_clean_up_reaches_the_phase_and_the_history`、`crates/voltip-core/src/runtime/notice.rs@a_take_the_built_in_service_turned_down_raises_the_notice`、`crates/voltip-core/src/runtime/notice.rs@only_the_built_in_services_capacity_raises_it`、`crates/voltip-core/src/runtime/notice.rs@a_cleaned_up_take_clears_it_and_a_closed_one_stays_away_a_day`、`crates/voltip-core/tests/dictation.rs@the_built_in_services_notice_comes_and_goes`、`crates/voltip-cloud/src/lib.rs@a_used_up_quota_keeps_its_kind_and_answers_name_their_model`、`packages/shared/src/labels.test.ts@names the model, or why the clean-up's text was not used`、`packages/ui/src/features/engines/RefineNotice.test.tsx@shows while the core reports one, leads to the AI models page, and closes through the core`、`apps/desktop/src/pages/Home.test.tsx@the built-in AI polish service's notice leads to AI 模型 and closes`、`apps/desktop/src/pages/History.test.tsx@an entry whose clean-up was turned down says why it is not polished`、`apps/mobile/src/screens/PhoneMic.test.tsx@shows under the talk card on the first screen and leads to AI 模型`、`apps/mobile/src/screens/History.test.tsx@an entry whose clean-up was turned down says why it is not polished`、`packages/ui/src/components/Pill.test.tsx@says why the text went in without polish, and only once the text is in`、`apps/desktop/src/pages/Overlay.test.tsx@a take whose polish was turned down says why on the inserted pill`、`packages/shared/src/mock-backend.test.ts@a turned-down clean-up keeps the raw text and says why` | [x] | | Responses 接口与服务端环境变量密钥(2026-10-05,docs/dictation.md §3.7、§23.5,用户决定只在自定义服务商上提供、带推理强度、voltip-server 可从环境变量读密钥):自定义服务商的 AI 润色可选 Chat Completions 或 Responses;Responses 发 `instructions` + `input` + `store: false`,不发温度和输出上限,推理强度只在设置时以 `reasoning.effort` 发送;答复取消息的 `output_text`,`incomplete` 为截断、`failed` 为无效答复,错误映射与 chat completions 相同;其他服务商的设置带这两项时被拒绝;voltip-server 从 `VOLTIP_KEY_*` 读取密钥,优先于钥匙串,`--check` 只列变量名 | `crates/voltip-refine/src/{config,client,lib}.rs`、`crates/voltip-core/src/{providers,engines,runtime,lib}.rs`、`crates/voltip-cloud/src/lib.rs`、`apps/server/src/{lib,cli}.rs`、`packages/shared/src/{schema,engine-drafts,mock-backend}.ts`、`packages/ui/src/features/engines/ProviderCard.tsx` | `crates/voltip-refine/src/client.rs@the_responses_interface_sends_instructions_and_input_and_reads_output_text`、`crates/voltip-refine/src/client.rs@the_responses_interface_maps_cut_off_answers_and_refusals`、`crates/voltip-core/src/engines.rs@the_custom_clean_up_speaks_the_interface_its_card_chooses`、`crates/voltip-core/tests/dictation.rs@only_the_custom_provider_chooses_its_clean_up_interface`、`crates/voltip-cloud/src/lib.rs@the_custom_interface_and_effort_reach_the_client`、`apps/server/tests/cli.rs@keys_from_the_environment_win_over_the_keychain_and_are_named_never_printed`、`packages/shared/src/engine-drafts.test.ts@the custom clean-up card keeps its interface and the Responses effort`、`packages/ui/src/features/engines/ProviderCard.test.tsx@the custom clean-up card chooses Responses and an effort` | [x] | | 内置服务的识别上传改用 Ogg Opus(2026-10-12,docs/dictation.md §3.9,用户报告 Paseo 经 voltip-server 的手机听写超过 35 s 常失败:WAV 在丢包的跨境连接上要上传 25 s,超过 Paseo 固定的 25 s 等待):发往 `builtin` 的识别请求把 16 位单声道 WAV 编成 Ogg Opus(VoIP、20 ms、24 kbit/s,每包缓冲区 61 字节,因为 `opus-rs` 按缓冲区大小给 SILK 分配码率),文件名 `audio.ogg`;其他服务商与 Opus 不能直接接受的 WAV 照常发 WAV;编码在阻塞线程池中运行 | `crates/voltip-asr/src/{ogg_opus,client,config,lib}.rs`、`crates/voltip-cloud/src/lib.rs`(`asr_upload`) | `crates/voltip-asr/src/ogg_opus.rs@the_file_is_an_ogg_opus_stream_as_rfc_7845_lays_it_out`、`crates/voltip-asr/src/ogg_opus.rs@granule_positions_rise_to_the_end_at_every_length_around_a_page`、`crates/voltip-asr/src/ogg_opus.rs@regression_a_take_stays_at_the_budgets_bitrate_and_decodes_back_to_it`、`crates/voltip-asr/src/ogg_opus.rs@only_a_pcm16_mono_wav_at_an_opus_rate_is_encoded`、`crates/voltip-asr/src/client.rs@an_ogg_opus_upload_sends_the_take_as_opus_and_anything_else_as_the_wav`、`crates/voltip-cloud/src/lib.rs@regression_the_built_in_service_gets_ogg_opus_and_every_other_service_the_wav`;首尔 vLLM 上 7 段样音 × 3 遍与 WAV 的字符差异 0.5 %(§3.9) | [x] | -| 推理模型的润色(2026-10-12,docs/dictation.md §3.10,用户报告手机上内置服务的 `openai/gpt-oss-120b` 润色一直失败:推理用完了 `max_tokens`,正文为空):模型名以 `gpt-oss` 开头时请求带 `reasoning_effort: low`,`max_tokens` 再加 256,不超过上限;润色回答在长度上限处被截断时插入原文 | `crates/voltip-refine/src/client.rs`(`reasons_first`、`complete_chat`、`refine_with`) | `crates/voltip-refine/src/client.rs@regression_a_model_that_reasons_first_gets_low_effort_and_room_to_reason`、`crates/voltip-refine/src/client.rs@regression_a_clean_up_cut_off_at_the_limit_is_refused`;经首尔网关用 voltip-server 实测内置服务的三个模型与 8 个内置预设(§3.10) | [x] | +| 推理模型的润色(2026-10-12,docs/dictation.md §3.10,用户报告手机上内置服务的 `openai/gpt-oss-120b` 润色一直失败:推理用完了 `max_tokens`,正文为空):模型名以 `gpt-oss` 开头时请求带 `reasoning_effort: low`,`max_tokens` 再加 256,不超过上限;润色回答在长度上限处被截断时插入原文 | `crates/voltip-refine/src/client.rs`(`chat_controls`、`complete_chat`、`refine_with`) | `crates/voltip-refine/src/client.rs@regression_a_model_that_reasons_first_gets_low_effort_and_room_to_reason`、`crates/voltip-refine/src/client.rs@regression_a_clean_up_cut_off_at_the_limit_is_refused`;经首尔网关用 voltip-server 实测内置服务的三个模型与 8 个内置预设(§3.10) | [x] | +| Google AI Studio 的润色失败(2026-10-24,docs/dictation.md §3.11,用户报告选用 Google AI Studio 后润色一直「请求失败」,历史写「未启用」,无处查看原因):503 归为「服务繁忙」并保留服务的说明;Gemini 的请求带 `reasoning_effort: low`,`max_tokens` 再加 1 024;请求失败的原因写进历史记录,桌面历史详情在「技术细节」中显示,耗时一行写失败的原因;Google 预设模型改为 Flash-Lite 在前并去掉 2.5 | `crates/voltip-refine/src/{client,error}.rs`、`crates/voltip-core/src/dictation/{ports,mod,engine,fakes}.rs`、`crates/voltip-core/src/history/mod.rs`、`crates/voltip-core/src/providers.rs`、`crates/voltip-cloud/src/lib.rs`、`packages/shared/src/{schema,providers,mock-backend}.ts`、`apps/desktop/src/features/history/RefineNote.tsx`、`apps/desktop/src/pages/history/{HistoryDetail,DetailParts}.tsx` | `crates/voltip-refine/src/client.rs@regression_an_overloaded_model_is_told_apart_with_the_services_message`、`crates/voltip-refine/src/client.rs@regression_gemini_gets_low_effort_and_room_to_think`、`crates/voltip-core/src/dictation/engine.rs@regression_the_history_keeps_why_the_clean_up_failed`、`crates/voltip-cloud/src/lib.rs@a_used_up_quota_keeps_its_kind_and_answers_name_their_model`、`apps/desktop/src/pages/History.test.tsx@a clean-up the service turned down says so in the timings and keeps the service's words`;在用户的 Windows 上按客户端请求实测 Google 的各模型(§3.11) | [x] | +| 国内服务商与 OpenRouter(2026-10-25,docs/dictation.md §3.2、§3.13,用户要求润色加入 Kimi、GLM(Z.ai)等,并选定火山方舟、腾讯混元、小米 MiMo、OpenRouter;每家只留 2–3 个推荐模型,其余从服务商列表获取):六家服务商只做润色,各带推荐模型、密钥页与标志;Kimi 与智谱一张卡片切换中国站与国际站,「获取密钥」打开所选站点的密钥页;请求按模型系列让模型直接回答(Kimi 不带 temperature,K3 与 GLM-5.3 低推理强度并加余量,可关闭推理的模型关闭推理,OpenRouter 用它自己的字段);润色列表去掉图像、视频与图片文字识别模型;遮住智谱与 MiMo 套餐的密钥;百炼识别的推荐模型减为 3 个 | `crates/voltip-core/src/{providers,engines}.rs`、`crates/voltip-refine/src/client.rs`、`crates/voltip-reply/src/lib.rs`、`apps/desktop/src-tauri/src/lib.rs`、`apps/mobile/src-tauri/src/lib.rs`、`apps/mobile-rn/rust/src/commands.rs`、`packages/shared/src/{schema,providers,engine-drafts,provider-marks,backend,tauri-backend,mock-backend}.ts`、`packages/ui/src/features/engines/ProviderCard.tsx`、`apps/mobile-rn/src/features/engines.tsx` | `crates/voltip-core/src/providers.rs@a_vendors_sites_start_with_its_default_and_have_their_own_key_pages`、`crates/voltip-core/src/providers.rs@vendors_carry_a_public_https_base_and_a_default_model_per_service`、`crates/voltip-core/src/providers.rs@each_service_offers_the_listed_models_it_can_use`、`crates/voltip-refine/src/client.rs@each_family_is_asked_to_answer_at_once_its_own_way`、`crates/voltip-refine/src/client.rs@regression_kimi_gets_no_temperature_and_glm_no_thinking`、`crates/voltip-reply/src/lib.rs@regression_keys_tokens_and_accounts_are_masked`、`packages/ui/src/features/engines/ProviderCard.test.tsx@switches Kimi between its China and international sites, each with its own key page`、`packages/ui/src/features/engines/ProviderCard.test.tsx@shows no site switch for a vendor with one address`、`apps/mobile-rn/src/features/engines.test.tsx@Kimi's sheet switches between its China and international sites`;用无效密钥核对六家的地址与 `GET /models`(§3.13) | [x] | +| 服务返回的错误(2026-10-24,docs/dictation.md §3.12,用户要求所有模型和服务商都显示上游错误的精简内容):新 crate `voltip-reply` 读各服务商的错误体(OpenAI、百炼、SiliconFlow、Google 数组、Ollama、FastAPI、HTML 页),遮住密钥、令牌与账号 ID,截到 200 字;识别与润色的 401/403、429、503、其他状态码、百炼与实时识别的错误都带服务的原话;测试连接附上原因;识别、润色、改写失败的标签带原因,悬浮窗截断显示、提示中完整;手机记录详情显示润色失败原因 | `crates/voltip-reply/src/lib.rs`、`crates/voltip-asr/src/{error,client,dashscope,duplex}.rs`、`crates/voltip-refine/src/{error,client}.rs`、`crates/voltip-core/src/providers.rs`、`crates/voltip-cloud/src/lib.rs`、`packages/shared/src/{schema,engine-drafts,labels}.ts`、`packages/ui/src/components/Pill.tsx`、`apps/desktop/src/features/history/RefineNote.tsx`、`apps/mobile-rn/src/screens/Entry.tsx` | `crates/voltip-reply/src/lib.rs@the_providers_error_shapes_give_their_code_and_message`、`crates/voltip-reply/src/lib.rs@a_summary_is_the_services_words_with_their_code`、`crates/voltip-reply/src/lib.rs@regression_keys_tokens_and_accounts_are_masked`、`crates/voltip-asr/src/client.rs@a_used_up_quota_is_read_from_the_body_of_any_refusal`、`crates/voltip-asr/src/dashscope.rs@service_errors_are_sorted`、`crates/voltip-asr/src/duplex.rs@refusals_and_failures_are_sorted`、`crates/voltip-refine/src/client.rs@regression_an_overloaded_model_is_told_apart_with_the_services_message`、`crates/voltip-core/src/providers.rs@probe_reports_are_flat_and_tagged`、`packages/shared/src/engine-drafts.test.ts@a failed probe says what the service said`、`packages/ui/src/components/Pill.test.tsx@a failure with the service's words is cut in the pill and whole in its tooltip` | [x] | | 本地模型的手动下载与导入(docs/dictation.md §10.8,2026-10-02 用户要求):`ModelState.dir` 与 `files`(每个文件的大小和 huggingface.co、hf-mirror.com 两个地址,构建镜像不显示);`model_folder_open` / `model_link_open` 由壳从核心的目录取路径和地址;`model_import`(`CoreCommand::ModelImport`)校验目录中每个文件的大小和 sha256,全部正确写入清单、否则为 `import_incomplete { missing, mismatched }`,重新扫描时保留;下载对已有的正式文件也先校验,不正确就重下;卡片的「手动下载」展开区(设置、实时预览、引导页;每个地址可在浏览器中打开,也可复制) | `crates/voltip-core/src/{models,runtime}.rs`、`crates/voltip-asr-local/src/{store,catalogue}.rs`、`crates/voltip-tauri-bridge/src/lib.rs`、`apps/desktop/src-tauri/src/lib.rs`、`apps/mobile/src-tauri/src/lib.rs`、`apps/desktop/src/pages/settings/engines/{LocalModels.tsx,helpers.ts}`、`packages/shared/src/{schema,backend,tauri-backend,mock-backend}.ts` | `crates/voltip-asr-local/tests/store.rs@a_manual_import_installs_what_was_put_there_and_names_what_is_not`、`crates/voltip-asr-local/tests/store.rs@regression_a_file_put_under_its_final_name_is_checked_before_the_download_trusts_it`、`crates/voltip-core/tests/models.rs@a_manual_import_names_what_is_missing_until_the_files_are_right`、`crates/voltip-tauri-bridge/src/lib.rs@the_manual_download_opens_the_catalogue_folder_and_addresses_only`、`apps/desktop/src-tauri/tests/ipc.rs@with_model_library_the_catalogue_is_listed_and_local_mode_reports_readiness`、`apps/mobile/src-tauri/tests/ipc.rs@hotkeys_are_refused_but_engines_secrets_and_history_work`、`apps/desktop/src/pages/settings/engines/EnginesPane.test.tsx@手动下载 names the folder and every file's addresses, opens them through the shell or copies them, and an import names what it found missing (docs/dictation.md section 10)`、`packages/shared/src/mock-backend.test.ts@model_import verifies, then installs or names what is missing; the folder and the links open what the catalogue names (docs/dictation.md section 10)`、`packages/shared/src/schema.test.ts@a model names its directory and files for a manual download, and an import can come back incomplete` | [x] | | 本地引擎第二代(docs/dictation.md §10 修正):目录五条(`qwen3-asr-0.6b` 均衡 / 推荐 / 默认、`qwen3-asr-1.7b` 高精度、`sense-voice-small` 轻量、`paraformer-zh` 轻量 · 中文、`zipformer-stream-zh-en` 实时预览),`ModelEntry.{engine,tier,capabilities,files: &[ModelFile]}`(1–5 文件),`ModelState.{tier,capabilities}`;transcribe.cpp(`transcribe-cpp = 0.2.3`,MIT,`default-features = false`,静态、CPU、无 DLL、cargo-xwin 交叉编译通过)`GgufLoader` 承接 Qwen3-ASR GGUF;`DefaultLoader` 按 `entry.engine` 分派到 `gguf.rs` / `sherpa.rs`;不足 1.25 s 的录音零填充到 1.25 s;`DEFAULT_LOCAL_MODEL_ID = qwen3-asr-0.6b`;手机壳不链接任何本地引擎 | `crates/voltip-asr-local/src/{catalogue,gguf,sherpa,transcriber}.rs`、`crates/voltip-core/src/models.rs`、`Cargo.toml`(`transcribe-cpp`)、`apps/mobile/src-tauri/Cargo.toml`(无 `voltip-asr-local`) | `crates/voltip-asr-local/src/catalogue.rs@catalogue_invariants`、`crates/voltip-asr-local/src/gguf.rs@resampling_to_the_model_rate_keeps_length_and_passes_16k_through`、`crates/voltip-asr-local/src/gguf.rs@loading_from_an_empty_directory_fails_with_the_entry_name`、`crates/voltip-asr-local/src/sherpa.rs@loaders_refuse_the_other_families`、`crates/voltip-asr-local/src/transcriber.rs@transcriber_checks_installation_caches_the_recogniser_and_maps_errors`、`crates/voltip-asr-local/src/transcriber.rs@decode_wav_handles_mono_stereo_float_and_garbage`(含 `pad_to_min`、`language_for` 与 `DefaultLoader` 分派)、`crates/voltip-core/src/models.rs@install_states_serialize_with_the_kind_tag`、`crates/voltip-asr-local/tests/real.rs@real_qwen3_gguf_transcribes_the_sample`(`#[ignore]`,`VOLTIP_LOCAL_GGUF`;2026-09-25 本机串行 0.6B Q6_K:冷 2230 ms(加载 + 推理)/ 热 1602 ms,16 s 中文样音,文本含「想创建」)、`cmake/x86_64-pc-windows-msvc-toolchain.cmake` + `.cargo/config.toml`(Windows 静态 CRT:`cargo xwin build -p voltip-desktop --target x86_64-pc-windows-msvc --release` 链接通过,`llvm-readobj --coff-imports` 只见系统 DLL 与 `sherpa-onnx-c-api.dll`,无 `MSVCP140.dll`)、`apps/desktop/src-tauri/tests/dictation.rs@build_clients_local_mode_selects_the_model_and_needs_no_endpoint` | [x] | | 流式识别 / 实时预览(docs/dictation.md §11):`voltip-audio` 实时 tap(`RecorderConfig.live_tap`,`live.rs`:rubato 异步 sinc 按块重采样 + `rtrb` 无锁环,满环置 `overrun` 不静默丢样本;`Recorder::live_consumer` 只取一次;`start_with_ready` 首块样本 `on_ready`);core 端口 `StreamingTranscriber::{open,warm}` / `StreamingSession::{feed,poll,finish}` / `LivePcm`,`DictationPorts.streaming`,`AudioSource::start(.., on_ready, live)` + `Capture::live_pcm`;`Listening { ready, live: Option, current, degraded }> }`、`Processing.preview`;`Internal::{CaptureReady,Partial,Segment,StreamDegraded,StreamFinished}`;解码线程 `run_live`(≥ 1600 样本 / 步,`PartialThrottle` ≥ 80 ms,`catch_unwind`);`EngineSettings.live_preview`(默认 true)、`EngineStatus.live_preview_ready`(与 `asr_kind` 无关);`LocalStreamingTranscriber`(sherpa `OnlineRecognizer`,greedy,endpoint rule1 2.0 s / rule2 0.8 s / rule3 20 s,endpoint 后 `get_result` 再 `reset`,常驻 + `warm()` 后台加载);任何流式失败只降级预览,终稿仍来自整段识别 | `crates/voltip-audio/src/{live,recorder}.rs`、`crates/voltip-core/src/dictation/{ports,mod,engine,fakes}.rs`、`crates/voltip-core/src/engines.rs`、`crates/voltip-asr-local/src/{streaming,sherpa}.rs`、`apps/desktop/src-tauri/src/dictation.rs` | `crates/voltip-audio/src/live.rs@chunked_resampling_matches_the_whole_take_within_1e_3_rms`、`crates/voltip-audio/src/live.rs@tap_carries_frames_reports_overrun_and_closes_with_the_producer`、`crates/voltip-audio/src/live.rs@passthrough_upsampling_and_errors`、`crates/voltip-audio/src/recorder.rs@live_tap_follows_the_take_and_closes_on_stop`、`crates/voltip-core/src/dictation/engine.rs@live_preview_streams_partials_and_carries_the_preview_into_processing`、`crates/voltip-core/src/dictation/engine.rs@partial_throttle_admits_one_per_window_and_resets_at_endpoints`、`crates/voltip-core/src/dictation/engine.rs@streaming_failures_degrade_the_preview_and_leave_the_pipeline_alone`、`crates/voltip-core/src/dictation/engine.rs@live_preview_is_off_without_readiness_or_a_streaming_port`、`crates/voltip-core/src/dictation/engine.rs@cancel_while_previewing_closes_the_tap_and_ends_the_worker_without_its_flush`、`crates/voltip-core/src/dictation/engine.rs@capture_ready_marks_listening_once_and_ignores_stale_or_late_marks`、`crates/voltip-core/src/dictation/mod.rs@status_serializes_with_the_phase_tag`、`crates/voltip-core/src/dictation/fakes.rs@fake_streaming_follows_its_script`、`crates/voltip-core/src/engines.rs@live_preview_readiness_follows_the_setting_and_the_streaming_model_only`、`crates/voltip-core/tests/models.rs@download_progress_install_active_and_readiness_through_the_core`、`crates/voltip-asr-local/src/streaming.rs@streaming_transcriber_checks_installation_loads_once_warms_and_maps_errors`、`crates/voltip-asr-local/src/sherpa.rs@endpoint_rules_follow_the_dictation_rhythm`、`crates/voltip-asr-local/tests/real.rs@real_streaming_model_emits_partials`(`#[ignore]`,`VOLTIP_LOCAL_STREAM_DIR`;2026-09-25 本机串行:加载 2.4 s,160 块平均 4.3 ms / 最大 28 ms,30 个不同部分结果,终稿含「想创建」)、`apps/desktop/src-tauri/tests/dictation.rs@recorder_audio_source_live_tap_streams_16k_and_closes_on_stop`、`apps/desktop/src-tauri/tests/ipc.rs@live_preview_events_carry_partials_and_the_processing_preview`、`crates/voltip-tauri-bridge/tests/contract.rs@events_fixture_covers_every_variant_and_matches_serde_output`(`listening.live` / `processing.preview` / `ModelState.tier,capabilities` 夹具) | [x](PulseAudio 回放样音的 smoke 断言 `live` 待做;Windows 真机待用户验证) | @@ -71,6 +74,7 @@ | 中继重连(docs/pairing.md「重连」「中继侧的连接检测」,2026-10-10 用户要求「为 relay 模式增加重连支持,防止网络波动导致配对后断开」):客户端心跳 15 s / pong 10 s(按负载匹配)、退避上限 10 s;`reconnect_now`(核心 `ReconnectRelay`、界面 `relay_reconnect`)跳过退避、对已连接的链路立即发 ping,5 s 无 pong 即重连,由 Android 网络变化与回到前台、桌面主窗口 `online` / 可见、睡眠唤醒(墙上时钟跳变)、连接自检触发;中继的 attach 答复带频道名,`channel_full` 每 4 s 重试、`rate_limited` 按等待时间重试;中继每 10 s ping 每条连接,25 s 无消息即关闭并通知对方;握手重试上限 30 s | `crates/voltip-transport/src/{link,reconnect}.rs`、`crates/voltip-protocol/src/relay.rs`、`crates/voltip-relay/src/{core,server}.rs`、`crates/voltip-core/src/runtime.rs`(`on_attach_refused` / `retry_attaches` / `reconnect_relay` / `woke_up`)、`crates/voltip-core/src/peer.rs`、`crates/voltip-tauri-bridge/src/lib.rs`、`apps/mobile-rn/rust/src/{ffi,shell}.rs`、`apps/mobile-rn/modules/voltip-native/android/src/main/java/dev/voltip/rn/VoltipNativeModule.kt`、`packages/ui/src/hooks/useRelayRecheck.ts`、`apps/desktop/src/shell/Shell.tsx` | `crates/voltip-core/tests/e2e.rs@regression_a_path_that_breaks_without_a_word_heals_on_its_own`、`crates/voltip-core/tests/e2e.rs@reconnect_relay_checks_the_socket_at_once_after_the_network_changed`、`crates/voltip-core/tests/sync.rs@regression_a_relay_path_that_breaks_mid_transfer_is_made_up`、`crates/voltip-relay/tests/socket.rs@regression_a_connection_gone_silent_gives_up_its_place_on_the_channel`、`crates/voltip-relay/tests/socket.rs@a_client_that_only_answers_pings_stays_connected`、`crates/voltip-relay/src/core.rs@attach_answers_name_their_channel`、`crates/voltip-protocol/src/relay.rs@attach_answers_name_their_channel_only_when_they_can`、`crates/voltip-transport/tests/link.rs@reconnect_now_cuts_a_backoff_short`、`crates/voltip-transport/tests/link.rs@reconnect_now_drops_a_socket_that_no_longer_answers`、`crates/voltip-transport/tests/link.rs@reconnect_now_leaves_a_working_link_alone`、`crates/voltip-transport/src/reconnect.rs@grows_exponentially_and_caps`、`crates/voltip-core/src/runtime.rs@a_long_gap_in_the_wall_clock_is_a_wake_up`、`crates/voltip-core/src/peer.rs@regression_failed_relay_handshakes_are_retried_with_backoff`、`packages/ui/src/hooks/useRelayRecheck.test.tsx@asks the relay link to check its socket` | [x](Android 切换网络、Windows 睡眠唤醒的真机验收见发布前测试) | | 更新镜像(docs/dictation.md §9、§20.9,2026-10-10 用户要求「升级不应该依赖 aws 资源,使用 CloudFlare 的免费资源」):可选的编译期 `VOLTIP_UPDATE_MIRROR`(发布构建取仓库变量)是文档站 `/updates/` 的根地址;桌面清单地址依次为 `/latest.json`、`VOLTIP_UPDATE_URL`,无效的镜像值被忽略、不关闭更新;手机依次查询 `/android.json` 与 GitHub 最新版本接口,都失败时报镜像的原因;镜像本身是 `sunerpy/firlab` 的 Pages Functions(GitHub 最新 Release 生成清单、改写安装包地址、首次请求时把文件拷进机房缓存) | `apps/desktop/src-tauri/src/update.rs`(`UpdaterConfig::from_values`、`UPDATE_MIRROR_ENV`)、`apps/mobile-rn/rust/src/update.rs`(`mirror_release`、`latest`)、`.github/workflows/release-candidate.yml`、`.env.build.example` | `apps/desktop/src-tauri/src/update.rs@the_mirror_is_asked_before_the_release_manifest`、`apps/desktop/src-tauri/tests/update.rs@regression_a_mirror_that_cannot_answer_leaves_the_check_to_the_release_manifest`、`apps/mobile-rn/rust/src/update.rs@the_mirror_answers_from_its_base_and_only_an_http_one_counts`、`apps/mobile-rn/rust/tests/update.rs@regression_the_mirror_answers_before_github`、`apps/mobile-rn/rust/tests/update.rs@regression_github_answers_when_the_mirror_cannot`、`apps/mobile-rn/rust/tests/update.rs@when_no_source_answers_the_check_names_the_mirror`;镜像的测试在 `sunerpy/firlab` 的 `voltip/test/updates.test.ts`(10 个);2026-10-10 线上实测:`/updates/latest.json` 四个平台地址都在镜像上,29 MB 安装包经镜像 sha256 与 `SHA256SUMS` 一致 | [x] | | Windows 应用内更新静默安装(docs/dictation.md §9,2026-10-17 用户要求:应用内升级会弹出 Windows 的安装框,参考 winer 的设计改为后台安装并重启,确保用户看不到安装器):注入的 `plugins.updater` 配置按二进制所在的包设置 `windows.installMode`:NSIS 包为 `quiet`(安装器以 `/S /UPDATE /R` 运行,没有窗口,装完重新打开 Voltip),其他包保持 `passive`;新安装器自带钩子,0.1.6 及更早的版本以 `/P /UPDATE` 运行它时,窗口在第一帧之前就被设为透明、不进任务栏、不抢焦点并隐藏;用户自己运行的安装器照常显示 | `apps/desktop/src-tauri/src/update.rs`(`windows_install_mode`、`UpdaterConfig::plugin_config_for`)、`apps/desktop/src-tauri/nsis/installer-hooks.nsh`、`apps/desktop/src-tauri/tauri.windows.conf.json`、`scripts/smoke-windows-update.ps1`、`.github/workflows/ci.yml`(`windows-native`) | `apps/desktop/src-tauri/src/update.rs@regression_a_windows_update_runs_the_nsis_installer_without_a_window`、`apps/desktop/src-tauri/tests/bundle.rs@windows_installer_hides_its_window_when_the_updater_runs_it`、`scripts/smoke-windows-update.ps1@passive update run (0.1.6 and earlier)`、`scripts/smoke-windows-update.ps1@silent update run (after 0.1.6)`(CI `windows-native` 在 windows-2025 的真实桌面上运行:每 5 ms 枚举安装器的顶层窗口,可见且不透明的一次也不允许,退出码 0,并重新打开已安装的 Voltip) | [x](2026-10-17 在用户的 Windows 上,自动更新已打开、代理跟随系统:0.1.6 自己的启动检查先问镜像,Columbus 机房回答 502(`sunerpy/firlab#105` 之前那里回答旧的 0.1.6 清单,应用当成已是最新),插件改问 GitHub、找到 0.1.7,下载到 Ready,下一次启动时安装,安装器装完重新打开 Voltip,已安装版本为 0.1.7。当时经代理下载只有约 30 KB/s,安装发生在采样脚本等待的 6 分钟之后,这次安装本身没有采到窗口样本;随后在同一会话里,用这次更新下载的安装器(SHA-256 与 Release 的 SHA256SUMS 一致)按 0.1.6 的方式(`/P /UPDATE /R /ARGS`)和 0.1.7 起的方式(`/S /UPDATE /R /ARGS`)各运行一次 `scripts/smoke-windows-update.ps1`:前者 1544 次采样、0 次可见、1517 次透明,后者 583 次采样、没有窗口,都退出 0 并重新打开 Voltip。0.1.7 → 0.1.8 是第一次以 `/S` 运行的真实更新) | +| 运行期间再检查更新与标题栏一键重启(docs/dictation.md §9「运行期间再检查」,2026-10-25 用户要求:开启自动更新后定时检测升级并自动下载,在标题栏提示,点击后重启完成安装;做法同 Lockra 0.16.3):开关打开时每次运行结束都把下一次检查排在 4 小时后,保留更早到期的启动检查;只有启动检查可以安装记下的版本,之后的检查只下载;`ready` / `installing` 停止检查,失败 4 小时后再试;关掉开关清掉已排的检查,打开时立即检查、只下载;开关关闭时手动检查不排任何检查。标题栏「重启以更新」一次点击开始安装,悬停提示写明版本。测试构建可用 `VOLTIP_UPDATE_INTERVAL_SECS` 缩短间隔(至少 60 s),清单服务器在本机或局域网时允许 http | `apps/desktop/src-tauri/src/update.rs`(`AutoSchedule`、`UpdateSlot::finish`、`follow_settings`、`UpdaterConfig::local_http`、`auto_check_interval`)、`apps/desktop/src/features/update/UpdateDialog.tsx`(`UpdateBadge`) | `apps/desktop/src-tauri/src/update.rs@the_automatic_looks_follow_the_toggle_and_each_runs_end`、`apps/desktop/src-tauri/src/update.rs@a_runs_end_schedules_the_next_look_through_the_slot`、`apps/desktop/src-tauri/src/update.rs@a_test_build_can_look_again_sooner_but_not_in_a_loop`、`apps/desktop/src-tauri/src/update.rs@plain_http_is_allowed_only_on_this_computer_and_the_local_network`、`apps/desktop/src-tauri/tests/update.rs@regression_auto_update_looks_again_while_voltip_runs`、`apps/desktop/src-tauri/tests/update.rs@a_check_by_hand_with_auto_update_off_schedules_no_look`、`apps/desktop/src/features/update/UpdateDialog.test.tsx@regression: a downloaded update restarts from the title bar in one click`、`apps/desktop/src/features/update/UpdateDialog.test.tsx@the title bar's other notes only open the dialog` | [x](2026-10-25 在用户的 Windows 上:测试构建 A 为 0.1.7,清单服务器在局域网、间隔 120 s、测试密钥;启动 10 s 后检查为已是最新,放上 0.1.8 清单后,第一次检查结束整 120 s 时再检查(不带安装许可),下载并校验 30 MB 安装包,此后不再检查;用界面自动化点击标题栏的按钮「重启以更新」(Button,提示「重启 Voltip,完成更新到 0.1.8」),3.3 s 后插件启动安装器,A 退出,约 6 s 后 0.1.8 打开。随后用 `scripts/smoke-windows-update.ps1` 按插件的参数运行同一安装器:`/S /UPDATE /R /ARGS` 512 次采样没有窗口,`/P /UPDATE /R /ARGS` 833 次采样 0 次可见、811 次透明,都退出 0 并重新打开 Voltip) | | 常开配对(docs/pairing.md「常开配对」,2026-09-28):电脑「手机」页的开关打开后一直有会话等手机(开着启动也是);剩 10 秒时换新会话,不显示「已过期」;中继还没连上时等它连上再开;一次配对结束后停 4 秒再开下一个,开不了每 5 秒重试;每次仍要在电脑上核对安全码并确认;关掉即关闭还没人加入的会话;手机的核心拒绝打开 | `crates/voltip-core/src/runtime/always_on.rs`、`crates/voltip-core/src/settings.rs`(`pairing_always_on`)、`crates/voltip-tauri-bridge/src/lib.rs`、`apps/desktop/src/features/pairing/PairingPanel.tsx`、`apps/desktop/src/pages/Devices.tsx`、`packages/shared/src/mock-backend.ts` | `crates/voltip-core/tests/e2e.rs@always_on_pairing_keeps_a_session_waiting_until_switched_off`(真中继:续期不过期、配对后开下一个、关掉后不再开、手机拒绝、中继晚到时等它连上再开)、`crates/voltip-core/src/settings.rs@settings_written_before_always_on_pairing_read_it_off`、`apps/desktop/src-tauri/tests/ipc.rs@always_on_pairing_opens_a_session_and_closes_it_when_off`、`apps/mobile/src-tauri/tests/ipc.rs@always_on_pairing_is_refused_on_the_phone`、`apps/desktop/src/pages/Devices.test.tsx@regression: always-on pairing keeps the window open`、`packages/shared/src/ipc-contract.test.ts@regression: always-on pairing survives parsing` | [x] | | 从结束的配对直接开始新配对(2026-09-28):电脑的「再配一台」「重新开始」和 Ctrl R 在已信任、被拒绝、失败的画面上直接发 `pairing_start`,手机也可以在失败的画面上直接输新码;核心先清掉结束的会话,不再报「pairing already in progress; reset first」(预览后端一直接受,界面测试没发现) | `crates/voltip-core/src/runtime.rs`(`clear_finished_pairing`) | `crates/voltip-core/tests/e2e.rs@regression_a_new_pairing_starts_straight_from_a_finished_one` | [x] | | 解除配对是双向的(2026-09-27):一边忘记对方时,对方在线就先经加密通道收到 `unpair`,同样忘记这台设备(不回发),手机和电脑都提示「「名字」解除了与这台设备的配对」;设备页「经这条通道传输」列出这一项,忘记确认框说明对方会同时忘记;离线的一方保留记录直到下一次连接失败 | `crates/voltip-protocol/src/app.rs`(`AppMessage::Unpair`)、`crates/voltip-core/src/runtime.rs`(`forget` / `on_unpaired`)、`crates/voltip-core/src/ui.rs`(`UiEvent::Unpaired`)、`packages/shared/src/schema.ts`、`apps/{desktop,mobile}/src/App.tsx`、`apps/desktop/src/pages/Devices.tsx`、`docs/pairing.md` | `crates/voltip-core/tests/e2e.rs@pair_by_code_message_presence_reconnect_forget`、`crates/voltip-protocol/src/app.rs@messages_roundtrip`、`apps/desktop/src/shell/Shell.test.tsx@regression: a phone that unpaired this computer is announced`、`apps/mobile/src/App.test.tsx@regression: a computer that unpaired this phone is announced`、`apps/desktop/src/pages/Devices.test.tsx@regression: the phone microphone panel follows a phone` | [x] | diff --git a/docs/architecture.md b/docs/architecture.md index de904bca..af499dab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,6 +19,7 @@ crates/voltip-relay 可选中继服务(axum WS):会话/短码索引 crates/voltip-core 应用外观层:把 identity + pairing + transport + trusted store 组合成 CoreCommand → CoreEvent;Hotkey 解析/规范化与 Settings.hotkey crates/voltip-tauri-bridge UiCommand / UiEvent / UiState:webview 看到的唯一契约;Bridge::publish 让 shell 自己产生的状态(热键注册结果)并入同一状态流 crates/voltip-audio 麦克风枚举与输入电平(cpal:WASAPI / CoreAudio / ALSA / Android AAudio),纯 DSP 可脱离硬件测试 +crates/voltip-reply 服务拒绝请求时说了什么(docs/dictation.md §3.12):各服务商错误体的代码与消息,合并空白、遮住密钥与账号后显示在悬浮窗、历史和测试连接里 crates/voltip-net 网络代理(docs/dictation.md §24):跟随系统 / 不使用 / 手动的代理计划,reqwest 客户端每个请求都按它走,WebSocket 连接经它打开 TCP 流(HTTP CONNECT、SOCKS5) crates/voltip-cloud 云端识别与润色(voltip-asr、voltip-refine 的客户端;阿里云百炼的实时模型经 WebSocket 推流)作为核心的 Transcriber / Refiner / StreamingTranscriber 端口,桌面与手机共用 crates/voltip-serve 本机语音服务的 HTTP 层(axum):OpenAI 兼容的 /v1/audio/transcriptions、令牌、准入、边收边解码的 WAV 上传;处理在 voltip-core 的 serve 模块(docs/dictation.md §23) @@ -26,7 +27,7 @@ apps/desktop/src-tauri 桌面 shell:全局热键(tauri-plugin-global-short apps/server 无头服务端 voltip-server(Linux x64 tar.gz):命令行、只读地读 App 的数据目录与系统钥匙串,没有 GUI 和音频依赖 ``` -依赖方向只允许向下:`apps → core → {pairing, transport, identity} → {crypto, protocol}`;`voltip-net` 在最底层,`transport`、`asr`、`refine`、`asr-local`、`cloud` 和 `core` 都依赖它,它不依赖任何 Voltip crate;`relay` 只依赖 `protocol`,不依赖 `crypto`(它拿不到、也不需要会话密钥)。`voltip-serve` 是 `voltip-core` 之上的适配层(实现核心的 `ServeHost` 端口,调用 `SpeechService`),只有桌面壳和 `apps/server` 依赖它。 +依赖方向只允许向下:`apps → core → {pairing, transport, identity} → {crypto, protocol}`;`voltip-net` 在最底层,`transport`、`asr`、`refine`、`asr-local`、`cloud` 和 `core` 都依赖它,它不依赖任何 Voltip crate;`voltip-reply` 同样在最底层,只有 `asr` 和 `refine` 依赖它;`relay` 只依赖 `protocol`,不依赖 `crypto`(它拿不到、也不需要会话密钥)。`voltip-serve` 是 `voltip-core` 之上的适配层(实现核心的 `ServeHost` 端口,调用 `SpeechService`),只有桌面壳和 `apps/server` 依赖它。 ## 1.1 原生优先(硬约束) diff --git a/docs/dictation.md b/docs/dictation.md index 92bba4a8..46d5544e 100644 --- a/docs/dictation.md +++ b/docs/dictation.md @@ -16,6 +16,7 @@ | `voltip-inject` | `inject(text) -> Injection`:备份剪贴板 → 写入文本 → 发 `Ctrl+V`(macOS `Cmd+V`)→ 600 ms 后恢复剪贴板;任何一步失败都把文本留在剪贴板并返回 `Via::Clipboard` + 原因;`Injector` trait + `FakeInjector` | arboard 3.6、enigo 0.6(x11rb / SendInput / CGEvent) | trait 假实现;真实实现只在有显示器时冒烟 | | `voltip-asr-local` | 本地引擎(§10):`catalogue`(6 条目录,含隐藏的 `silero-vad`)、`store`(下载 / 校验 / 安装,§12 辅助条目随首个模型下载)、`transcriber`(`LocalTranscriber`,按条目引擎分派:`gguf.rs` transcribe.cpp、`sherpa.rs` sherpa-onnx;`vad_trim` 时先经 `vad.rs` 裁剪)、`streaming`(`LocalStreamingTranscriber`,§11 实时预览)、`vad`(`VadTrimmer`,§12 Silero VAD 首尾裁剪)、`segmenter`(`VadSegmenterFactory`,§22 长录音在停顿处切段) | transcribe-cpp 0.2.3(静态,CPU)、sherpa-onnx 1.13.8(动态)、rubato | 假加载器 / 假 VAD 单测;`tests/real.rs` 四条 `#[ignore]` 真模型测试 | | `voltip-core` | `dictation` 模块:状态机 + 编排(含 §11 的解码线程 `run_live` 与 `Listening.live` / `Processing.preview`,§22 长录音 `long.rs`);`history` 模块:`history.sqlite3`(§4.3,上限 2 万条)、用 AI 预设处理与导出(§22);`engines` 模块:默认值解析(`option_env!`)与 `EngineSettings`;`models` 模块:模型库端口;秘密经 `SecretStore` | 只依赖 trait(`AudioSource` / `Capture` / `LivePcm` / `Transcriber` / `StreamingTranscriber` / `Refiner` / `Injector` / `ModelManager`),不依赖 cpal / rtrb / reqwest / enigo / sherpa | 假实现驱动完整状态机(`fakes.rs`:`FakeAudio` 带假 tap、`FakeStreaming` 脚本会话、`FakeModels`) | +| `voltip-reply` | 服务拒绝请求时说了什么(§3.12):`error_fields`(各服务商错误体的 `code` 与 `message`)、`summary`(要显示的话:消息与代码、HTML 页的标题或正文)、`clean`(合并空白、遮住密钥令牌与账号、截到 200 字) | regex、serde_json | 各服务商的错误体样本、遮盖与截断 | | `voltip-net` | 网络代理(§24):`Network`(进程内的代理计划:跟随系统 / 不使用 / 手动)、`apply`(给 reqwest 客户端加一个每个请求都问计划的代理)、`connect`(为 WebSocket 打开直连、HTTP CONNECT 或 SOCKS5 的 TCP 流) | reqwest、hyper-util(`client-proxy-system`:系统代理读取,CONNECT 与 SOCKS5 连接器) | 记录请求的假 HTTP / SOCKS5 代理 | | `voltip-serve` | 本机语音服务的 HTTP 层(§23):`ServeHandle`(路由、令牌、准入、边收边解码的 WAV 上传、OpenAI 的返回与错误格式)与 `HttpHost`(核心 `ServeHost` 端口的实现,桌面 App 用);处理本身在 `voltip_core::serve` | axum 0.8(`multipart`)、hound、rubato、subtle | `tower::ServiceExt::oneshot` 加假 `SpeechService`;真实 socket | | `apps/server` | 无头服务端 `voltip-server`(§23.5):命令行、读 App 的数据目录、只读钥匙串、信号与退出 | voltip-core、voltip-cloud、voltip-asr-local、voltip-serve;不依赖 voltip-audio、voltip-inject 和 Tauri | 假工厂下的各命令;真实二进制收到 SIGTERM 后的退出 | @@ -117,10 +118,11 @@ pub struct DictationStatus { pub phase: DictationPhase, pub session: u64 /* 递 | `openai`、`groq`、`siliconflow`、`aliyun` | ✓ | ✓ | 必填,同一家的两项服务共用一把 | | `google`(Google AI Studio,2026-10-08) | — | ✓(Gemini,OpenAI 兼容接口 `…/v1beta/openai`) | 必填 | | `deepseek` | — | ✓ | 必填 | +| `kimi`、`glm`(智谱 / Z.ai,两家都可切换中国站与国际站,§3.2)、`volcengine`(火山方舟)、`hunyuan`(腾讯混元,经腾讯云 TokenHub)、`mimo`(小米 MiMo)、`openrouter`(2026-10-25,§3.13) | — | ✓ | 必填 | | `ollama` | — | ✓(`http://127.0.0.1:11434/v1`) | 无 | | `custom` | ✓ | ✓ | 可选,识别与润色各一把;接口地址必填 | -服务商卡片和设置抽屉上,各服务商显示自己的标志(2026-10-12 用户要求「获取对应服务商的实际图标」):取自 LobeHub Icons(MIT,`packages/shared/src/provider-marks.ts`,固定在该图标集的提交 `bcdb7c8`,许可说明随每个安装包的 `THIRD-PARTY-NOTICES.txt` 附带),打包在应用里,不联网获取:页面离线也能显示,也不向第三方发任何请求。黑白标志(OpenAI、Groq、Google AI Studio、Ollama)随主题取文字颜色,彩色标志保留原色;内置服务、本机和自定义接口仍用应用自己的图标。 +服务商卡片和设置抽屉上,各服务商显示自己的标志(2026-10-12 用户要求「获取对应服务商的实际图标」):取自 LobeHub Icons(MIT,`packages/shared/src/provider-marks.ts`,固定在该图标集的提交 `bcdb7c8`,许可说明随每个安装包的 `THIRD-PARTY-NOTICES.txt` 附带),打包在应用里,不联网获取:页面离线也能显示,也不向第三方发任何请求。黑白标志(OpenAI、Groq、Google AI Studio、Kimi、小米 MiMo、OpenRouter、Ollama)随主题取文字颜色,彩色标志保留原色;Kimi 的彩色标志是白色字母,在浅色背景上看不见,所以用黑白的。内置服务、本机和自定义接口仍用应用自己的图标。 ```rust pub struct EngineSettings { // Settings.engines,#[serde(default)] @@ -138,7 +140,7 @@ pub struct EngineSettings { // Settings.engines } ``` -没有填写的模型与地址取目录里的预设。`ResolvedEngines::resolve` 把设置、用户密钥和内置服务合成出客户端真正用的配置;识别或润色跑不了时给出 `EngineIssue`(`unavailable` / `key_missing` / `url_missing` / `model_missing` / `model_not_installed` / `no_provider`),界面按它提示,首页按钮在识别就绪前禁用。 +没有填写的模型与地址取目录里的预设。每家服务商的每项服务只带两三个推荐模型,第一个是默认(2026-10-25 用户决定:「所有的provider不需要带内置列表 而是填写了key测试通过后拉取model」「每家留2-3个模型吧」);其余模型在保存密钥后从服务商自己的模型列表获取(§3.3),推荐模型保证填好密钥就能用,也让没有选过模型的老用户照常使用。`ResolvedEngines::resolve` 把设置、用户密钥和内置服务合成出客户端真正用的配置;识别或润色跑不了时给出 `EngineIssue`(`unavailable` / `key_missing` / `url_missing` / `model_missing` / `model_not_installed` / `no_provider`),界面按它提示,首页按钮在识别就绪前禁用。 **内置服务**(`voltip_core::engines::BuiltIn`)全部来自编译期 `option_env!`,源码与文档不出现它的主机名(`.github/scripts/check-no-production-hosts.sh` 扫描整棵树;真实值只在 CI secrets 与本机 git 忽略的 `.env.build`): @@ -149,15 +151,24 @@ pub struct EngineSettings { // Settings.engines **内置服务的多个模型**(2026-10-08 用户要求:内置润色提供 Qwen 与 GPT-OSS 等多个模型):`VOLTIP_REFINE_MODEL`(识别的 `VOLTIP_ASR_MODEL` 同理)可以是逗号分隔的列表,`BuiltIn::models` 按顺序给出,第一个是默认。内置服务卡片在列表多于一个时显示模型下拉框,选中的存为 `providers.builtin.llm_model`;选回第一个时不保存,以后的构建换了默认也跟着走。设置里的模型不在本构建的列表中时用默认模型。候补模型(§3.5)可以是内置服务的另一个模型;内置服务的候补项模型为空时指它卡片上选的那个。内置网关把请求原样转给 Groq,列表里的模型由网关所在账户的免费层提供(2026-10-08 经首尔网关实测 `qwen/qwen3.8-27b`、`openai/gpt-oss-120b`、`openai/gpt-oss-20b` 都回 200)。 -**Google AI Studio**(2026-10-08 用户决定:作为独立服务商,用户填自己的密钥,从列表里选模型而不是手填):只做润色,基址 `https://generativelanguage.googleapis.com/v1beta/openai`,预设为有免费额度的 `gemini-3.8-flash`、`gemini-3.5-flash-lite`、`gemini-3.1-flash-lite`、`gemini-2.5-flash`、`gemini-2.5-pro`(ai.google.dev 的定价页,2026-10-08)。这个基址没有 `/v1`,`voltip_refine::normalize_base_url` 对这个主机不追加。它的 `GET /models` 把模型写成 `models/gemini-…`,还列出嵌入、图像、语音、视频模型:服务商列出的模型(§3.3)只留 Gemini 与 Gemma 的文本模型,并去掉前缀,下拉框里就是可选的模型。卡片说明写明免费层的内容可能被 Google 用于改进产品。 +**Google AI Studio**(2026-10-08 用户决定:作为独立服务商,用户填自己的密钥,从列表里选模型而不是手填):只做润色,基址 `https://generativelanguage.googleapis.com/v1beta/openai`,预设为有免费额度的 `gemini-3.5-flash-lite`、`gemini-3.8-flash`、`gemini-3.1-flash-lite`(ai.google.dev 的定价页,2026-10-08)。2026-10-24 复查后改为 Flash-Lite 在前,并去掉 2.5:`gemini-3.8-flash` 连续 40 分钟对润色回 503「This model is currently experiencing high demand」,`gemini-3.1-flash-lite` 也回 503,`gemini-3.5-flash-lite` 1–6 秒回答;`gemini-2.5-flash`、`gemini-2.5-pro` 回 404「no longer available to new users」(§3.6)。这个基址没有 `/v1`,`voltip_refine::normalize_base_url` 对这个主机不追加。它的 `GET /models` 把模型写成 `models/gemini-…`,还列出嵌入、图像、语音、视频模型:服务商列出的模型(§3.3)只留 Gemini 与 Gemma 的文本模型,并去掉前缀,下拉框里就是可选的模型。卡片说明写明免费层的内容可能被 Google 用于改进产品。 两个令牌都是服务端可随时吊销的应用令牌,不是任何服务商的 API 密钥:服务商密钥只放在服务端,打包脚本扫描二进制,发现 `gsk_…` / `sk-…` 形式的密钥就拒绝出包。 **密钥**:`provider_key_set { provider, kind, value }`(`value: null` 删除)写进系统钥匙串(条目 `provider-key.`,自定义接口为 `provider-key.custom-asr` / `-llm`)。界面只看到 `SecretState`(有没有、来自哪里),永远拿不到值。一把密钥只发给它所属的服务商:换了服务商或改了自定义地址,旧密钥不会跟过去,内置令牌也只发给内置服务(回归测试 `regression_keys_never_leave_their_provider`)。 +### 3.2 中国站与国际站(2026-10-25) + +Kimi(`api.moonshot.cn` / `api.moonshot.ai`)和智谱 GLM(智谱开放平台 `open.bigmodel.cn` / Z.ai `api.z.ai`)各有一个中国站和一个国际站,两站的账号和密钥不通用。用户决定一家服务商一张卡片,在卡片里切换站点(2026-10-25): + +- 目录里每个站点有自己的接口地址和密钥页(`ServicePreset::sites`、`ProviderSite`),第一个是默认站点,地址与服务的默认地址相同;只有一个地址的服务商没有站点。`ServiceStatus.sites` 把站点和地址下发给界面。 +- 设置抽屉的「连接」里有「站点」切换:选默认站点时清空接口地址(之后目录里的地址变了也跟着走),选另一个站点时把它的地址填进接口地址。接口地址为空时选中默认站点,填的是某个站点的地址时选中那个站点,其他地址(代理、套餐的专属地址)两个都不选(`siteOf`、`siteUrl`)。没有新的设置项:保存的仍是 `llm_url`。 +- 「获取密钥」打开所选站点的密钥页:`provider_console_open { provider, page?, site? }`,外壳只打开目录里的地址(`ProviderId::console_page(page, site)`);站点不属于这家服务商时打开它自己的密钥页。 +- 一家服务商仍只存一把密钥(`provider-key.kimi`、`provider-key.glm`);切换站点后要换成那个站点的密钥,切换下方的说明写明这一点。 + ### 3.3 测试连接与模型列表(2026-10-12) -**测试连接**:`provider_probe { provider, kind, base_url?, key? }` 请求 `GET {base}/models`,结果以 `provider_probe` 事件返回模型列表或失败原因(`ProbeFailure`,不含主机名,内置服务的地址也不会借错误信息泄露)。列表按服务筛选(见下),内置服务只计本构建提供的模型。`provider_console_open { provider, page? }` 在浏览器里打开该服务商的密钥页(`page` 省略或为 `key`)或额度页(`quota`,目前只有阿里云百炼,§3.5);外壳只打开目录里的地址(`ProviderId::console_page`),界面只给出服务商和页面。 +**测试连接**:`provider_probe { provider, kind, base_url?, key? }` 请求 `GET {base}/models`,结果以 `provider_probe` 事件返回模型列表或失败原因(`ProbeFailure`,不含主机名,内置服务的地址也不会借错误信息泄露)。列表按服务筛选(见下),内置服务只计本构建提供的模型。`provider_console_open { provider, page?, site? }` 在浏览器里打开该服务商的密钥页(`page` 省略或为 `key`;有两个站点的服务商打开 `site` 的,§3.2)或额度页(`quota`,目前只有阿里云百炼,§3.5);外壳只打开目录里的地址(`ProviderId::console_page`),界面只给出服务商、页面和站点。 **模型列表**(2026-10-12 用户反馈:模型下拉框只有内置的几个预设,测试连接却显示很多模型;保存密钥后,每次打开模型列表都应带出服务商最新的模型,不必手填模型 ID): @@ -207,8 +218,8 @@ pub struct EngineSettings { // Settings.engines 用户需求(2026-10-05):内置润色服务限流时,界面要有提示,并引导用户改用自己的服务商(例如免费的 Groq 密钥)。此前润色失败只是少了「· 已润色」,原因只以英文字符串存在 `refine_error` 里,界面不显示。内置润色服务用 Groq 免费层,限额按组织计算(`qwen/qwen3.8-27b` 每天 20 万 token),所有用户共用。 -- **原因分类**:`voltip_core::RefineFailure { rate_limited, quota, unconfigured, too_long, empty, failed }`,是 `refine_error` 的类别,`refine_error` 仍保留详细说明。`DictationError::RateLimited { service, detail }` 表示服务暂时拒绝(HTTP 429),由 `voltip_cloud::refine_error` 从 `RefineError::RateLimited` 转换而来(识别的 429 不变,仍为 `Asr`)。它不是额度用完,候补模型列表不会因它切换(§3.5)。失败码对应 `asr` / `refine`。`steps::run_refine` 按错误给出类别,`RefinePlan::Skip` 携带 `unconfigured` / `too_long`,空结果为 `empty`。 -- **记录**:`DictationPhase::Done.refine_failure`、`HistoryEntry.refine_failure`、本机服务的 `ServeOutcome.refine_failure`(`verbose_json` 的 `voltip` 对象),均可省略;旧记录没有这一项。 +- **原因分类**:`voltip_core::RefineFailure { rate_limited, quota, unconfigured, too_long, empty, failed }`,是 `refine_error` 的类别,`refine_error` 仍保留详细说明。`DictationError::RateLimited { service, detail }` 表示服务暂时拒绝(HTTP 429),由 `voltip_cloud::refine_error` 从 `RefineError::RateLimited` 转换而来(识别的 429 不变,仍为 `Asr`)。`DictationError::Overloaded`(「服务繁忙:…」)表示服务暂时处理不过来(HTTP 503,`RefineError::Overloaded`),类别同为 `rate_limited`。两者都不是额度用完,候补模型列表不会因它们切换(§3.5)。失败码对应 `asr` / `refine`。`steps::run_refine` 按错误给出类别,`RefinePlan::Skip` 携带 `unconfigured` / `too_long`,空结果为 `empty`。 +- **记录**:`DictationPhase::Done.refine_failure`、`HistoryEntry.refine_failure`、本机服务的 `ServeOutcome.refine_failure`(`verbose_json` 的 `voltip` 对象),均可省略;旧记录没有这一项。请求失败时(`rate_limited`、`quota`、`failed`),`HistoryEntry.refine_error` 保存客户端报告的原因;没有发出请求(`unconfigured`、`too_long`)和空结果只有类别,不保存。 - **提示**(`runtime/notice.rs`,`UiState.refine_notice: Option`,事件 `refine_notice { notice }`): - 出现:一次听写结束为 `Done`,`refine_failure` 为 `rate_limited` 或 `quota`,且润色服务商是内置服务。同一类别再次出现时不重复发事件。 - 消失:之后有一次听写润色成功;润色服务商改为内置服务以外的服务商;用户关闭(命令 `refine_notice_close`,两个壳都注册)。关闭后 `REFINE_NOTICE_SNOOZE_MS = 24 h` 内不再出现,这一记录只在内存里,重启后可能再次出现。 @@ -218,6 +229,7 @@ pub struct EngineSettings { // Settings.engines - `dictationPhaseLabel`(首次设置的试听写结果行)在要求润色但未生效时加上「· 未润色」。 - 悬浮窗:插入后的胶囊在标签后注明「未润色 · 服务繁忙」等原因(`Pill` 的 `note`,带黄色指示灯,文字用胶囊自己的颜色),与胶囊一起在 2.5 秒后消失(2026-10-05 用户要求)。 - 历史详情的「润色模型」显示「未润色 · 服务繁忙」等原因(`refineModelText`),桌面与手机相同。 + - 桌面历史详情:耗时一行写「润色 — · 服务繁忙」等原因,只有没有要求润色时才写「未启用」;有 `refine_error` 的记录在下方显示一句说明和「原因:…」(`RefineNote`,含服务返回的话,§3.12);手机的记录详情多一行「润色失败原因」。 - **Groq 预设模型**:`llama-3.3-70b-versatile` 已只对企业账号开放(console.groq.com/docs/models,2026-10-05),列表改为 `qwen/qwen3.8-27b`、`openai/gpt-oss-120b`、`openai/gpt-oss-20b`。 ### 3.7 Responses 接口(2026-10-05) @@ -274,6 +286,68 @@ pub struct EngineSettings { // Settings.engines - 润色的回答在 `max_tokens` 处被截断(`finish_reason: length`)时按失败处理(`RefineError::Truncated`),插入识别原文,与语音编辑相同;此前会插入截断后的半截文字。 - **实测**(2026-10-12,用本分支构建的 `voltip-server` 经首尔网关):内置服务的 `openai/gpt-oss-120b`、`openai/gpt-oss-20b`、`qwen/qwen3.8-27b` 对 10 s 与 37.7 s 的样音都润色成功;`openai/gpt-oss-120b` 用 8 个内置预设各润色一次,全部成功。 - **测试**:`voltip-refine` 的 `regression_a_model_that_reasons_first_gets_low_effort_and_room_to_reason`、`regression_a_clean_up_cut_off_at_the_limit_is_refused`(去掉修复时两者都失败)。 +- 2026-10-24 起 Gemini 也这样处理(§3.11),2026-10-25 起按模型系列决定请求带什么(§3.13)。 + +### 3.11 Google AI Studio 的润色失败(2026-10-24) + +用户报告(2026-10-24):选用 Google AI Studio,测试连接显示 20 个可用模型,但润色一直失败,悬浮窗显示「请求失败」,历史的耗时一行写「润色 — · 未启用」,也没有日志可以查看;怀疑没有走系统代理。 + +- **排查结论**(在用户的 Windows 上,用保存的密钥按客户端的请求原样调用): + - 系统代理生效:`GET /models` 返回 200,与测试连接一致。 + - `gemini-3.8-flash` 连续 40 分钟回 503 `UNAVAILABLE`「This model is currently experiencing high demand」,或 40 秒无响应;`gemini-3.1-flash-lite` 也回 503,`gemini-3.5-flash-lite` 1–6 秒回答。Google 的错误体是数组 `[{"error": {...}}]`,`code` 是数字。 + - `gemini-3.8-flash` 有余量时也失败:不带 `reasoning_effort` 时短句的 152 个 token 中 146 个用于思考,`finish_reason: length`,回答截断为「请问现在」(`RefineError::Truncated`)。`reasoning_effort: "low"` 时不思考或很少思考(`gemini-3.1-flash-lite` 用了 110 个);`minimal` 被 `gemini-3.8-flash` 拒绝,`none` 被所有 3.x 模型拒绝。 + - 失败原因只到达 `Done.refine_error`,历史只保存类别;桌面不写日志文件,原因无处可查。 +- **处理**: + - `voltip-refine`:`error_fields` 读数组形式的错误体;503 为 `RefineError::Overloaded`;其他拒绝的 `Server.body` 用服务的 `error.message`,没有时才用原始响应体。 + - 模型名去掉前缀后以 `gemini-` 开头时,请求带 `reasoning_effort: "low"`,`max_tokens` 再加 1 024(Gemini 2.5 的 low 是 1 024 个 token 的思考预算),仍不超过上限。Gemma 不变。 + - `voltip-core`:`DictationError::Overloaded` 归为 `rate_limited`(§3.6);`HistoryEntry.refine_error` 保存请求失败的原因;桌面历史详情显示原因(§3.6 界面)。所有服务商的拒绝都带上服务返回的话见 §3.12。 + - Google 的预设模型改为 Flash-Lite 在前,去掉 2.5(§3)。 +- **测试**:`voltip-refine` 的 `regression_an_overloaded_model_is_told_apart_with_the_services_message`、`regression_gemini_gets_low_effort_and_room_to_think`,`voltip-core` 的 `regression_the_history_keeps_why_the_clean_up_failed`,桌面 `History.test.tsx` 的 "a clean-up the service turned down says so in the timings"(去掉修复时都失败)。 + +### 3.12 服务返回的错误(2026-10-24) + +用户要求(2026-10-24):「所有的模型和 provider 都应返回上游的错误日志精简吧,否则用户不知道什么问题」。此前只有额度用完(`QuotaExhausted`)和百炼的服务错误(`Service`)带服务的原话;401/403 只说「密钥被拒绝」,429 只说「请求过于频繁」,其他状态码带截断的原始响应体(JSON 原样),测试连接只有状态码,识别失败的标签「识别服务出错」不带原因。 + +- **`crates/voltip-reply`**(识别和润色客户端共用,不依赖其他 Voltip crate): + - `error_fields(body)`:各服务商错误体的 `code` 与 `message`:OpenAI 的 `{"error": {"code", "message"}}`(Groq、DeepSeek、vLLM、自建网关)、百炼与 SiliconFlow 的 `{"code", "message"}`、Google AI Studio 的 `[{"error": {"code", "message", "status"}}]`、Ollama 的 `{"error": "…"}`、FastAPI 的 `{"detail": …}`。代码依次取字符串 `code`、Google 的 `status`、OpenAI 的 `type`、数字 `code`。 + - `summary(body)`:要显示的话。JSON 取消息,消息里没有代码时在后面加「(代码)」(三位以内的数字代码与状态码重复,不加);没有消息的 JSON 原样(空对象、空数组不显示);HTML 取 ``,没有时去掉标签;其他文本原样。 + - `clean(text)`:合并空白;遮住 `Bearer` 令牌、`sk-` / `gsk_` / `xai-` / `AIza` 开头的密钥(含 OpenAI 回显的半遮盖密钥)、`org_` / `org-` 账号 ID(Groq 的限流消息写出组织 ID,内置服务的就是项目自己的账号)、URL 查询里的 `key=` / `token=` 等、32 位以上字母数字混合的串;截到 200 字(`MAX_SUMMARY_CHARS`,含省略号)。 +- **客户端**:`AsrError::Unauthorized { message }`、`RateLimited { retry_after_ms, message }`、`Server { status, body }`,`RefineError` 同样,另有 `Overloaded { message }`(§3.11);`body` / `message` 都是 `summary` 的结果,`QuotaExhausted` 与 `Service` 的消息经过 `clean`。OpenAI 兼容接口、百炼的 HTTP 协议、实时识别的握手与 `task-failed`、润色与模型列表(测试连接)都这样处理。错误的显示文字是「ASR rejected the credentials: Incorrect API key provided (invalid_api_key)」这样的英文前缀加服务的原话,核心的 `DictationError` 再加前缀(「asr: 」「服务繁忙:」等)。 +- **测试连接**:`ProbeError.detail` / `ProbeOutcome::Failed.detail`(可省略):服务返回的话,或连接错误(主机已在 `map_reqwest` 去掉)。界面写「密钥被拒绝:Incorrect API key provided (invalid_api_key)」(`engines.probeResult.withDetail`)。 +- **界面**:识别、润色、改写失败的标签带原因(`dictation.failureCode.asr`、`refine`、`dictation.edit.refineFailed` 的 `{reason}`):悬浮窗、首页的结果行和历史记录都显示。悬浮窗里较长的原因截断显示(最宽 380 px),完整内容在提示里;润色失败的记录详情显示「原因:…」(§3.6)。原因里客户端自己的英文说法(`ASR rejected the credentials`、`refine service overloaded (HTTP 503)` 等,即两个 `error.rs` 的 `Display`)由 `serviceReasonText`(`packages/shared/src/labels.ts`)换成界面语言(「密钥被拒绝」「服务繁忙」),核心加在前面的「请求过于频繁:」「服务繁忙:」「额度已用完:」一并去掉,服务的原话照原样跟在后面:「识别服务出错:密钥被拒绝:Incorrect API key provided (invalid_api_key)」。其他形式的原因只去掉机器前缀、原样显示。改 `Display` 时同步改这张表(2026-10-25:同步文档站时发现界面把「ASR rejected the credentials」夹在中文里)。 +- **测试**:`voltip-reply` 的单元测试(各服务商的错误体、HTML、遮盖 `regression_keys_tokens_and_accounts_are_masked`、截断),`voltip-asr` / `voltip-refine` 的错误映射测试改为断言服务的原话,`voltip-core` 的 `probe_reports_are_flat_and_tagged`,`engine-drafts.test.ts` 的 "a failed probe says what the service said",`labels.test.ts`,`Pill.test.tsx` 的 "a failure with the service's words is cut in the pill"。 + +### 3.13 国内服务商与 OpenRouter(2026-10-25) + +用户要求(2026-10-24):「润色模型中 需要加入kimi GLM(Z.AI)等系列的支持 也可以调研下国内还有哪些热门provider一并支持」。用户选定 Kimi、智谱 GLM、火山方舟(豆包)、腾讯混元、小米 MiMo 和 OpenRouter;MiniMax、阶跃星辰、百度千帆、魔搭未选。六家都只用于润色,都走 OpenAI 兼容的 chat completions,都有 `GET /models`。 + +| 服务商 | 地址 | 推荐模型 | 说明 | +|---|---|---|---| +| Kimi | 中国站 `https://api.moonshot.cn/v1`,国际站 `https://api.moonshot.ai/v1`(§3.2) | `kimi-k3.1-pro-highspeed`、`kimi-k3.1-pro`、`kimi-k3` | K2 系列 2026-10-31 起不再对新用户开放,年底下线 | +| 智谱 GLM | 中国站 `https://open.bigmodel.cn/api/paas/v4`,国际站 Z.ai `https://api.z.ai/api/paas/v4`(§3.2) | `glm-5.3-flash`、`glm-4.7-flash`(两站都免费)、`glm-5.3` | 文档没写 `GET /models`,Cherry Studio 等客户端在用 | +| 火山方舟 | `https://ark.cn-beijing.volces.com/api/v3` | `doubao-seed-2-1-lite-260915`、`doubao-seed-2-1-turbo-260628`、`doubao-seed-2-0-mini-260428` | 模型 ID 带发布日期;模型要先在方舟控制台的「开通管理」中开通 | +| 腾讯混元 | `https://tokenhub.tencentmaas.com/v1`(腾讯云 TokenHub,广州) | `hy3`、`hy4-preview` | 旧的混元平台在 2026-06-22 停用了 46 个旧模型,新模型在 TokenHub,它也提供其他厂商的模型;未开通的模型回 402;新加坡站 `https://tokenhub-intl.tencentmaas.com/v1` 的密钥与广州站不通用,可以填进接口地址 | +| 小米 MiMo | `https://api.xiaomimimo.com/v1` | `mimo-v2.6-flash`、`mimo-v2.6-pro` | 2.5 系列 2026-10-21 下线;Token Plan 的专属地址可以填进接口地址 | +| OpenRouter | `https://openrouter.ai/api/v1` | `google/gemini-3.5-flash-lite`、`deepseek/deepseek-v4-flash`、`openai/gpt-6-luna` | 一把密钥使用多家厂商的模型 | + +以上于 2026-10-25 按 platform.kimi.com、docs.z.ai、docs.bigmodel.cn、docs.volcengine.com、cloud.tencent.com/document/product/1823、mimo.mi.com/docs 和 openrouter.ai/api/v1/models 核对。 + +**让模型直接回答**(`voltip_refine` 的 `chat_controls`):这些模型大多先推理再回答,推理计入 `max_tokens`(§3.10)。请求带什么按模型名(去掉 `moonshotai/` 等前缀)决定,在哪里调用都一样,例如 TokenHub 上的 `kimi-k3` 和 OpenRouter 上的 `moonshotai/kimi-k3`: + +- 不能关闭推理、接受推理强度的模型(gpt-oss、Gemini、Kimi K3 与 K3.1、GLM-5.3 系列):`reasoning_effort: "low"`,`max_tokens` 再加推理的余量(gpt-oss 256,其余 1 024),不超过上限。 +- 能关闭推理的模型(GLM-4.5 至 GLM-5.2、Kimi K2.6、小米 MiMo、腾讯 Hy3 与 Hy4、豆包 Seed 系列):`thinking: {"type": "disabled"}`。 +- Kimi K2.7 Code 总要推理,也不接受推理强度:只加余量。 +- Kimi 的模型都固定 temperature(K3 为 1.0),传其他值回 400,经 OpenRouter 调用也一样:请求不带 `temperature`。其他模型仍为 0.2。 +- 经 OpenRouter 时用它自己的 `reasoning: {"effort": …}`,不带厂商的字段:不能关闭推理的模型为 `low`,能关闭的为 `none`。 +- 其他模型的请求不变;阿里云百炼的地址仍带 `enable_thinking: false`(§3.4)。 + +**模型列表**(§3.3):火山方舟、TokenHub 和智谱还列出图像、视频和图片文字识别模型,润色另外去掉 ID 含 seedream、seedance、vidu、cogview、cogvideo、ocr 的模型。 + +**错误里的密钥**(§3.12):智谱的密钥形如 `<32 位十六进制>.<16 位字母数字>`,小米 MiMo 套餐的密钥以 `tp-`、`ttp-` 开头,都整段遮住。 + +**未在真实账户上验证**:没有这几家的密钥。只用无效密钥核对了地址与 `GET /models`:Kimi、TokenHub、小米 MiMo 的 `/models` 回 401,OpenRouter 的不要密钥、回 200,无效路径都回 404;智谱和火山方舟先鉴权,`/models` 与无效路径都回 401。请求参数按各家文档。 + +**测试**:`voltip-core` 的 `a_vendors_sites_start_with_its_default_and_have_their_own_key_pages`、`each_service_offers_the_listed_models_it_can_use`;`voltip-refine` 的 `each_family_is_asked_to_answer_at_once_its_own_way`、`regression_kimi_gets_no_temperature_and_glm_no_thinking`;`voltip-reply` 的 `regression_keys_tokens_and_accounts_are_masked`;`packages/ui` 的 `switches Kimi between its China and international sites, each with its own key page`;手机的 `Kimi's sheet switches between its China and international sites`。 ## 4. 历史记录(`voltip_core::history`) @@ -459,8 +533,10 @@ pub struct HistoryEntry { - **状态**(`UiEvent::Update` / `UiState.update`,`#[serde(tag = "state")]`):`idle` → `checking` → `up_to_date { version, checked_at }` | `available { version, current, notes?, date? }` → `downloading { version, received, total? }` → `ready { version }` → `installing { version }`;任一步失败 → `failed { message }`;未配置 → `disabled`。由 shell 产生,经 `Bridge::publish` 折叠进状态(同热键状态)。 - **手动路径**:`update_check` 只问清单;`update_install`(「立即重启更新」)下载(带进度)→ 验签 → `ready` → `installing` → 安装:Windows 由 NSIS 安装器在后台接管、不显示窗口,装完自行重启应用(见下一条);macOS / Linux 原地替换后 shell 请求 Tauri 重启进程。没有待安装包时 `update_install` 先检查一次。 - **Windows 静默安装**(用户 2026-10-17:应用内升级会弹出 Windows 的安装框,参考 winer 的做法改为后台安装并重启):插件按 `plugins.updater.windows.installMode` 启动安装器(`/UPDATE /R /ARGS …`,`/R` 让安装器装完后重新打开应用)。`UpdaterConfig::plugin_config` 按二进制被打进的包设置它(`windows_install_mode(tauri::utils::platform::bundle_type())`):NSIS 包为 `quiet`(`/S`,没有任何窗口;按用户安装,不需要提权),其他包(MSI,或打包器没有标记的 exe)保持插件默认的 `passive`(`/P`,进度窗口,需要提权的包仍能弹出提权确认)。0.1.6 及更早的版本仍以 `passive` 启动新版本的安装器,所以新安装器自带钩子 `src-tauri/nsis/installer-hooks.nsh`(`tauri.windows.conf.json` 的 `nsis.installerHooks`):命令行同时有 `/P` 和 `/UPDATE` 时,在 MUI 初始化界面(`MUI_CUSTOMFUNCTION_GUIINIT`,第一帧之前)把窗口设为分层且完全透明(`WS_EX_LAYERED` + alpha 0)、不进任务栏(`WS_EX_TOOLWINDOW`)、不抢焦点(`WS_EX_NOACTIVATE`),再隐藏;复制文件前(`NSIS_HOOK_PREINSTALL`)再隐藏一次。用户自己运行的安装器不受影响。验证:`update.rs` 的 `regression_a_windows_update_runs_the_nsis_installer_without_a_window`(注入的配置经插件自己的 `Config` 解析为 `quiet`,参数 `/S`)、`tests/bundle.rs` 的 `windows_installer_hides_its_window_when_the_updater_runs_it`,以及 CI `windows-native` 在真实桌面上以两种参数各运行一次安装器(`scripts/smoke-windows-update.ps1`:每 5 ms 枚举安装器的顶层窗口,可见且不透明的一次也不允许;安装器退出码为 0,并重新打开已安装的 Voltip)。 -- **自动路径**(开关打开且构建带更新源):启动 10 s 后检查,有新版本则后台下载到 `ready`,**不**安装;设置页显示「立即重启更新」。到达 `ready` 的版本记在 app data 目录的 `update-ready.json`;用户若直接退出再启动,下次启动检查到**同一版本**时立刻下载安装并重启(这就是「重启应用即可更新」);比记录更新的版本仍先走 `ready`,从不悄悄安装用户没见过的包。运行中把开关打开会立即检查一次(只下载)。下载好的包不跨进程保留,所以第二次启动会重新下载。 -- **网络与失败原因**(用户报告 2026-09-29):清单请求与安装包下载都设了连接超时 `CONNECT_TIMEOUT`(15 s,含 TCP 与 TLS)和读取超时 `READ_TIMEOUT`(30 s,任一次读取停顿超过即失败);插件本身不设,原先要等操作系统放弃(Windows 约 20 s,其他系统更久),连接无响应时会一直停在「正在检查更新…」。`failed` 的 `message` 是完整的原因链(`update::describe`),例如 `error sending request for url (…): client error (Connect): tcp connect error: Connection refused (os error 111)`;原先只有第一段,看不出是 DNS、连接、TLS 还是超时。服务器返回非 2xx 时插件只报 `Could not fetch a valid release JSON from the remote`,不带状态码。代理:reqwest 未开 `system-proxy`,只认 `HTTP(S)_PROXY` 环境变量和 TUN,不读 Windows / macOS 的系统代理设置。 +- **自动路径**(开关打开且构建带更新源):启动 10 s 后检查,有新版本则后台下载到 `ready`,**不**安装;标题栏显示「重启以更新」,设置页显示「重启并更新」。到达 `ready` 的版本记在 app data 目录的 `update-ready.json`;用户若直接退出再启动,下次启动检查到**同一版本**时立刻下载安装并重启(这就是「重启应用即可更新」);比记录更新的版本仍先走 `ready`,从不悄悄安装用户没见过的包。运行中把开关打开会立即检查一次(只下载)。下载好的包不跨进程保留,所以第二次启动会重新下载。 +- **运行期间再检查**(用户 2026-10-25:开启自动更新后定时检测升级并自动下载,在标题栏提示,点击后重启完成安装;做法同 Lockra 0.16.3):常驻托盘几天的应用只在启动时检查,就要到下次启动才知道有新版本。开关打开时,每次运行结束(手动检查也算)都把下一次检查排在 `AUTO_CHECK_INTERVAL`(4 小时)之后(`update::AutoSchedule`,在 `UpdateSlot::finish` 里,先释放占用、记下最终状态,再排下一次,最后广播);已经排好、更早到期的检查(启动后那一次)保留。规则:只有启动后那一次检查可以安装 `update-ready.json` 记下的版本,到期时用 `std::mem::take` 取走这个许可,之后的检查只下载,所以几小时后的一次检查不会在用户工作时重启应用;结果是 `ready` 或 `installing` 时不再排下一次,等用户重启(比已下载版本更新的发布,由重启后的启动检查发现);失败后同样 4 小时后再试,不会连续重试;关掉开关时清掉已排的检查和安装许可,重新打开时立即检查一次、只下载;开关关闭时手动检查不排任何检查。一个任务(`follow_settings`)睡到下一次到期,设置变化和每次运行结束(`Notify`)都会唤醒它;到期时正在进行的运行(用户自己的)会让这次检查以 busy 被拒并丢掉,由那次运行结束时排下一次。时钟是单调时钟(`tokio::time::Instant`),电脑睡眠的时间不计入。标题栏的「重启以更新」一次点击就开始安装(打开对话框并调用 `update_install`,对话框直接显示「正在安装」),悬停提示写明版本「重启 Voltip,完成更新到 {version}」;设置页「自动更新」的说明写明两个时间。 +- **测试构建**:编译期变量 `VOLTIP_UPDATE_INTERVAL_SECS` 把运行期间的间隔改短(秒,至少 60),发布构建不设。清单地址是 `http://` 且所有 `http://` 地址都在本机或局域网(`localhost`、回环地址、私有或链路本地 IPv4,`UpdaterConfig::local_http`)时,注入的配置带 `dangerousInsecureTransportProtocol`:插件在发布构建里拒绝 http 地址,否则这样的测试构建连启动都会失败;公网主机仍必须是 https,安装包照旧验签。用于在真机上对着局域网里的清单服务器验收更新。 +- **网络与失败原因**(用户报告 2026-09-29):清单请求与安装包下载都设了连接超时 `CONNECT_TIMEOUT`(15 s,含 TCP 与 TLS)和读取超时 `READ_TIMEOUT`(30 s,任一次读取停顿超过即失败);插件本身不设,原先要等操作系统放弃(Windows 约 20 s,其他系统更久),连接无响应时会一直停在「正在检查更新…」。`failed` 的 `message` 是完整的原因链(`update::describe`),例如 `error sending request for url (…): client error (Connect): tcp connect error: Connection refused (os error 111)`;原先只有第一段,看不出是 DNS、连接、TLS 还是超时。服务器返回非 2xx 时插件只报 `Could not fetch a valid release JSON from the remote`,不带状态码。代理按 §24 的设置连接(`voltip_net::Network::apply`);0.1.3 及更早的版本只认 `HTTP(S)_PROXY` 环境变量和 TUN,不读 Windows / macOS 的系统代理设置。 - **发布侧**:产出 `.sig` 与 `latest.json` 需要 `bundle.createUpdaterArtifacts: true` 与 CI 上的 `TAURI_SIGNING_PRIVATE_KEY` / `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`;`latest.json` 是 Release 的资产,`VOLTIP_UPDATE_URL` 即 `https://github.com/<owner>/<repo>/releases/latest/download/latest.json`(`docs/runbook.md` 发布)。本机打包取 `.env.build` 的同名变量:2026-10-10 本机测试包 0.0.53 的 `.env.build` 还是早已没有文件的旧网关镜像地址,检查更新报 403(`Could not fetch a valid release JSON from the remote`)与跨境 TLS 断开,`.env.build.example` 因此写明与发布相同的地址。 diff --git a/docs/frontend.md b/docs/frontend.md index 39746631..a2918557 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -87,7 +87,7 @@ type Platform = "windows" | "macos" | "linux" | "android" | "ios" | "other"; type ConnectionState = "disconnected" | "connecting" | "authenticating" | "connected" | "reconnecting" | "closed"; type OutputMode = "whole_take" | "streaming_final" | "live_inject"; // docs/dictation.md §12 type Activation = "hold" | "toggle" | "hold_or_toggle"; // docs/dictation.md §13 -type ProviderId = "builtin" | "local" | "openai" | "groq" | "siliconflow" | "aliyun" | "deepseek" | "ollama" | "custom"; // docs/dictation.md §3 +type ProviderId = "builtin" | "local" | "openai" | "groq" | "google" | "siliconflow" | "aliyun" | "deepseek" | "kimi" | "glm" | "volcengine" | "hunyuan" | "mimo" | "openrouter" | "ollama" | "custom"; // docs/dictation.md §3 interface EngineSettings { asr_provider: ProviderId /* default "builtin" */; llm_provider: ProviderId /* default "builtin" */; refine_enabled: boolean; providers: Partial<Record<ProviderId, { asr_model?: string; asr_url?: string; llm_model?: string; llm_url?: string }>>; local_model?: string | null; local_device: "auto" | "cpu" | "gpu"; local_gpu?: string | null; local_threads?: number | null /* §10.6 */; language?: string; live_preview: boolean /* default true, §11 */; output_mode: OutputMode /* default "whole_take" */; vad_trim: boolean /* default false */; chinese_script: "simplified" | "traditional" | "as_is" /* default "simplified", §17 */; inject: "paste" | "clipboard_only" } interface Settings { schema: 1; theme: ThemeId; follow_system_theme: boolean; relay_url?: string; relay_enabled: boolean; hotkey: string; engines: EngineSettings; locale: "system" | "zh-cn" | "en" /* default "system" */; auto_update: boolean /* default false */; activation: Activation /* default "hold" */; hold_threshold_ms: number /* default 300 */; extra_recording_ms: number /* default 0 */; edit_hotkey: string | null /* §19,default "Ctrl+Alt+E",null = 关闭 */; solo_key: SoloKey | null /* §13.1,default null = 关闭 */; pairing_always_on: boolean /* docs/pairing.md「常开配对」,default false */ } interface DeviceIdentityPublic { device_id: string; name: string; platform: Platform; public_key: string /* 64 hex */; fingerprint: string /* "A7:C4:19:8E · 3D:F2:61:09" */ } @@ -224,7 +224,7 @@ type UiEvent = { type: "state" } & UiState | { type: "identity" } & DeviceIdenti - **状态**:`state.update: UpdateStatus`(§3),核心以 `update` 事件推送;`Backend.updateStatus()` 对应查询 `update_status`。 - **UI**(`apps/desktop/src/pages/settings/General.tsx` `UpdateControls`,通用分组完整版,关于分组紧凑版):`自动更新` Toggle → `settings_set_auto_update`;状态行 `updateStatusLine`:`idle` 尚未检查更新 / `checking` 正在检查更新… / `up_to_date` 已是最新 · {version} · 检查于 {at} / `available` 有新版本 {version} · 当前 {current} / `downloading` 正在下载 {version} · {percent}%(无 `total` 时显示已收 MB)/ `ready` {version} 已下载 · 重启后生效 / `installing` 正在安装 {version}… / `failed` 更新失败 · {message} / `disabled` 此构建未配置更新源。按钮只有一个且随状态变化:`检查更新`(`update_check`;checking / installing / disabled 时禁用)、`查看新版本`(available / downloading → 打开更新对话框)、`重启并更新`(ready → `update_install`)。 -- **更新对话框**(`apps/desktop/src/features/update/UpdateDialog.tsx`,参考 clash-verge 的做法):有新版本、下载中或待重启时,标题栏出现提示(`新版本 {version}` / `下载中 {percent}` / `重启以更新`),点它或设置里的「查看新版本」打开。对话框写明当前版本与发布日期,提供「查看发布页」(`project_link_open { link: "releases" }`,由壳层拼地址),更新内容取自更新器清单 `latest.json` 的 `notes`(release-please 写的 markdown,按标题、列表、段落渲染成文本:不解释 HTML,链接只留文字,开头重复版本号的标题去掉)。「立即更新」之后同一个对话框显示下载进度、速度(最近 3 s 的读数)与剩余时间;「后台下载」只关对话框,不取消下载;下载校验完是「重启并更新」,失败时写原因并可「重试」。 +- **更新对话框**(`apps/desktop/src/features/update/UpdateDialog.tsx`,参考 clash-verge 的做法):有新版本、下载中或待重启时,标题栏出现提示(`新版本 {version}` / `下载中 {percent}` / `重启以更新`),点它或设置里的「查看新版本」打开;待重启时(自动更新在后台下载好之后)点「重启以更新」同时开始安装(`update_install`),对话框直接显示「正在安装」,不再问一次,悬停提示为「重启 Voltip,完成更新到 {version}」(dictation.md §9「运行期间再检查」)。对话框写明当前版本与发布日期,提供「查看发布页」(`project_link_open { link: "releases" }`,由壳层拼地址),更新内容取自更新器清单 `latest.json` 的 `notes`(release-please 写的 markdown,按标题、列表、段落渲染成文本:不解释 HTML,链接只留文字,开头重复版本号的标题去掉)。「立即更新」之后同一个对话框显示下载进度、速度(最近 3 s 的读数)与剩余时间;「后台下载」只关对话框,不取消下载;下载校验完是「重启并更新」,失败时写原因并可「重试」。 - **MockBackend**:`update_check` → `checking` → 300 ms 后 `available`(固定 `0.0.2`,当前 `0.0.1`,`notes` 为 release-please 格式的 `MOCK_UPDATE_NOTES`);`update_install` → 三次 200 ms `downloading` tick(`received` 16 / 32 / 48 MB,`total` 48 MB)→ `ready` → 200 ms → `installing`;`simulateUpdate(status)` 供测试到达 `failed` / `disabled` / `up_to_date`;构造参数 `update` 设初始状态。`settings_set_locale` / `settings_set_auto_update` 只回发 `settings`。 ## 8. 界面文案 diff --git a/docs/site/guide/updates.md b/docs/site/guide/updates.md index 650bda7e..c2e024df 100644 --- a/docs/site/guide/updates.md +++ b/docs/site/guide/updates.md @@ -11,7 +11,7 @@ Use this page to keep Voltip up to date, to find the files it keeps on your comp Voltip updates from inside the app. Under **Settings → General**: - **Check for updates** looks for a new release once, when you press it. -- **Automatic updates**, off by default, checks when Voltip starts and downloads a new version in the background. It is installed when you choose **Restart and update**, or the next time you start Voltip. +- **Automatic updates**, off by default, checks for updates 10 seconds after Voltip starts and every 4 hours while it runs, and downloads a new version in the background. When the download is done, the title bar shows **Restart to update**: click it to restart Voltip and finish the update. Otherwise the new version is installed the next time you start Voltip. The checks while Voltip runs only download; they never restart it on their own. Version 0.1.7 and earlier check only at start-up. With automatic updates off, Voltip contacts the update server only when you press **Check for updates**. On Windows and macOS the tray menu has the same command. diff --git a/docs/site/index.md b/docs/site/index.md index ee212e36..09ee6ebf 100644 --- a/docs/site/index.md +++ b/docs/site/index.md @@ -229,6 +229,12 @@ home: - { name: SiliconFlow, mark: siliconflow } - { name: Model Studio, mark: aliyun } - { name: DeepSeek, mark: deepseek } + - { name: Kimi, mark: kimi } + - { name: Zhipu GLM, mark: glm } + - { name: Volcengine Ark, mark: volcengine } + - { name: Tencent Hunyuan, mark: hunyuan } + - { name: Xiaomi MiMo, mark: mimo } + - { name: OpenRouter, mark: openrouter } - { name: Ollama, mark: ollama } note: On an NVIDIA L40S, Qwen3-ASR 0.6B recognised 16 seconds of Chinese speech in 0.16 s on the GPU and in 2.87 s on 8 processor threads. See [Local recognition](/recognition/local) and [Cloud services](/recognition/cloud). diff --git a/docs/site/privacy.md b/docs/site/privacy.md index 76541ab2..00915e81 100644 --- a/docs/site/privacy.md +++ b/docs/site/privacy.md @@ -81,7 +81,7 @@ The **Feedback** dialog on the computer and the **Feedback** page on the phone s ## Updates -Voltip checks for updates when you press **Check for updates**, or at start-up when automatic updates are on. The check reads the release information published on GitHub, from this site first and from GitHub when this site cannot be reached; an update is downloaded from the same place. This site runs on Cloudflare, which, like GitHub, sees the network address of the request; the check sends nothing about you or your recordings. On Android, a copy installed from Google Play makes no such check: Google Play updates it. Voltip 0.0.50 for Android makes none at all. +Voltip checks for updates when you press **Check for updates**, or, when automatic updates are on, at start-up and every 4 hours while it runs. The check reads the release information published on GitHub, from this site first and from GitHub when this site cannot be reached; an update is downloaded from the same place. This site runs on Cloudflare, which, like GitHub, sees the network address of the request; the check sends nothing about you or your recordings. On Android, a copy installed from Google Play makes no such check: Google Play updates it. Voltip 0.0.50 for Android makes none at all. ## Network proxy diff --git a/docs/site/recognition/cloud.md b/docs/site/recognition/cloud.md index e2c1ce27..f1694067 100644 --- a/docs/site/recognition/cloud.md +++ b/docs/site/recognition/cloud.md @@ -30,6 +30,12 @@ When a connection to a recognition or polish service drops before the request is | SiliconFlow | Yes | Yes | Required | | Alibaba Cloud Model Studio | Yes, realtime models included | Yes | Required | | DeepSeek | — | Yes | Required | +| Kimi | — | Yes, on its China or international site | Required | +| Zhipu GLM | — | Yes, on its China or international site (Z.ai) | Required | +| Volcengine Ark | — | Yes, Doubao and other models | Required | +| Tencent Hunyuan | — | Yes, through Tencent Cloud TokenHub | Required | +| Xiaomi MiMo | — | Yes | Required | +| OpenRouter | — | Yes, many vendors' models | Required | | Ollama, on this computer | — | Yes | Not needed | | Custom, any OpenAI-compatible endpoint | Yes | Yes | Optional; the endpoint address is required | @@ -40,16 +46,37 @@ Recognition can also run on a GPU server of your own, used as the custom endpoin ## Setting up a provider 1. On the Speech models or AI models page, select the provider's card. Its settings open in a panel on the right, or from the bottom on the phone. -2. Choose a model, and for a custom endpoint enter its address. Once a key is saved, the model list shows every model the provider offers after **Other model…** (the **From the provider** group, with their number), updated each time the list opens, so there is no need to type a model ID. -3. Enter your key. **Test connection** checks the address and the key and shows how many models the provider offers, the same number as in the model list. The Speech models page lists speech recognition models only, and the AI models page leaves out speech, image and embedding models. +2. Choose a model, and for a custom endpoint enter its address. Each provider suggests two or three models, the first being the default, so a provider works as soon as its key is entered. Once a key is saved, the model list shows every model the provider offers after **Other model…** (the **From the provider** group, with their number), updated each time the list opens, so there is no need to type a model ID. +3. Enter your key. **Test connection** checks the address and the key and shows how many models the provider offers, the same number as in the model list. When it fails, the result ends with what the service answered, for example **The key was refused: Incorrect API key provided**. The Speech models page lists speech recognition models only, and the AI models page leaves out speech, image and embedding models. 4. Choose **Set as current** to save what you entered and switch to the provider. **Set as current** on a card switches to the provider without opening its settings. +When a provider refuses a request (a wrong key, a used-up quota, an unknown model, a busy service and so on), the overlay, the home page and the history show what the service answered, for example **Speech recognition failed: the key was refused: Incorrect API key provided**. Keys, tokens and account ids in it are masked; a long answer shows its beginning, and the history's details show all of it. + ## Google AI Studio -Google AI Studio offers Gemini models for AI polish. Create a key at [aistudio.google.com/apikey](https://aistudio.google.com/apikey) (**Get a key** in the provider's settings opens it) and enter it in the Google AI Studio settings on the **AI models** page. The model list suggests Gemini models with a free tier; once the key is saved, it also shows every text model the key can use, so you choose from the list instead of typing a name. +Google AI Studio offers Gemini models for AI polish. Create a key at [aistudio.google.com/apikey](https://aistudio.google.com/apikey) (**Get a key** in the provider's settings opens it) and enter it in the Google AI Studio settings on the **AI models** page. The model list suggests Gemini models with a free tier, `gemini-3.5-flash-lite` first: polish handles short texts, and Flash-Lite answers faster. Once the key is saved, the list also shows every text model the key can use, so you choose from the list instead of typing a name. + +A Google model is sometimes unavailable for a while because of high demand (HTTP 503); the overlay then says **Not polished · service busy**. Try again later, or pick another model. On the free tier, Google may use what you send to improve its products; a key from a Google Cloud project with billing turned on is not used that way. See [Privacy](/privacy). +## China and international sites + +Kimi and Zhipu GLM each have a China site and an international site (Zhipu's international site is Z.ai), with separate accounts and keys. Under **Site** in the provider's settings, choose the site you signed up on; the endpoint address changes with it, and **Get a key** opens the key page of that site. The China site is the default. + +## Providers in China and OpenRouter + +These providers are for AI polish only. + +- **Kimi**: suggests the K3 models, `kimi-k3.1-pro-highspeed` first. Kimi's K2 models are closed to new accounts from 31 October 2026. +- **Zhipu GLM**: suggests `glm-5.3-flash`; `glm-4.7-flash` is free on both sites. +- **Volcengine Ark**: suggests Doubao Seed models. Enable a model under Model Activation in the Ark console first; otherwise its requests are refused. +- **Tencent Hunyuan**: used through Tencent Cloud's model platform TokenHub, suggesting `hy3` and `hy4-preview`; TokenHub also offers other vendors' models. Enable a model in the TokenHub console first. For the Singapore site, enter its address `https://tokenhub-intl.tencentmaas.com/v1` as the endpoint address and use a key made there. +- **Xiaomi MiMo**: suggests `mimo-v2.6-flash` and `mimo-v2.6-pro`. With a Token Plan, enter the plan's own address as the endpoint address. +- **OpenRouter**: many vendors' models with one key, suggesting fast, inexpensive models such as `google/gemini-3.5-flash-lite`. + +Many of these models think before they answer. So that polish finishes quickly, Voltip turns thinking off in its requests; models that cannot stop thinking, such as Kimi K3 and GLM-5.3, are asked for a low reasoning effort. + ## The Responses interface For AI polish, the custom provider's **Interface** can be Chat Completions (the default) or Responses. OpenAI's reasoning models and some gateways (kiro-provider, for example) speak the Responses interface. diff --git a/docs/site/recognition/polish.md b/docs/site/recognition/polish.md index ce4a525e..ffdad655 100644 --- a/docs/site/recognition/polish.md +++ b/docs/site/recognition/polish.md @@ -10,9 +10,9 @@ Turn it on or off with the **AI Polish** switch at the top right of the window. ## Services -AI polish uses the service and model chosen on the **AI models** page, or from the model menu next to the **AI Polish** switch: the built-in service, OpenAI, Groq, SiliconFlow, Alibaba Cloud Model Studio, DeepSeek, a local Ollama, or any OpenAI-compatible endpoint. The same menu is on the home page; it lists the models of the built-in service and of each provider that is set up. See [Cloud services](/recognition/cloud) for setting one up. +AI polish uses the service and model chosen on the **AI models** page, or from the model menu next to the **AI Polish** switch: the built-in service, OpenAI, Groq, Google AI Studio, SiliconFlow, Alibaba Cloud Model Studio, DeepSeek, Kimi, Zhipu GLM, Volcengine Ark, Tencent Hunyuan, Xiaomi MiMo, OpenRouter, a local Ollama, or any OpenAI-compatible endpoint. The same menu is on the home page; it lists the models of the built-in service and of each provider that is set up. See [Cloud services](/recognition/cloud) for setting one up. -If the service does not answer, or its answer is cut off at the length limit, the recognised text is inserted without polish, and the dictation is not lost; once it is in, the overlay says it was not polished and why, for example **service busy**, and so do the history's details. For the notice the built-in service shows when it is busy, see [The built-in service](/recognition/cloud#the-built-in-service). When the selected model's quota is used up, AI polish can move on to other models in order: see **When a model's quota runs out** in [Cloud services](/recognition/cloud). +If the service does not answer, is busy, or its answer is cut off at the length limit, the recognised text is inserted without polish, and the dictation is not lost; once it is in, the overlay says it was not polished and why, for example **service busy**. The history's details say why too, including what the service answered. For the notice the built-in service shows when it is busy, see [The built-in service](/recognition/cloud#the-built-in-service). When the selected model's quota is used up, AI polish can move on to other models in order: see **When a model's quota runs out** in [Cloud services](/recognition/cloud). ## Presets diff --git a/docs/site/zh/guide/updates.md b/docs/site/zh/guide/updates.md index 0fd86144..022ff630 100644 --- a/docs/site/zh/guide/updates.md +++ b/docs/site/zh/guide/updates.md @@ -11,7 +11,7 @@ description: Voltip 如何更新,各平台上的数据保存位置,以及如 Voltip 在应用内更新。在「设置 → 通用」中: - 「检查更新」在你点击时检查一次是否有新版本。 -- 「自动更新」默认关闭。开启后,Voltip 启动时检查更新,并在后台下载新版本;选择「重启并更新」时安装,或在下次启动 Voltip 时安装。 +- 「自动更新」默认关闭。开启后,Voltip 在启动 10 秒后检查更新,运行期间每 4 小时再检查一次,有新版本就在后台下载。下载完成后,标题栏显示「重启以更新」,点击它即可重启 Voltip 并完成安装;不点击时,新版本在下次启动 Voltip 时安装。运行期间的检查只下载,不会自行重启 Voltip。0.1.7 及更早的版本只在启动时检查。 关闭自动更新时,Voltip 只在你点击「检查更新」时连接更新服务器。在 Windows 和 macOS 上,托盘菜单中也有同样的命令。 diff --git a/docs/site/zh/index.md b/docs/site/zh/index.md index d1311b6d..2a7ea7ff 100644 --- a/docs/site/zh/index.md +++ b/docs/site/zh/index.md @@ -229,6 +229,12 @@ home: - { name: 硅基流动, mark: siliconflow } - { name: 阿里云百炼, mark: aliyun } - { name: DeepSeek, mark: deepseek } + - { name: Kimi, mark: kimi } + - { name: 智谱 GLM, mark: glm } + - { name: 火山方舟, mark: volcengine } + - { name: 腾讯混元, mark: hunyuan } + - { name: 小米 MiMo, mark: mimo } + - { name: OpenRouter, mark: openrouter } - { name: Ollama, mark: ollama } note: 在 NVIDIA L40S 上实测,Qwen3-ASR 0.6B 识别一段 16 秒的中文语音,GPU 用时 0.16 秒,8 个处理器线程用时 2.87 秒。详见[本地识别](/zh/recognition/local)和[云端服务](/zh/recognition/cloud)。 diff --git a/docs/site/zh/privacy.md b/docs/site/zh/privacy.md index af7a4494..ed83ab82 100644 --- a/docs/site/zh/privacy.md +++ b/docs/site/zh/privacy.md @@ -81,7 +81,7 @@ description: 在各种使用方式下 Voltip 把哪些内容发送到哪里, ## 更新 -Voltip 在你点击「检查更新」时检查更新;开启自动更新后,也会在启动时检查。检查时读取 GitHub 上发布的版本信息:先从本站读取,本站无法访问时再从 GitHub 读取,更新也从同一处下载。本站运行在 Cloudflare 上,与 GitHub 一样能看到请求的网络地址;检查更新不发送任何关于你或你的录音的信息。在 Android 上,从 Google Play 安装的不做这项检查,由 Google Play 更新;Android 版 0.0.50 完全不做这项检查。 +Voltip 在你点击「检查更新」时检查更新;开启自动更新后,也会在启动时检查,并在运行期间每 4 小时再检查一次。检查时读取 GitHub 上发布的版本信息:先从本站读取,本站无法访问时再从 GitHub 读取,更新也从同一处下载。本站运行在 Cloudflare 上,与 GitHub 一样能看到请求的网络地址;检查更新不发送任何关于你或你的录音的信息。在 Android 上,从 Google Play 安装的不做这项检查,由 Google Play 更新;Android 版 0.0.50 完全不做这项检查。 ## 网络代理 diff --git a/docs/site/zh/recognition/cloud.md b/docs/site/zh/recognition/cloud.md index b3e77a53..c63eb815 100644 --- a/docs/site/zh/recognition/cloud.md +++ b/docs/site/zh/recognition/cloud.md @@ -30,6 +30,12 @@ description: 内置服务、Voltip 支持的云端识别与 AI 润色服务商 | 硅基流动 | 支持 | 支持 | 必填 | | 阿里云百炼 | 支持,含实时识别模型 | 支持 | 必填 | | DeepSeek | — | 支持 | 必填 | +| Kimi | — | 支持,可切换中国站与国际站 | 必填 | +| 智谱 GLM | — | 支持,可切换中国站与国际站(Z.ai) | 必填 | +| 火山方舟 | — | 支持,豆包等模型 | 必填 | +| 腾讯混元 | — | 支持,通过腾讯云 TokenHub | 必填 | +| 小米 MiMo | — | 支持 | 必填 | +| OpenRouter | — | 支持,多家厂商的模型 | 必填 | | Ollama(本机运行) | — | 支持 | 不需要 | | 自定义,任意 OpenAI 兼容接口 | 支持 | 支持 | 可选;接口地址必填 | @@ -40,16 +46,37 @@ description: 内置服务、Voltip 支持的云端识别与 AI 润色服务商 ## 设置服务商 1. 在「语音模型」或「AI 模型」页点该服务商的卡片,右侧会打开它的设置;手机上从底部打开。 -2. 选择模型;自定义接口还需要填写接口地址。保存密钥后,模型列表会在「其他模型…」之后列出该服务商提供的全部可用模型(「服务商提供」一组,标注数量),每次打开列表时更新,不必手动填写模型 ID。 -3. 填写你的密钥。「测试连接」会检查地址和密钥,并显示该服务商提供的可用模型数量,与模型列表中标注的数量相同。语音模型页只列出语音识别模型,AI 模型页不列出语音、图像、向量等模型。 +2. 选择模型;自定义接口还需要填写接口地址。每个服务商推荐两三个模型,第一个是默认模型,填写密钥后即可使用。保存密钥后,模型列表会在「其他模型…」之后列出该服务商提供的全部可用模型(「服务商提供」一组,标注数量),每次打开列表时更新,不必手动填写模型 ID。 +3. 填写你的密钥。「测试连接」会检查地址和密钥,并显示该服务商提供的可用模型数量,与模型列表中标注的数量相同。连接失败时,结果后面附有服务返回的原因,例如「密钥被拒绝:Incorrect API key provided」。语音模型页只列出语音识别模型,AI 模型页不列出语音、图像、向量等模型。 4. 选择「设为当前」,保存填写的内容并切换到这个服务商。卡片上的「设为当前」不打开设置,直接切换。 +服务商拒绝请求时(密钥错误、额度用完、模型不存在或服务繁忙等),悬浮窗、首页和历史记录会显示服务返回的原因,例如「识别服务出错:密钥被拒绝:Incorrect API key provided」。原因中的密钥、令牌和账号 ID 会被遮住,较长的原因只显示开头,完整内容在历史记录的详情中。 + ## Google AI Studio -Google AI Studio 提供用于 AI 润色的 Gemini 模型。在 [aistudio.google.com/apikey](https://aistudio.google.com/apikey) 创建密钥(服务商设置中的「获取密钥」会打开这个页面),填到「AI 模型」页 Google AI Studio 的设置中。模型列表里推荐的是有免费额度的 Gemini 模型;保存密钥后,列表还会列出这把密钥能用的全部文本模型,直接从列表中选择,不必手动填写模型名。 +Google AI Studio 提供用于 AI 润色的 Gemini 模型。在 [aistudio.google.com/apikey](https://aistudio.google.com/apikey) 创建密钥(服务商设置中的「获取密钥」会打开这个页面),填到「AI 模型」页 Google AI Studio 的设置中。模型列表里推荐的是有免费额度的 Gemini 模型,第一个是 `gemini-3.5-flash-lite`:润色只处理短文字,Flash-Lite 响应更快。保存密钥后,列表还会列出这把密钥能用的全部文本模型,直接从列表中选择,不必手动填写模型名。 + +Google 的模型有时会因请求过多暂时不可用(HTTP 503),这时悬浮窗显示「未润色 · 服务繁忙」。可以稍后再试,或换一个模型。 使用免费额度时,Google 可能用你发送的内容改进其产品;绑定了结算账号的 Google Cloud 项目的密钥不会这样使用。见[隐私](/zh/privacy)。 +## 中国站与国际站 + +Kimi 和智谱 GLM 各有中国站和国际站(智谱的国际站为 Z.ai),两个站点的账号和密钥不通用。在服务商设置的「站点」中选择你注册的站点,接口地址随之更改;「获取密钥」会打开所选站点的密钥页面。默认使用中国站。 + +## 国内服务商与 OpenRouter + +以下服务商只用于 AI 润色。 + +- **Kimi**:推荐 `kimi-k3.1-pro-highspeed` 等 K3 系列模型。Kimi 的 K2 系列模型自 2026 年 10 月 31 日起不再向新注册用户开放。 +- **智谱 GLM**:推荐 `glm-5.3-flash`;`glm-4.7-flash` 在两个站点都免费。 +- **火山方舟**:推荐豆包 Seed 系列模型。模型需要先在方舟控制台的「开通管理」中开通,否则请求会被拒绝。 +- **腾讯混元**:通过腾讯云的大模型服务平台 TokenHub 使用,推荐 `hy3` 和 `hy4-preview`;TokenHub 还提供其他厂商的模型。模型需要先在 TokenHub 控制台开通。使用新加坡站时,把它的地址 `https://tokenhub-intl.tencentmaas.com/v1` 填到接口地址中,并使用新加坡站创建的密钥。 +- **小米 MiMo**:推荐 `mimo-v2.6-flash` 和 `mimo-v2.6-pro`。使用 Token Plan 套餐时,把套餐的专属地址填到接口地址中。 +- **OpenRouter**:用一把密钥使用多家厂商的模型,推荐 `google/gemini-3.5-flash-lite` 等响应快、价格低的模型。 + +这些服务商的许多模型会先思考再回答。为了让润色尽快完成,Voltip 会在请求中关闭思考;不能关闭思考的模型,例如 Kimi K3 和 GLM-5.3,使用较低的推理强度。 + ## Responses 接口 自定义服务商的 AI 润色可以在「接口类型」中选择 Chat Completions(默认)或 Responses。OpenAI 的推理模型和一些网关(例如 kiro-provider)支持 Responses 接口。 diff --git a/docs/site/zh/recognition/polish.md b/docs/site/zh/recognition/polish.md index 55daaea2..ed303034 100644 --- a/docs/site/zh/recognition/polish.md +++ b/docs/site/zh/recognition/polish.md @@ -10,9 +10,9 @@ AI 润色在插入之前整理识别出的文字,具体做法取决于当前 ## 服务 -AI 润色使用「AI 模型」页或「AI 润色」开关右侧模型菜单中选择的服务和模型:内置服务、OpenAI、Groq、硅基流动、阿里云百炼、DeepSeek、本机的 Ollama,或任意 OpenAI 兼容接口。首页也有这个菜单,其中列出内置服务和已设置的服务商提供的模型。设置方法见[云端服务](/zh/recognition/cloud)。 +AI 润色使用「AI 模型」页或「AI 润色」开关右侧模型菜单中选择的服务和模型:内置服务、OpenAI、Groq、Google AI Studio、硅基流动、阿里云百炼、DeepSeek、Kimi、智谱 GLM、火山方舟、腾讯混元、小米 MiMo、OpenRouter、本机的 Ollama,或任意 OpenAI 兼容接口。首页也有这个菜单,其中列出内置服务和已设置的服务商提供的模型。设置方法见[云端服务](/zh/recognition/cloud)。 -如果服务没有响应,或返回的结果在长度上限处被截断,Voltip 会插入未经润色的识别结果,这次听写不会丢失;插入后悬浮窗会注明「未润色」和原因,例如「服务繁忙」,历史记录的详情中也会显示。内置服务繁忙时的提示见[内置服务](/zh/recognition/cloud#内置服务)。所选模型的额度用完时,可以按顺序改用其他模型,见[额度用完后改用其他模型](/zh/recognition/cloud#额度用完后改用其他模型)。 +如果服务没有响应、暂时繁忙,或返回的结果在长度上限处被截断,Voltip 会插入未经润色的识别结果,这次听写不会丢失;插入后悬浮窗会注明「未润色」和原因,例如「服务繁忙」。历史记录的详情中也会显示原因,包括服务返回的说明。内置服务繁忙时的提示见[内置服务](/zh/recognition/cloud#内置服务)。所选模型的额度用完时,可以按顺序改用其他模型,见[额度用完后改用其他模型](/zh/recognition/cloud#额度用完后改用其他模型)。 ## 预设 diff --git a/packages/shared/src/backend.ts b/packages/shared/src/backend.ts index ebb402c1..9103cf28 100644 --- a/packages/shared/src/backend.ts +++ b/packages/shared/src/backend.ts @@ -34,6 +34,7 @@ import type { GuidePage, ProjectLink, ProviderId, + SiteId, StagedAttachment, UiEvent, UiState, @@ -122,10 +123,11 @@ export interface Backend { /** 「粘贴到上一个窗口」 (`paste_text`): paste `text` into the window the user came from, or copy * it; resolves with what became of it (a refusal is an outcome, not a rejection). */ pasteText(text: string): Promise<PasteOutcome>; - /** Open the vendor's API-key page, or with `page: "quota"` its page of free quota and usage, in - * the browser (`provider_console_open`); the shell only opens catalogue URLs, so the webview - * names the provider and the page, never a URL. */ - providerConsoleOpen(provider: ProviderId, page?: ConsolePage): Promise<void>; + /** Open the vendor's API-key page (`site`'s, for a vendor with several, docs/dictation.md + * §3.2), or with `page: "quota"` its page of free quota and usage, in the browser + * (`provider_console_open`); the shell only opens catalogue URLs, so the webview names the + * provider, the page and the site, never a URL. */ + providerConsoleOpen(provider: ProviderId, page?: ConsolePage, site?: SiteId): Promise<void>; /** Open a project page in the browser (`project_link_open`): the repository or its new-issue * page; the shell builds the URL from its own repository. */ projectLinkOpen(link: ProjectLink): Promise<void>; diff --git a/packages/shared/src/engine-drafts.test.ts b/packages/shared/src/engine-drafts.test.ts index 09da5d9a..e66dc58e 100644 --- a/packages/shared/src/engine-drafts.test.ts +++ b/packages/shared/src/engine-drafts.test.ts @@ -14,6 +14,8 @@ import { providersFor, serviceTarget, shortModel, + siteOf, + siteUrl, withProvider, } from "./engine-drafts"; import { defaultEngineSettings } from "./schema"; @@ -43,6 +45,26 @@ const SETTINGS: EngineSettings = { } as EngineSettings; describe("engine drafts (provider cards, desktop and phone)", () => { + it("docs/dictation.md §3.2: the URL field names a vendor's site, and a site fills the field", () => { + const kimi = service({ + sites: [ + { id: "cn", base_url: "https://api.moonshot.cn/v1" }, + { id: "intl", base_url: "https://api.moonshot.ai/v1" }, + ], + }); + expect(siteOf(kimi, "")).toBe("cn"); + expect(siteOf(kimi, " ")).toBe("cn"); + expect(siteOf(kimi, "https://api.moonshot.ai/v1")).toBe("intl"); + expect(siteOf(kimi, " HTTPS://API.moonshot.AI/v1/ ")).toBe("intl"); + expect(siteOf(kimi, "https://api.moonshot.cn/v1")).toBe("cn"); + expect(siteOf(kimi, "https://proxy.example.test/v1")).toBeUndefined(); + expect(siteOf(service(), "")).toBeUndefined(); + // The default site empties the field, so the catalogue's address follows it. + expect(siteUrl(kimi, "cn")).toBe(""); + expect(siteUrl(kimi, "intl")).toBe("https://api.moonshot.ai/v1"); + expect(siteUrl(service(), "intl")).toBe(""); + }); + it("lists the providers that offer a service", () => { const status = { providers: [ @@ -288,6 +310,41 @@ describe("engine drafts (provider cards, desktop and phone)", () => { ); }); + it("regression: section 3.12 a failed probe says what the service said", () => { + // 2026-10-24: a refused 测试连接 said only 密钥被拒绝 or 服务返回错误(HTTP 404). + const refused = probeText( + { + provider: "openai", + kind: "llm", + result: "failed", + reason: "unauthorized", + detail: "Incorrect API key provided: sk-***. (invalid_api_key)", + }, + t, + ); + expect(refused).toEqual({ + ok: false, + text: "密钥被拒绝:Incorrect API key provided: sk-***. (invalid_api_key)", + }); + const gone = probeText( + { + provider: "google", + kind: "llm", + result: "failed", + reason: "http_status", + status: 404, + detail: "Not Found", + }, + createTranslator("en").t, + ); + expect(gone.text).toBe("The service returned HTTP 404: Not Found"); + const blank = probeText( + { provider: "groq", kind: "llm", result: "failed", reason: "timeout", detail: " " }, + t, + ); + expect(blank.text).toBe(t("engines.probeResult.timeout")); + }); + it("names where a service sends its data", () => { expect(serviceTarget(undefined, "x", t)).toBeUndefined(); expect(serviceTarget("local", "x", t)).toBeUndefined(); diff --git a/packages/shared/src/engine-drafts.ts b/packages/shared/src/engine-drafts.ts index 6d1c583f..6884257d 100644 --- a/packages/shared/src/engine-drafts.ts +++ b/packages/shared/src/engine-drafts.ts @@ -15,6 +15,7 @@ import type { ReasoningEffort, ServiceKind, ServiceStatus, + SiteId, } from "./schema"; /** The providers that offer `kind` in this build, in display order. */ @@ -168,6 +169,30 @@ export function modelListHelp( : t("engines.field.modelHelpKeyless"); } +/** docs/dictation.md §3.2: the site a card's URL field points at — the default site while the + * field is empty, the site whose address it holds, or none for any other address (a proxy, a + * plan's own endpoint). `undefined` too for a vendor with one site. */ +export function siteOf(service: ServiceStatus, baseUrl: string): SiteId | undefined { + const sites = service.sites ?? []; + const url = comparableUrl(baseUrl); + if (sites.length === 0) return undefined; + if (url.length === 0) return sites[0]?.id; + return sites.find((site) => comparableUrl(site.base_url) === url)?.id; +} + +/** What the URL field holds for `site`: nothing for the default site, whose address the catalogue + * keeps up to date, the site's address otherwise. */ +export function siteUrl(service: ServiceStatus, site: SiteId): string { + const sites = service.sites ?? []; + const index = sites.findIndex((s) => s.id === site); + return index <= 0 ? "" : (sites[index]?.base_url ?? ""); +} + +/** `https://API.example.com/v1/` and `https://api.example.com/v1` are one address. */ +function comparableUrl(url: string): string { + return url.trim().replace(/\/+$/, "").toLowerCase(); +} + /** Client-side checks before a save (the core validates again). */ export function checkProviderDraft( draft: ProviderDraft, @@ -203,9 +228,15 @@ export function probeText( }), }; const reason = report.reason ?? "unreachable"; + const result = t(`engines.probeResult.${reason}`, { status: report.status ?? 0 }); + // docs/dictation.md §3.12: what the service said, so the user knows what to fix. + const detail = report.detail?.trim(); return { ok: false, - text: t(`engines.probeResult.${reason}`, { status: report.status ?? 0 }), + text: + detail === undefined || detail.length === 0 + ? result + : t("engines.probeResult.withDetail", { result, detail }), }; } diff --git a/packages/shared/src/fixtures/ipc/events.json b/packages/shared/src/fixtures/ipc/events.json index d41d541d..14a90856 100644 --- a/packages/shared/src/fixtures/ipc/events.json +++ b/packages/shared/src/fixtures/ipc/events.json @@ -1017,6 +1017,7 @@ "duration_ms": 900, "asr_ms": 410, "refine_failure": "rate_limited", + "refine_error": "服务繁忙:refine service overloaded (HTTP 503): This model is currently experiencing high demand.", "outcome": { "kind": "clipboard", "reason": "没有可粘贴的前台窗口" @@ -1199,6 +1200,7 @@ "duration_ms": 900, "asr_ms": 410, "refine_failure": "rate_limited", + "refine_error": "服务繁忙:refine service overloaded (HTTP 503): This model is currently experiencing high demand.", "outcome": { "kind": "clipboard", "reason": "enigo: the application does not have the permission to simulate input", @@ -1283,6 +1285,7 @@ "duration_ms": 900, "asr_ms": 410, "refine_failure": "rate_limited", + "refine_error": "服务繁忙:refine service overloaded (HTTP 503): This model is currently experiencing high demand.", "outcome": { "kind": "clipboard", "reason": "全文超过 5000 字,已复制到剪贴板,未直接粘贴。", @@ -1422,14 +1425,8 @@ "on_device": false, "console": true, "llm": { - "model": "gemini-3.8-flash", - "presets": [ - "gemini-3.8-flash", - "gemini-3.5-flash-lite", - "gemini-3.1-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-pro" - ], + "model": "gemini-3.5-flash-lite", + "presets": ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-flash-lite"], "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "key": { @@ -1481,8 +1478,6 @@ "presets": [ "qwen-audio-3.1-asr-flash-streaming", "qwen-audio-3.1-asr-flash", - "qwen-audio-3.1-asr-flash-message", - "qwen3-asr-flash", "fun-asr-realtime" ], "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", @@ -1525,6 +1520,142 @@ "active": false } }, + { + "id": "kimi", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "kimi-k3.1-pro-highspeed", + "presets": ["kimi-k3.1-pro-highspeed", "kimi-k3.1-pro", "kimi-k3"], + "base_url": "https://api.moonshot.cn/v1", + "default_base_url": "https://api.moonshot.cn/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://api.moonshot.cn/v1" + }, + { + "id": "intl", + "base_url": "https://api.moonshot.ai/v1" + } + ] + } + }, + { + "id": "glm", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "glm-5.3-flash", + "presets": ["glm-5.3-flash", "glm-4.7-flash", "glm-5.3"], + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "default_base_url": "https://open.bigmodel.cn/api/paas/v4", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://open.bigmodel.cn/api/paas/v4" + }, + { + "id": "intl", + "base_url": "https://api.z.ai/api/paas/v4" + } + ] + } + }, + { + "id": "volcengine", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "doubao-seed-2-1-lite-260915", + "presets": [ + "doubao-seed-2-1-lite-260915", + "doubao-seed-2-1-turbo-260628", + "doubao-seed-2-0-mini-260428" + ], + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "default_base_url": "https://ark.cn-beijing.volces.com/api/v3", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "hunyuan", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "hy3", + "presets": ["hy3", "hy4-preview"], + "base_url": "https://tokenhub.tencentmaas.com/v1", + "default_base_url": "https://tokenhub.tencentmaas.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "mimo", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "mimo-v2.6-flash", + "presets": ["mimo-v2.6-flash", "mimo-v2.6-pro"], + "base_url": "https://api.xiaomimimo.com/v1", + "default_base_url": "https://api.xiaomimimo.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "openrouter", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "google/gemini-3.5-flash-lite", + "presets": [ + "google/gemini-3.5-flash-lite", + "deepseek/deepseek-v4-flash", + "openai/gpt-6-luna" + ], + "base_url": "https://openrouter.ai/api/v1", + "default_base_url": "https://openrouter.ai/api/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, { "id": "ollama", "key": "none", @@ -1720,14 +1851,8 @@ "on_device": false, "console": true, "llm": { - "model": "gemini-3.8-flash", - "presets": [ - "gemini-3.8-flash", - "gemini-3.5-flash-lite", - "gemini-3.1-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-pro" - ], + "model": "gemini-3.5-flash-lite", + "presets": ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-flash-lite"], "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "key": { @@ -1779,8 +1904,6 @@ "presets": [ "qwen-audio-3.1-asr-flash-streaming", "qwen-audio-3.1-asr-flash", - "qwen-audio-3.1-asr-flash-message", - "qwen3-asr-flash", "fun-asr-realtime" ], "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", @@ -1823,6 +1946,142 @@ "active": false } }, + { + "id": "kimi", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "kimi-k3.1-pro-highspeed", + "presets": ["kimi-k3.1-pro-highspeed", "kimi-k3.1-pro", "kimi-k3"], + "base_url": "https://api.moonshot.cn/v1", + "default_base_url": "https://api.moonshot.cn/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://api.moonshot.cn/v1" + }, + { + "id": "intl", + "base_url": "https://api.moonshot.ai/v1" + } + ] + } + }, + { + "id": "glm", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "glm-5.3-flash", + "presets": ["glm-5.3-flash", "glm-4.7-flash", "glm-5.3"], + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "default_base_url": "https://open.bigmodel.cn/api/paas/v4", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://open.bigmodel.cn/api/paas/v4" + }, + { + "id": "intl", + "base_url": "https://api.z.ai/api/paas/v4" + } + ] + } + }, + { + "id": "volcengine", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "doubao-seed-2-1-lite-260915", + "presets": [ + "doubao-seed-2-1-lite-260915", + "doubao-seed-2-1-turbo-260628", + "doubao-seed-2-0-mini-260428" + ], + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "default_base_url": "https://ark.cn-beijing.volces.com/api/v3", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "hunyuan", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "hy3", + "presets": ["hy3", "hy4-preview"], + "base_url": "https://tokenhub.tencentmaas.com/v1", + "default_base_url": "https://tokenhub.tencentmaas.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "mimo", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "mimo-v2.6-flash", + "presets": ["mimo-v2.6-flash", "mimo-v2.6-pro"], + "base_url": "https://api.xiaomimimo.com/v1", + "default_base_url": "https://api.xiaomimimo.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "openrouter", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "google/gemini-3.5-flash-lite", + "presets": [ + "google/gemini-3.5-flash-lite", + "deepseek/deepseek-v4-flash", + "openai/gpt-6-luna" + ], + "base_url": "https://openrouter.ai/api/v1", + "default_base_url": "https://openrouter.ai/api/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, { "id": "ollama", "key": "none", @@ -2018,14 +2277,8 @@ "on_device": false, "console": true, "llm": { - "model": "gemini-3.8-flash", - "presets": [ - "gemini-3.8-flash", - "gemini-3.5-flash-lite", - "gemini-3.1-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-pro" - ], + "model": "gemini-3.5-flash-lite", + "presets": ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-flash-lite"], "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "key": { @@ -2077,8 +2330,6 @@ "presets": [ "qwen-audio-3.1-asr-flash-streaming", "qwen-audio-3.1-asr-flash", - "qwen-audio-3.1-asr-flash-message", - "qwen3-asr-flash", "fun-asr-realtime" ], "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", @@ -2121,6 +2372,142 @@ "active": false } }, + { + "id": "kimi", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "kimi-k3.1-pro-highspeed", + "presets": ["kimi-k3.1-pro-highspeed", "kimi-k3.1-pro", "kimi-k3"], + "base_url": "https://api.moonshot.cn/v1", + "default_base_url": "https://api.moonshot.cn/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://api.moonshot.cn/v1" + }, + { + "id": "intl", + "base_url": "https://api.moonshot.ai/v1" + } + ] + } + }, + { + "id": "glm", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "glm-5.3-flash", + "presets": ["glm-5.3-flash", "glm-4.7-flash", "glm-5.3"], + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "default_base_url": "https://open.bigmodel.cn/api/paas/v4", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://open.bigmodel.cn/api/paas/v4" + }, + { + "id": "intl", + "base_url": "https://api.z.ai/api/paas/v4" + } + ] + } + }, + { + "id": "volcengine", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "doubao-seed-2-1-lite-260915", + "presets": [ + "doubao-seed-2-1-lite-260915", + "doubao-seed-2-1-turbo-260628", + "doubao-seed-2-0-mini-260428" + ], + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "default_base_url": "https://ark.cn-beijing.volces.com/api/v3", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "hunyuan", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "hy3", + "presets": ["hy3", "hy4-preview"], + "base_url": "https://tokenhub.tencentmaas.com/v1", + "default_base_url": "https://tokenhub.tencentmaas.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "mimo", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "mimo-v2.6-flash", + "presets": ["mimo-v2.6-flash", "mimo-v2.6-pro"], + "base_url": "https://api.xiaomimimo.com/v1", + "default_base_url": "https://api.xiaomimimo.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "openrouter", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "google/gemini-3.5-flash-lite", + "presets": [ + "google/gemini-3.5-flash-lite", + "deepseek/deepseek-v4-flash", + "openai/gpt-6-luna" + ], + "base_url": "https://openrouter.ai/api/v1", + "default_base_url": "https://openrouter.ai/api/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, { "id": "ollama", "key": "none", @@ -2340,14 +2727,8 @@ "on_device": false, "console": true, "llm": { - "model": "gemini-3.8-flash", - "presets": [ - "gemini-3.8-flash", - "gemini-3.5-flash-lite", - "gemini-3.1-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-pro" - ], + "model": "gemini-3.5-flash-lite", + "presets": ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-flash-lite"], "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "key": { @@ -2399,8 +2780,6 @@ "presets": [ "qwen-audio-3.1-asr-flash-streaming", "qwen-audio-3.1-asr-flash", - "qwen-audio-3.1-asr-flash-message", - "qwen3-asr-flash", "fun-asr-realtime" ], "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", @@ -2443,6 +2822,142 @@ "active": false } }, + { + "id": "kimi", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "kimi-k3.1-pro-highspeed", + "presets": ["kimi-k3.1-pro-highspeed", "kimi-k3.1-pro", "kimi-k3"], + "base_url": "https://api.moonshot.cn/v1", + "default_base_url": "https://api.moonshot.cn/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://api.moonshot.cn/v1" + }, + { + "id": "intl", + "base_url": "https://api.moonshot.ai/v1" + } + ] + } + }, + { + "id": "glm", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "glm-5.3-flash", + "presets": ["glm-5.3-flash", "glm-4.7-flash", "glm-5.3"], + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "default_base_url": "https://open.bigmodel.cn/api/paas/v4", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://open.bigmodel.cn/api/paas/v4" + }, + { + "id": "intl", + "base_url": "https://api.z.ai/api/paas/v4" + } + ] + } + }, + { + "id": "volcengine", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "doubao-seed-2-1-lite-260915", + "presets": [ + "doubao-seed-2-1-lite-260915", + "doubao-seed-2-1-turbo-260628", + "doubao-seed-2-0-mini-260428" + ], + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "default_base_url": "https://ark.cn-beijing.volces.com/api/v3", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "hunyuan", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "hy3", + "presets": ["hy3", "hy4-preview"], + "base_url": "https://tokenhub.tencentmaas.com/v1", + "default_base_url": "https://tokenhub.tencentmaas.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "mimo", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "mimo-v2.6-flash", + "presets": ["mimo-v2.6-flash", "mimo-v2.6-pro"], + "base_url": "https://api.xiaomimimo.com/v1", + "default_base_url": "https://api.xiaomimimo.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "openrouter", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "google/gemini-3.5-flash-lite", + "presets": [ + "google/gemini-3.5-flash-lite", + "deepseek/deepseek-v4-flash", + "openai/gpt-6-luna" + ], + "base_url": "https://openrouter.ai/api/v1", + "default_base_url": "https://openrouter.ai/api/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, { "id": "ollama", "key": "none", @@ -2637,14 +3152,8 @@ "on_device": false, "console": true, "llm": { - "model": "gemini-3.8-flash", - "presets": [ - "gemini-3.8-flash", - "gemini-3.5-flash-lite", - "gemini-3.1-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-pro" - ], + "model": "gemini-3.5-flash-lite", + "presets": ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-flash-lite"], "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "key": { @@ -2696,8 +3205,6 @@ "presets": [ "qwen-audio-3.1-asr-flash-streaming", "qwen-audio-3.1-asr-flash", - "qwen-audio-3.1-asr-flash-message", - "qwen3-asr-flash", "fun-asr-realtime" ], "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", @@ -2740,6 +3247,142 @@ "active": false } }, + { + "id": "kimi", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "kimi-k3.1-pro-highspeed", + "presets": ["kimi-k3.1-pro-highspeed", "kimi-k3.1-pro", "kimi-k3"], + "base_url": "https://api.moonshot.cn/v1", + "default_base_url": "https://api.moonshot.cn/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://api.moonshot.cn/v1" + }, + { + "id": "intl", + "base_url": "https://api.moonshot.ai/v1" + } + ] + } + }, + { + "id": "glm", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "glm-5.3-flash", + "presets": ["glm-5.3-flash", "glm-4.7-flash", "glm-5.3"], + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "default_base_url": "https://open.bigmodel.cn/api/paas/v4", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://open.bigmodel.cn/api/paas/v4" + }, + { + "id": "intl", + "base_url": "https://api.z.ai/api/paas/v4" + } + ] + } + }, + { + "id": "volcengine", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "doubao-seed-2-1-lite-260915", + "presets": [ + "doubao-seed-2-1-lite-260915", + "doubao-seed-2-1-turbo-260628", + "doubao-seed-2-0-mini-260428" + ], + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "default_base_url": "https://ark.cn-beijing.volces.com/api/v3", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "hunyuan", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "hy3", + "presets": ["hy3", "hy4-preview"], + "base_url": "https://tokenhub.tencentmaas.com/v1", + "default_base_url": "https://tokenhub.tencentmaas.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "mimo", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "mimo-v2.6-flash", + "presets": ["mimo-v2.6-flash", "mimo-v2.6-pro"], + "base_url": "https://api.xiaomimimo.com/v1", + "default_base_url": "https://api.xiaomimimo.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "openrouter", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "google/gemini-3.5-flash-lite", + "presets": [ + "google/gemini-3.5-flash-lite", + "deepseek/deepseek-v4-flash", + "openai/gpt-6-luna" + ], + "base_url": "https://openrouter.ai/api/v1", + "default_base_url": "https://openrouter.ai/api/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, { "id": "ollama", "key": "none", @@ -2827,6 +3470,14 @@ "reason": "http_status", "status": 502 }, + { + "type": "provider_probe", + "provider": "openai", + "kind": "llm", + "result": "failed", + "reason": "unauthorized", + "detail": "Incorrect API key provided: sk-***. (invalid_api_key)" + }, { "type": "hardware", "cpu_threads": 8, diff --git a/packages/shared/src/fixtures/ipc/state.json b/packages/shared/src/fixtures/ipc/state.json index 7b401401..e3f463a4 100644 --- a/packages/shared/src/fixtures/ipc/state.json +++ b/packages/shared/src/fixtures/ipc/state.json @@ -277,6 +277,7 @@ "duration_ms": 900, "asr_ms": 410, "refine_failure": "rate_limited", + "refine_error": "服务繁忙:refine service overloaded (HTTP 503): This model is currently experiencing high demand.", "outcome": { "kind": "clipboard", "reason": "没有可粘贴的前台窗口" @@ -456,14 +457,8 @@ "on_device": false, "console": true, "llm": { - "model": "gemini-3.8-flash", - "presets": [ - "gemini-3.8-flash", - "gemini-3.5-flash-lite", - "gemini-3.1-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-pro" - ], + "model": "gemini-3.5-flash-lite", + "presets": ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-flash-lite"], "base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "default_base_url": "https://generativelanguage.googleapis.com/v1beta/openai", "key": { @@ -515,8 +510,6 @@ "presets": [ "qwen-audio-3.1-asr-flash-streaming", "qwen-audio-3.1-asr-flash", - "qwen-audio-3.1-asr-flash-message", - "qwen3-asr-flash", "fun-asr-realtime" ], "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", @@ -559,6 +552,142 @@ "active": false } }, + { + "id": "kimi", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "kimi-k3.1-pro-highspeed", + "presets": ["kimi-k3.1-pro-highspeed", "kimi-k3.1-pro", "kimi-k3"], + "base_url": "https://api.moonshot.cn/v1", + "default_base_url": "https://api.moonshot.cn/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://api.moonshot.cn/v1" + }, + { + "id": "intl", + "base_url": "https://api.moonshot.ai/v1" + } + ] + } + }, + { + "id": "glm", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "glm-5.3-flash", + "presets": ["glm-5.3-flash", "glm-4.7-flash", "glm-5.3"], + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "default_base_url": "https://open.bigmodel.cn/api/paas/v4", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false, + "sites": [ + { + "id": "cn", + "base_url": "https://open.bigmodel.cn/api/paas/v4" + }, + { + "id": "intl", + "base_url": "https://api.z.ai/api/paas/v4" + } + ] + } + }, + { + "id": "volcengine", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "doubao-seed-2-1-lite-260915", + "presets": [ + "doubao-seed-2-1-lite-260915", + "doubao-seed-2-1-turbo-260628", + "doubao-seed-2-0-mini-260428" + ], + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "default_base_url": "https://ark.cn-beijing.volces.com/api/v3", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "hunyuan", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "hy3", + "presets": ["hy3", "hy4-preview"], + "base_url": "https://tokenhub.tencentmaas.com/v1", + "default_base_url": "https://tokenhub.tencentmaas.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "mimo", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "mimo-v2.6-flash", + "presets": ["mimo-v2.6-flash", "mimo-v2.6-pro"], + "base_url": "https://api.xiaomimimo.com/v1", + "default_base_url": "https://api.xiaomimimo.com/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, + { + "id": "openrouter", + "key": "required", + "on_device": false, + "console": true, + "llm": { + "model": "google/gemini-3.5-flash-lite", + "presets": [ + "google/gemini-3.5-flash-lite", + "deepseek/deepseek-v4-flash", + "openai/gpt-6-luna" + ], + "base_url": "https://openrouter.ai/api/v1", + "default_base_url": "https://openrouter.ai/api/v1", + "key": { + "set": false, + "source": "none" + }, + "issue": "key_missing", + "active": false + } + }, { "id": "ollama", "key": "none", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 7642ffff..5023710b 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -227,8 +227,8 @@ export const en: Messages = { failureCode: { no_speech: "No speech detected", audio: "Microphone capture failed", - asr: "Speech recognition failed", - refine: "Polishing failed", + asr: "Speech recognition failed: {reason}", + refine: "Polishing failed: {reason}", inject: "Could not insert into the target window", no_selection: "Nothing is selected", selection_too_long: "The selection is too long (2,000 characters at most)", @@ -237,13 +237,30 @@ export const en: Messages = { edit_in_terminal: "Voice edit is off in terminals: a terminal's selection cannot be replaced", quota: "The model's quota is used up", }, + serviceReason: { + unauthorized: "the key was refused", + rateLimited: "too many requests", + rateLimitedRetry: "too many requests (try again in {s} s)", + overloaded: "the service is busy", + server: "server error (HTTP {status})", + network: "network error", + timeout: "the request timed out", + badResponse: "the service's answer could not be read", + empty: "the service returned nothing", + truncated: "the answer was longer than the output limit", + quota: "the quota is used up ({code})", + service: "the service returned error {code}", + config: "invalid settings", + audio: "the recording's format is not supported", + said: "{reason}: {said}", + }, /** Voice edit (docs/dictation.md §19): the phase line of an edit take. */ edit: { listening: "Listening for the edit… {elapsed}", refining: "Rewriting…", donePaste: "Replaced with {n} chars · {via}", doneClipboard: "Rewrite copied, {n} chars · {via}", - refineFailed: "Rewrite failed; the selection is unchanged", + refineFailed: "Rewrite failed; the selection is unchanged: {reason}", }, audioFailure: { system: "Computer audio could not be recorded", @@ -605,6 +622,19 @@ export const en: Messages = { openAccessibility: "Open Accessibility settings", details: "Technical details", }, + /** Under an entry whose clean-up request failed (docs/dictation.md §3.6): the kind in words, + * the service's own explanation under the technical details. */ + refineNote: { + rate_limited: + "The polish service is busy, so the transcript was used as it is. Try again later, or pick another model on the AI models page.", + quota: + "The model's quota is used up, so the transcript was used as it is. Pick another model or provider on the AI models page.", + failed: "The polish request failed, so the transcript was used as it is.", + /** Before the reason the core reported, the service's words included (§3.12). */ + reason: "Reason: ", + /** The phone's fact row of the same reason. */ + reasonLabel: "Why polish failed", + }, liveError: "Live transcription stopped; the whole recording was transcribed instead: {reason}", /** Voice edit rows (docs/dictation.md §19.5): instruction → result, the original expandable. */ edit: { @@ -785,6 +815,8 @@ export const en: Messages = { asr: "Transcription {n} ms", refine: "Polish {n} ms", refineOff: "Polish — · off", + /** A clean-up was asked for and its text was not used: why, as `dictation.refineFailure`. */ + refineFailed: "Polish — · {reason}", total: "Total {n} ms", }, confirm: { @@ -1056,6 +1088,7 @@ export const en: Messages = { custom: "OpenAI-compatible endpoint", }, detail: { connection: "Connection" }, + site: { cn: "China", intl: "International" }, provider: { builtin: "Built-in service", local: "This computer", @@ -1065,6 +1098,12 @@ export const en: Messages = { siliconflow: "SiliconFlow", aliyun: "Alibaba Cloud Model Studio", deepseek: "DeepSeek", + kimi: "Kimi", + glm: "Zhipu GLM", + volcengine: "Volcengine Ark", + hunyuan: "Tencent Hunyuan", + mimo: "Xiaomi MiMo", + openrouter: "OpenRouter", ollama: "Ollama", custom: "Custom endpoint", }, @@ -1079,6 +1118,14 @@ export const en: Messages = { aliyun: "Model Studio's speech recognition and Qwen models; its realtime models transcribe while you speak.", deepseek: "DeepSeek's chat models, for polish only.", + kimi: "Moonshot AI's Kimi models, for polish only; switch between the China and international sites, whose accounts and keys are separate.", + glm: "Zhipu's GLM models, for polish only; the China site is Zhipu's BigModel platform, the international one Z.ai, and their accounts and keys are separate.", + volcengine: + "Doubao and other models on Volcengine Ark, for polish only; enable a model under Model Activation in the Ark console first.", + hunyuan: + "Hunyuan and other models on Tencent Cloud TokenHub, for polish only; enable a model in the TokenHub console first.", + mimo: "Xiaomi's MiMo models, for polish only.", + openrouter: "Many vendors' models with one key, for polish only.", ollama: "Models in Ollama on this computer; text never leaves it.", custom: "Any OpenAI-compatible endpoint: vLLM, LocalAI or your own gateway.", }, @@ -1115,6 +1162,9 @@ export const en: Messages = { baseUrl: "Endpoint", baseUrlHelpVendor: "Leave empty for the official endpoint; fill in only for a proxy or relay.", + site: "Site", + siteHelp: + "The two sites have separate accounts and keys; use a key made on the selected site.", baseUrlHelpCustom: "An OpenAI-compatible endpoint, e.g. http://192.168.1.20:8000/v1; an Alibaba Cloud Model Studio address works too.", baseUrlHelpAliyun: @@ -1156,6 +1206,8 @@ export const en: Messages = { timeout: "The connection timed out", http_status: "The service returned HTTP {status}", bad_response: "The response is not a model list", + /** A failed probe with what the service said (docs/dictation.md §3.12). */ + withDetail: "{result}: {detail}", }, check: { badUrl: "The endpoint must start with http:// or https://", @@ -1877,7 +1929,7 @@ export const en: Messages = { guideRun: "Open the guide", autoUpdate: "Automatic updates", autoUpdateHelp: - "Check for updates at launch and download in the background; when off, the app only goes online when you press “Check for updates”.", + "Checks for updates 10 seconds after launch and every 4 hours while Voltip runs, and downloads a new version in the background; choose “Restart to update” in the title bar to install it. When off, Voltip goes online only when you press “Check for updates”.", checkUpdate: "Check for updates", checking: "Checking…", installNow: "Update now", @@ -2698,6 +2750,7 @@ export const en: Messages = { ready: "Restart to update", }, badgeTitle: "Show the update", + badgeRestartTitle: "Restart Voltip to finish updating to {version}", }, feedback: { title: "Feedback", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 0f38c77b..d08e0c09 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -236,8 +236,8 @@ export const zhCN = { failureCode: { no_speech: "没有听到声音", audio: "麦克风采集失败", - asr: "识别服务出错", - refine: "润色失败", + asr: "识别服务出错:{reason}", + refine: "润色失败:{reason}", inject: "未能插入到目标窗口", no_selection: "没有选中文本", selection_too_long: "选中文本过长(上限 2000 字)", @@ -246,13 +246,32 @@ export const zhCN = { edit_in_terminal: "终端里不支持语音编辑:终端里的选区不能被替换", quota: "模型额度已用完", }, + /** docs/dictation.md §3.12: the recognition and polish clients' own words before what the + * service said (`serviceReasonText`); the service's words follow as they are. */ + serviceReason: { + unauthorized: "密钥被拒绝", + rateLimited: "请求过于频繁", + rateLimitedRetry: "请求过于频繁({s} 秒后可重试)", + overloaded: "服务繁忙", + server: "服务出错(HTTP {status})", + network: "网络错误", + timeout: "请求超时", + badResponse: "无法读取服务的答复", + empty: "服务没有返回内容", + truncated: "结果超出输出长度上限", + quota: "额度已用完({code})", + service: "服务返回错误 {code}", + config: "设置无效", + audio: "录音格式不受支持", + said: "{reason}:{said}", + }, /** Voice edit (docs/dictation.md §19): the phase line of an edit take. */ edit: { listening: "正在听编辑指令… {elapsed}", refining: "改写中…", donePaste: "已替换 {n} 字 · {via}", doneClipboard: "改写结果已复制 {n} 字 · {via}", - refineFailed: "改写失败,选中文本未改动", + refineFailed: "改写失败,选中文本未改动:{reason}", }, /** docs/dictation.md §22: the `audio` failure of a take that records more than the microphone. */ audioFailure: { @@ -613,6 +632,17 @@ export const zhCN = { openAccessibility: "打开辅助功能设置", details: "技术细节", }, + /** Under an entry whose clean-up request failed (docs/dictation.md §3.6): the kind in words, + * the service's own explanation under the technical details. */ + refineNote: { + rate_limited: "润色服务繁忙,本次使用了识别原文。可以稍后再试,或在「AI 模型」页换一个模型。", + quota: "模型额度已用完,本次使用了识别原文。可以在「AI 模型」页换一个模型或服务商。", + failed: "润色请求失败,本次使用了识别原文。", + /** Before the reason the core reported, the service's words included (§3.12). */ + reason: "原因:", + /** The phone's fact row of the same reason. */ + reasonLabel: "润色失败原因", + }, liveError: "实时识别中断,已改为整段识别:{reason}", /** Voice edit rows (docs/dictation.md §19.5): instruction → result, the original expandable. */ edit: { @@ -793,6 +823,8 @@ export const zhCN = { asr: "识别 {n} ms", refine: "润色 {n} ms", refineOff: "润色 — · 未启用", + /** A clean-up was asked for and its text was not used: why, as `dictation.refineFailure`. */ + refineFailed: "润色 — · {reason}", total: "总计 {n} ms", }, confirm: { @@ -1060,6 +1092,8 @@ export const zhCN = { custom: "OpenAI 兼容接口", }, detail: { connection: "连接" }, + /** docs/dictation.md §3.2: a vendor's two sites. */ + site: { cn: "中国站", intl: "国际站" }, provider: { builtin: "内置服务", local: "本机", @@ -1069,6 +1103,12 @@ export const zhCN = { siliconflow: "硅基流动", aliyun: "阿里云百炼", deepseek: "DeepSeek", + kimi: "Kimi", + glm: "智谱 GLM", + volcengine: "火山方舟", + hunyuan: "腾讯混元", + mimo: "小米 MiMo", + openrouter: "OpenRouter", ollama: "Ollama", custom: "自定义接口", }, @@ -1082,6 +1122,12 @@ export const zhCN = { siliconflow: "硅基流动托管的 SenseVoice 与开源大模型。", aliyun: "阿里云百炼的语音识别与千问大模型;实时识别模型在说话时就完成识别。", deepseek: "DeepSeek 的对话模型,只用于润色。", + kimi: "月之暗面的 Kimi 模型,只用于润色;可切换中国站与国际站,两个站点的账号和密钥不通用。", + glm: "智谱的 GLM 模型,只用于润色;中国站为智谱开放平台,国际站为 Z.ai,两个站点的账号和密钥不通用。", + volcengine: "火山方舟的豆包等模型,只用于润色;模型需先在方舟控制台的「开通管理」中开通。", + hunyuan: "腾讯云 TokenHub 上的混元等模型,只用于润色;模型需先在 TokenHub 控制台开通。", + mimo: "小米的 MiMo 模型,只用于润色。", + openrouter: "用一个密钥使用多家厂商的模型,只用于润色。", ollama: "本机 Ollama 里的模型,文本不离开这台电脑。", custom: "任意 OpenAI 兼容接口,例如 vLLM、LocalAI 或自建网关。", }, @@ -1115,6 +1161,8 @@ export const zhCN = { reasoningHelp: "只随 Responses 请求发送;不设置时使用服务的默认值。", baseUrl: "接口地址", baseUrlHelpVendor: "留空使用官方地址;需要代理或中转时再填写。", + site: "站点", + siteHelp: "两个站点的账号和密钥不通用,请使用所选站点创建的密钥。", baseUrlHelpCustom: "OpenAI 兼容地址,例如 http://192.168.1.20:8000/v1;也可以填写阿里云百炼的地址。", baseUrlHelpAliyun: "留空使用公共地址;使用业务空间的专属地址时在此填写。", @@ -1155,6 +1203,8 @@ export const zhCN = { timeout: "连接超时", http_status: "服务返回错误(HTTP {status})", bad_response: "返回内容不是模型列表", + /** A failed probe with what the service said (docs/dictation.md §3.12). */ + withDetail: "{result}:{detail}", }, check: { badUrl: "接口地址须以 http:// 或 https:// 开头", @@ -1848,7 +1898,8 @@ export const zhCN = { "逐步检查权限、快捷键和语音模型,再试说一句。默认设置开箱即用,首次启动不再自动打开。", guideRun: "打开向导", autoUpdate: "自动更新", - autoUpdateHelp: "启动时检查更新并在后台下载;关闭后仅在点击「检查更新」时联网。", + autoUpdateHelp: + "启动 10 秒后检查更新,运行期间每 4 小时再检查一次,有新版本就在后台下载;下载完成后,点击标题栏的「重启以更新」即可完成安装。关闭后,仅在点击「检查更新」时联网。", checkUpdate: "检查更新", checking: "检查中…", installNow: "立即更新", @@ -2626,6 +2677,7 @@ export const zhCN = { ready: "重启以更新", }, badgeTitle: "查看更新", + badgeRestartTitle: "重启 Voltip,完成更新到 {version}", }, feedback: { title: "反馈", diff --git a/packages/shared/src/labels.test.ts b/packages/shared/src/labels.test.ts index 28c5196a..4ec1615c 100644 --- a/packages/shared/src/labels.test.ts +++ b/packages/shared/src/labels.test.ts @@ -1,5 +1,6 @@ import { notRefinedText, + serviceReasonText, refineModelText, activationChip, activationDescription, @@ -80,6 +81,106 @@ describe("coreMessageText", () => { }); }); +describe("serviceReasonText", () => { + // Regression (2026-10-25): a refusal showed the clients' own English words in a Chinese line + // (「识别服务出错:ASR rejected the credentials: …」), an internal term the interface never uses. + // The strings are the `Display` of crates/voltip-asr and crates/voltip-refine `src/error.rs`. + it("regression: the clients' words are the interface's, the service's words stay as they are", () => { + const cases: [string, string, string][] = [ + [ + "ASR rejected the credentials: Invalid API Key (invalid_api_key)", + "密钥被拒绝:Invalid API Key (invalid_api_key)", + "the key was refused: Invalid API Key (invalid_api_key)", + ], + ["ASR rejected the credentials", "密钥被拒绝", "the key was refused"], + [ + "refine service rejected the credentials: bad key", + "密钥被拒绝:bad key", + "the key was refused: bad key", + ], + [ + "请求过于频繁:refine service rate limited (retry after 355000 ms)", + "请求过于频繁(355 秒后可重试)", + "too many requests (try again in 355 s)", + ], + [ + "ASR rate limited (retry after 1200 ms): Rate limit reached", + "请求过于频繁(2 秒后可重试):Rate limit reached", + "too many requests (try again in 2 s): Rate limit reached", + ], + ["ASR rate limited", "请求过于频繁", "too many requests"], + [ + "服务繁忙:refine service overloaded (HTTP 503): This model is currently experiencing high demand. (UNAVAILABLE)", + "服务繁忙:This model is currently experiencing high demand. (UNAVAILABLE)", + "the service is busy: This model is currently experiencing high demand. (UNAVAILABLE)", + ], + [ + "ASR server error 502: 502 Bad Gateway", + "服务出错(HTTP 502):502 Bad Gateway", + "server error (HTTP 502): 502 Bad Gateway", + ], + ["refine server error 500", "服务出错(HTTP 500)", "server error (HTTP 500)"], + [ + "refine: refine network error: error sending request", + "网络错误:error sending request", + "network error: error sending request", + ], + ["ASR request timed out", "请求超时", "the request timed out"], + ["refine request timed out", "请求超时", "the request timed out"], + [ + "ASR returned an unexpected response: missing text", + "无法读取服务的答复:missing text", + "the service's answer could not be read: missing text", + ], + [ + "refine service returned an empty answer", + "服务没有返回内容", + "the service returned nothing", + ], + [ + "refine answer was cut off at the output limit", + "结果超出输出长度上限", + "the answer was longer than the output limit", + ], + [ + "额度已用完:refine quota used up (insufficient_quota): You exceeded your current quota", + "额度已用完(insufficient_quota):You exceeded your current quota", + "the quota is used up (insufficient_quota): You exceeded your current quota", + ], + [ + "ASR service error InvalidParameter: the audio is too long", + "服务返回错误 InvalidParameter:the audio is too long", + "the service returned error InvalidParameter: the audio is too long", + ], + [ + "invalid ASR configuration: empty model", + "设置无效:empty model", + "invalid settings: empty model", + ], + [ + "ASR audio: not a 16-bit PCM WAV", + "录音格式不受支持:not a 16-bit PCM WAV", + "the recording's format is not supported: not a 16-bit PCM WAV", + ], + ]; + for (const [reason, zh, en] of cases) { + expect(serviceReasonText(reason)).toBe(zh); + expect(serviceReasonText(reason, "en")).toBe(en); + } + }); + + it("shows any other reason as it came, without its machine prefix", () => { + expect(serviceReasonText("refine: 润色服务配置无效:地址不是 http(s)")).toBe( + "润色服务配置无效:地址不是 http(s)", + ); + expect(serviceReasonText("请求过于频繁:mock: the clean-up was turned down")).toBe( + "请求过于频繁:mock: the clean-up was turned down", + ); + expect(serviceReasonText("429 rate limited", "en")).toBe("429 rate limited"); + expect(serviceReasonText("")).toBe(""); + }); +}); + describe("hotkeyMethodText", () => { it("regression: the shortcut method names the system and the Linux session, not the library or the system call", () => { // The shell's table (apps/desktop/src-tauri/src/hotkey.rs backend_name_for). @@ -633,8 +734,19 @@ describe("voice edit labels (section 19)", () => { expect( takeFailureText(edit(failed("selection", "selection: keystroke: no copy tool on Wayland"))), ).toBe("读取选中文本失败:keystroke: no copy tool on Wayland"); - expect(takeFailureText(edit(failed("refine", "refine: 429")))).toBe("改写失败,选中文本未改动"); - expect(dictationFailureText(failed("refine", "refine: 429"))).toBe("润色失败"); + // Section 3.12: a rewrite or a recognition the service refused says what the service said. + expect(takeFailureText(edit(failed("refine", "refine: 429")))).toBe( + "改写失败,选中文本未改动:429", + ); + expect(dictationFailureText(failed("refine", "refine: 429"))).toBe("润色失败:429"); + expect( + dictationFailureText( + failed( + "asr", + "asr: ASR rejected the credentials: Incorrect API key provided (invalid_api_key)", + ), + ), + ).toBe("识别服务出错:密钥被拒绝:Incorrect API key provided (invalid_api_key)"); expect(takePhaseLabel(edit(failed("no_selection")), now)).toEqual({ text: "失败 · 没有选中文本", tone: "danger", @@ -648,7 +760,7 @@ describe("voice edit labels (section 19)", () => { "Voice edit is off in terminals: a terminal's selection cannot be replaced", ); expect(takeFailureText(edit(failed("refine")), "en")).toBe( - "Rewrite failed; the selection is unchanged", + "Rewrite failed; the selection is unchanged: x", ); expect(takeFailureText(edit(failed("selection", "selection: clipboard: busy")), "en")).toBe( "Could not read the selection: clipboard: busy", diff --git a/packages/shared/src/labels.ts b/packages/shared/src/labels.ts index b5794df1..2e606014 100644 --- a/packages/shared/src/labels.ts +++ b/packages/shared/src/labels.ts @@ -357,14 +357,18 @@ function failureText( ): string { const code = phase.code; if (code === undefined || code === "unknown") return phase.message; - if (kind === "edit" && code === "refine") return translate(locale, "dictation.edit.refineFailed"); // docs/dictation.md §22: what failed to record is not always the microphone. if (code === "audio" && (source === "system" || source === "mixed")) return translate(locale, `dictation.audioFailure.${source}`); const prefix = `${code}: `; - const reason = phase.message.startsWith(prefix) + // §3.12: the reason carries what the service said (a recognition or rewrite refusal), after the + // clients' own words in the interface's language. + const message = phase.message.startsWith(prefix) ? phase.message.slice(prefix.length) : phase.message; + const reason = code === "asr" || code === "refine" ? serviceReasonText(message, locale) : message; + if (kind === "edit" && code === "refine") + return translate(locale, "dictation.edit.refineFailed", { reason }); return translate(locale, `dictation.failureCode.${code}`, { reason }); } @@ -703,6 +707,108 @@ export function coreMessageText(message: string): string { return message.replace(MACHINE_PREFIX, ""); } +type ServiceReasonKey = + | "unauthorized" + | "rateLimited" + | "rateLimitedRetry" + | "overloaded" + | "server" + | "network" + | "timeout" + | "badResponse" + | "empty" + | "truncated" + | "quota" + | "service" + | "config" + | "audio"; + +/** The recognition and polish clients' own words (`crates/voltip-asr/src/error.rs`, + * `crates/voltip-refine/src/error.rs`, keep them in step): which phrase each opening is, the + * parameters of that phrase, and what the service said after it. */ +const SERVICE_REASONS: readonly { + pattern: RegExp; + read: (m: RegExpExecArray) => { + key: ServiceReasonKey; + params?: Record<string, string>; + said?: string; + }; +}[] = [ + { + pattern: /^(?:ASR|refine service) rejected the credentials(?:: ([\s\S]*))?$/, + read: (m) => ({ key: "unauthorized", said: m[1] }), + }, + { + pattern: /^(?:ASR|refine service) rate limited(?: \(retry after (\d+) ms\))?(?:: ([\s\S]*))?$/, + read: (m) => + m[1] === undefined + ? { key: "rateLimited", said: m[2] } + : { + key: "rateLimitedRetry", + params: { s: String(Math.max(1, Math.ceil(Number(m[1]) / 1000))) }, + said: m[2], + }, + }, + { + pattern: /^refine service overloaded \(HTTP 503\)(?:: ([\s\S]*))?$/, + read: (m) => ({ key: "overloaded", said: m[1] }), + }, + { + pattern: /^(?:ASR|refine) server error (\d{3})(?:: ([\s\S]*))?$/, + read: (m) => ({ key: "server", params: { status: m[1] ?? "" }, said: m[2] }), + }, + { + pattern: /^(?:ASR|refine) network error: ([\s\S]*)$/, + read: (m) => ({ key: "network", said: m[1] }), + }, + { pattern: /^(?:ASR|refine) request timed out$/, read: () => ({ key: "timeout" }) }, + { + pattern: /^(?:ASR|refine service) returned an unexpected response: ([\s\S]*)$/, + read: (m) => ({ key: "badResponse", said: m[1] }), + }, + { pattern: /^refine service returned an empty answer$/, read: () => ({ key: "empty" }) }, + { + pattern: /^refine answer was cut off at the output limit$/, + read: () => ({ key: "truncated" }), + }, + { + pattern: /^(?:ASR|refine) quota used up \(([^)]*)\): ([\s\S]*)$/, + read: (m) => ({ key: "quota", params: { code: m[1] ?? "" }, said: m[2] }), + }, + { + pattern: /^ASR service error (\S+): ([\s\S]*)$/, + read: (m) => ({ key: "service", params: { code: m[1] ?? "" }, said: m[2] }), + }, + { + pattern: /^invalid (?:ASR|refine) configuration: ([\s\S]*)$/, + read: (m) => ({ key: "config", said: m[1] }), + }, + { pattern: /^ASR audio: ([\s\S]*)$/, read: (m) => ({ key: "audio", said: m[1] }) }, +]; + +/** The core's own name for the kind of a refusal, before the client's words (`DictationError`'s + * `RateLimited`, `Overloaded` and `QuotaExhausted`); the client's words name the kind again. */ +const CORE_KIND_PREFIX = /^(?:请求过于频繁|服务繁忙|额度已用完):/; + +/** Why a recognition or a polish request failed, as the interface shows it (docs/dictation.md + * §3.12): the clients' own English words (`ASR rejected the credentials`) in the interface's + * language, and after them what the service said, as it said it. Text in any other shape is shown + * as it came, without its machine prefix. */ +export function serviceReasonText(reason: string, locale: Locale = DEFAULT_LOCALE): string { + const text = coreMessageText(reason); + const words = text.replace(CORE_KIND_PREFIX, ""); + for (const { pattern, read } of SERVICE_REASONS) { + const m = pattern.exec(words); + if (m === null) continue; + const { key, params, said } = read(m); + const label = translate(locale, `dictation.serviceReason.${key}`, params); + return said === undefined || said.length === 0 + ? label + : translate(locale, "dictation.serviceReason.said", { reason: label, said }); + } + return text; +} + /** The system calls a shortcut backend goes through: implementation, never shown. */ const HOTKEY_SYSTEM_CALLS: ReadonlySet<string> = new Set(["RegisterHotKey", "Carbon"]); diff --git a/packages/shared/src/mock-backend.test.ts b/packages/shared/src/mock-backend.test.ts index eaafc3cf..7c81f594 100644 --- a/packages/shared/src/mock-backend.test.ts +++ b/packages/shared/src/mock-backend.test.ts @@ -567,7 +567,11 @@ describe("MockBackend dictation pipeline (docs/dictation.md §2)", () => { refine_error: MOCK_REFINE_FAILED, }); const entry = backend.peek().history_recent[0]; - expect(entry).toMatchObject({ refined: false, refine_failure: "rate_limited" }); + expect(entry).toMatchObject({ + refined: false, + refine_failure: "rate_limited", + refine_error: MOCK_REFINE_FAILED, + }); expect(entry?.refine_model).toBeUndefined(); expect(entry?.preset).toBeUndefined(); }); @@ -1177,6 +1181,12 @@ describe("MockBackend history, engines and secrets", () => { "siliconflow", "aliyun", "deepseek", + "kimi", + "glm", + "volcengine", + "hunyuan", + "mimo", + "openrouter", "ollama", "custom", ]); diff --git a/packages/shared/src/mock-backend.ts b/packages/shared/src/mock-backend.ts index f53da865..f76b7013 100644 --- a/packages/shared/src/mock-backend.ts +++ b/packages/shared/src/mock-backend.ts @@ -31,6 +31,7 @@ import { type ArgsOf, type CommandArgs, type ConsolePage, + type SiteId, type DeviceConnection, type DeviceIdentityPublic, type DeviceInfo, @@ -984,6 +985,8 @@ export class MockBackend implements Backend { private readonly quotaOut = new Map<ServiceKind, number>(); /** Every `providerConsoleOpen` of a key page, in order (tests). */ readonly consolesOpened: ProviderId[] = []; + /** The site of each of `consolesOpened`, `undefined` for none (tests). */ + readonly consoleSites: (SiteId | undefined)[] = []; /** Every `providerConsoleOpen` of a quota page, in order (tests). */ readonly quotaPagesOpened: ProviderId[] = []; /** How many `provider_models_refresh` requests left (tests). */ @@ -3326,7 +3329,11 @@ export class MockBackend implements Backend { // ---- providers (docs/dictation.md §3.3) ---------------------------------------------------------- - providerConsoleOpen(provider: ProviderId, page: ConsolePage = "key"): Promise<void> { + providerConsoleOpen( + provider: ProviderId, + page: ConsolePage = "key", + site?: SiteId, + ): Promise<void> { const spec = providerSpec(provider); if (page === "quota") { if (spec.quota !== true) return Promise.reject(new Error(`${provider}: no quota page`)); @@ -3335,6 +3342,7 @@ export class MockBackend implements Backend { } if (!spec.console) return Promise.reject(new Error(`${provider}: no key page`)); this.consolesOpened.push(provider); + this.consoleSites.push(site); return Promise.resolve(); } @@ -4066,6 +4074,12 @@ export class MockBackend implements Backend { asr_model: engines.asr_model, ...(polished ? { refine_model: engines.refine_model } : {}), ...(refineFailure === undefined ? {} : { refine_failure: refineFailure }), + // As the core: a failed request keeps the service's words (§3.6). + ...(refineFailure === "rate_limited" || + refineFailure === "quota" || + refineFailure === "failed" + ? { refine_error: MOCK_REFINE_FAILED } + : {}), duration_ms: durationMs, asr_ms: asrMs, outcome: { kind: "inserted", via }, diff --git a/packages/shared/src/provider-marks.test.ts b/packages/shared/src/provider-marks.test.ts index 31cdd027..f7de4d27 100644 --- a/packages/shared/src/provider-marks.test.ts +++ b/packages/shared/src/provider-marks.test.ts @@ -25,6 +25,6 @@ describe("provider marks (docs/dictation.md §3)", () => { .filter(([, mark]) => mark.paths.every((p) => p.fill === undefined)) .map(([id]) => id); mono.sort(); - expect(mono).toEqual(["google", "groq", "ollama", "openai"]); + expect(mono).toEqual(["google", "groq", "kimi", "mimo", "ollama", "openai", "openrouter"]); }); }); diff --git a/packages/shared/src/provider-marks.ts b/packages/shared/src/provider-marks.ts index 5b17cf99..1ba390ff 100644 --- a/packages/shared/src/provider-marks.ts +++ b/packages/shared/src/provider-marks.ts @@ -2,8 +2,10 @@ // (https://github.com/lobehub/lobe-icons, MIT, Copyright (c) 2023 LobeHub; the notice travels with // every package, scripts/release/third-party-notices.py). Each mark is the paths of one of the // set's 24×24 static SVGs at commit bcdb7c8 (openai, groq, aistudio, siliconcloud-color, -// bailian-color, deepseek-color, ollama), copied as they are; a path without `fill` takes the -// colour of the text around it, so the monochrome marks follow the theme. Bundled, not fetched: +// bailian-color, deepseek-color, kimi, zhipu-color, volcengine-color, hunyuan-color, xiaomimimo, +// openrouter, ollama), copied as they are, hunyuan-color's background circle written as a path; a +// path without `fill` takes the colour of the text around it, so the monochrome marks follow the +// theme (Kimi's own colours draw a white letter, which would vanish on a light background). Bundled, not fetched: // the pages show them offline and ask no third party for anything. The built-in, on-device and // custom providers keep the app's own icons. import type { ProviderId } from "./schema"; @@ -105,6 +107,89 @@ export const PROVIDER_MARKS: Readonly<Partial<Record<ProviderId, ProviderMark>>> }, ], }, + kimi: { + title: "Kimi", + paths: [ + { + d: "M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z", + evenOdd: true, + }, + { + d: "M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z", + evenOdd: true, + }, + ], + }, + glm: { + title: "Zhipu", + paths: [ + { + d: "M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z", + fill: "#3859FF", + }, + ], + }, + volcengine: { + title: "Volcengine", + paths: [ + { + d: "M19.44 10.153l-2.936 11.586a.215.215 0 00.214.261h5.87a.215.215 0 00.214-.261l-2.95-11.586a.214.214 0 00-.412 0zM3.28 12.778l-2.275 8.96A.214.214 0 001.22 22h4.532a.212.212 0 00.214-.165.214.214 0 000-.097l-2.276-8.96a.214.214 0 00-.41 0z", + fill: "#00E5E5", + }, + { + d: "M7.29 5.359L3.148 21.738a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.358a.214.214 0 00-.41 0z", + fill: "#006EFF", + }, + { + d: "M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.216.216 0 00.171-.078.214.214 0 00.044-.183L14.439.15z", + fill: "#006EFF", + }, + { + d: "M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.215.215 0 00.214-.264L10.688 7.741a.214.214 0 00-.41 0z", + fill: "#00E5E5", + }, + ], + }, + hunyuan: { + title: "Hunyuan", + paths: [ + { d: "M0 12a12 12 0 1 0 24 0a12 12 0 1 0 -24 0z", fill: "#0055E9" }, + { + d: "M12 0c.518 0 1.028.033 1.528.096A6.188 6.188 0 0112.12 12.28l-.12.001c-2.99 0-5.242 2.179-5.554 5.11-.223 2.086.353 4.412 2.242 6.146C3.672 22.1 0 17.479 0 12 0 5.373 5.373 0 12 0z", + fill: "#A8DFF5", + }, + { + d: "M5.286 5a2.438 2.438 0 01.682 3.38c-3.962 5.966-3.215 10.743 2.648 15.136C3.636 22.056 0 17.452 0 12c0-1.787.39-3.482 1.09-5.006.253-.435.525-.872.817-1.311A2.438 2.438 0 015.286 5z", + fill: "#0055E9", + }, + { + d: "M12.98.04c.272.021.543.053.81.093.583.106 1.117.254 1.538.44 6.638 2.927 8.07 10.052 1.748 15.642a4.125 4.125 0 01-5.822-.358c-1.51-1.706-1.3-4.184.357-5.822.858-.848 3.108-1.223 4.045-2.441 1.257-1.634 2.122-6.009-2.523-7.506L12.98.039z", + fill: "#00BCFF", + }, + { + d: "M13.528.096A6.187 6.187 0 0112 12.281a5.75 5.75 0 00-1.71.255c.147-.905.595-1.784 1.321-2.501.858-.848 3.108-1.223 4.045-2.441 1.27-1.651 2.14-6.104-2.676-7.554.184.014.367.033.548.056z", + fill: "#ECECEE", + }, + ], + }, + mimo: { + title: "Xiaomi MiMo", + paths: [ + { + d: "M.958 15.936a.459.459 0 01.459.44v2.729a.46.46 0 01-.918 0v-2.729a.459.459 0 01.459-.44zm4.814-2.035a.46.46 0 01.553.45v4.754a.458.458 0 11-.918 0V15.48L3.74 17.202a.462.462 0 01-.655.016.462.462 0 01-.065-.082L.628 14.67a.459.459 0 01.658-.637l2.124 2.187 2.127-2.188a.46.46 0 01.235-.13zm2.068.004a.46.46 0 01.458.445v4.755a.46.46 0 01-.458.458.459.459 0 01-.458-.458V14.35a.459.459 0 01.458-.445zm1.973 2.014a.46.46 0 01.46.457v2.729a.46.46 0 01-.784.324.46.46 0 01-.134-.324v-2.729a.46.46 0 01.458-.458zm.002-2.045a.458.458 0 01.328.157l2.127 2.19 2.125-2.19a.459.459 0 01.784.318v4.756a.46.46 0 01-.455.458.46.46 0 01-.458-.458V15.48l-1.667 1.723a.46.46 0 01-.65.008l-.005-.005c0-.002-.002-.002-.004-.003l-2.455-2.534a.46.46 0 01-.008-.667.461.461 0 01.338-.128zm6.797 1.206a.46.46 0 01.53.651A1.966 1.966 0 0019.81 18.4a.462.462 0 01.623.18.46.46 0 01-.181.624 2.863 2.863 0 01-1.38.353l-.142-.004a2.88 2.88 0 01-2.393-4.263.461.461 0 01.274-.21zm.864-.931a2.884 2.884 0 013.915 3.914.46.46 0 01-.402.24l-.057-.004a.458.458 0 01-.164-.055.46.46 0 01-.182-.622 1.967 1.967 0 00-2.669-2.67.459.459 0 11-.441-.803zM9.59 6.368c1.481 0 1.696 1.202 1.696 1.654v2.648h-.917v-.432c-.26.346-.792.535-1.36.535-.133 0-1.289-.03-1.384-1.136-.082-.932.675-1.61 2.053-1.61h.691c0-.563-.367-.886-.983-.886-.44.013-.864.174-1.2.458l-.36-.664c.484-.379 1.012-.567 1.764-.567zm4.427.1c1.263 0 2.082.97 2.083 2.15 0 1.181-.824 2.154-2.083 2.154-1.26 0-2.084-.972-2.084-2.152 0-1.18.82-2.153 2.084-2.153zm6.801.015c.68 0 1.202.465 1.197 1.548v2.642H21.1V8.29c0-.312-.002-.98-.63-.98s-.628.667-.628.838v2.524h-.89V8.148c0-.17-.001-.838-.63-.838-.628 0-.628.668-.628.98v2.383h-.917v-4.03h.917V7a1.22 1.22 0 01.947-.516c.398 0 .76.193.982.686a1.321 1.321 0 011.195-.686zm-18.093.872l1.457-1.772H5.32L3.311 8.07l2.14 2.602H4.24L2.725 8.796 1.21 10.672H0L2.138 8.07.13 5.583h1.138l1.458 1.772zm4.149 3.317h-.916V6.644h.916v4.028zm16.99 0h-.916V6.644h.916v4.028zM9.925 8.71c-1.055 0-1.359.412-1.326.742.032.329.324.537.757.537a1.013 1.013 0 001.014-.968l.002-.31h-.447zM14.018 7.3c-.663 0-1.184.487-1.184 1.32 0 .832.52 1.32 1.184 1.32.662 0 1.182-.49 1.182-1.32 0-.832-.52-1.32-1.182-1.32zM6.417 5.001a.568.568 0 01.587.582.588.588 0 01-1.175 0A.57.57 0 016.417 5zm16.991 0a.57.57 0 01.592.582.588.588 0 01-1.174 0 .57.57 0 01.357-.542.572.572 0 01.225-.04z", + evenOdd: true, + }, + ], + }, + openrouter: { + title: "OpenRouter", + paths: [ + { + d: "M18.654 3.87a5.087 5.087 0 110 10.174L23.7 19.09c.64.641.187 1.737-.72 1.737H8.48a8.479 8.479 0 010-16.958h10.175zM8.479 7.26a5.087 5.087 0 100 10.176 5.087 5.087 0 000-10.175z", + evenOdd: true, + }, + ], + }, ollama: { title: "Ollama", paths: [ diff --git a/packages/shared/src/providers.ts b/packages/shared/src/providers.ts index 324c1ca9..386f2221 100644 --- a/packages/shared/src/providers.ts +++ b/packages/shared/src/providers.ts @@ -15,14 +15,18 @@ import type { SecretState, ServiceKind, ServiceStatus, + SiteId, LiveSource, } from "./schema"; import { PROVIDER_IDS } from "./schema"; -/** A service a vendor offers: its public base URL and the models suggested first. */ +/** A service a vendor offers: its public base URL, the models suggested first and, for a vendor + * with a Chinese and an international site, both sites (the default first, docs/dictation.md + * §3.2). */ export interface ServicePreset { baseUrl: string; models: readonly string[]; + sites?: readonly { id: SiteId; baseUrl: string }[]; } export interface ProviderSpec { @@ -79,13 +83,7 @@ const SPECS: Readonly<Record<ProviderId, Omit<ProviderSpec, "id">>> = { google: { llm: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", - models: [ - "gemini-3.8-flash", - "gemini-3.5-flash-lite", - "gemini-3.1-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-pro", - ], + models: ["gemini-3.5-flash-lite", "gemini-3.8-flash", "gemini-3.1-flash-lite"], }, key: "required", onDevice: false, @@ -110,8 +108,6 @@ const SPECS: Readonly<Record<ProviderId, Omit<ProviderSpec, "id">>> = { models: [ "qwen-audio-3.1-asr-flash-streaming", "qwen-audio-3.1-asr-flash", - "qwen-audio-3.1-asr-flash-message", - "qwen3-asr-flash", "fun-asr-realtime", ], }, @@ -133,6 +129,66 @@ const SPECS: Readonly<Record<ProviderId, Omit<ProviderSpec, "id">>> = { onDevice: false, console: true, }, + kimi: { + llm: { + baseUrl: "https://api.moonshot.cn/v1", + models: ["kimi-k3.1-pro-highspeed", "kimi-k3.1-pro", "kimi-k3"], + sites: [ + { id: "cn", baseUrl: "https://api.moonshot.cn/v1" }, + { id: "intl", baseUrl: "https://api.moonshot.ai/v1" }, + ], + }, + key: "required", + onDevice: false, + console: true, + }, + glm: { + llm: { + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + models: ["glm-5.3-flash", "glm-4.7-flash", "glm-5.3"], + sites: [ + { id: "cn", baseUrl: "https://open.bigmodel.cn/api/paas/v4" }, + { id: "intl", baseUrl: "https://api.z.ai/api/paas/v4" }, + ], + }, + key: "required", + onDevice: false, + console: true, + }, + volcengine: { + llm: { + baseUrl: "https://ark.cn-beijing.volces.com/api/v3", + models: [ + "doubao-seed-2-1-lite-260915", + "doubao-seed-2-1-turbo-260628", + "doubao-seed-2-0-mini-260428", + ], + }, + key: "required", + onDevice: false, + console: true, + }, + hunyuan: { + llm: { baseUrl: "https://tokenhub.tencentmaas.com/v1", models: ["hy3", "hy4-preview"] }, + key: "required", + onDevice: false, + console: true, + }, + mimo: { + llm: { baseUrl: "https://api.xiaomimimo.com/v1", models: ["mimo-v2.6-flash", "mimo-v2.6-pro"] }, + key: "required", + onDevice: false, + console: true, + }, + openrouter: { + llm: { + baseUrl: "https://openrouter.ai/api/v1", + models: ["google/gemini-3.5-flash-lite", "deepseek/deepseek-v4-flash", "openai/gpt-6-luna"], + }, + key: "required", + onDevice: false, + console: true, + }, ollama: { llm: { baseUrl: "http://127.0.0.1:11434/v1", models: [] }, key: "none", @@ -343,6 +399,9 @@ function serviceStatus( ...("issue" in resolved ? { issue: resolved.issue } : {}), active, ...(listed.length === 0 ? {} : { listed: [...listed] }), + ...(preset.sites === undefined + ? {} + : { sites: preset.sites.map((site) => ({ id: site.id, base_url: site.baseUrl })) }), }; } diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 6bc642f0..afcb6d64 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -30,8 +30,8 @@ export const injectModeSchema = z.enum(["paste", "clipboard_only"]); export type InjectMode = z.infer<typeof injectModeSchema>; /** Who serves recognition or clean-up (`voltip_core::providers::ProviderId`, docs/dictation.md - * §3): the build's own service, the on-device models, a few OpenAI-compatible vendors, Alibaba - * Cloud Model Studio (its own recognition protocols, §3.4), Ollama on this machine and a custom + * §3): the build's own service, the on-device models, OpenAI-compatible vendors, Alibaba Cloud + * Model Studio (its own recognition protocols, §3.4), Ollama on this machine and a custom * endpoint. Display order. */ export const PROVIDER_IDS = [ "builtin", @@ -42,6 +42,12 @@ export const PROVIDER_IDS = [ "siliconflow", "aliyun", "deepseek", + "kimi", + "glm", + "volcengine", + "hunyuan", + "mimo", + "openrouter", "ollama", "custom", ] as const; @@ -1281,6 +1287,9 @@ export const historyEntrySchema = z.object({ refine_ms: z.number().nonnegative().optional(), /** Why a requested clean-up's text was not used (§3.6); absent in entries written before it. */ refine_failure: refineFailureSchema.optional(), + /** The service's explanation when the clean-up's request failed (`rate_limited`, `quota`, + * `failed`), for the technical details (§3.6); absent otherwise and in older entries. */ + refine_error: z.string().optional(), outcome: historyOutcomeSchema, starred: z.boolean(), /** Same meaning as `done.mode` / `segments` / `live_error` (§12); an older `history.json` reads @@ -1515,6 +1524,18 @@ export const secretStateSchema = z.object({ }); export type SecretState = z.infer<typeof secretStateSchema>; +/** Which of a vendor's sites (`voltip_core::SiteId`, docs/dictation.md §3.2). */ +export const siteIdSchema = z.enum(["cn", "intl"]); +export type SiteId = z.infer<typeof siteIdSchema>; + +/** One of a vendor's sites (`voltip_core::engines::SiteStatus`). */ +export const siteStatusSchema = z.object({ + id: siteIdSchema, + /** The service's base URL there. */ + base_url: z.string(), +}); +export type SiteStatus = z.infer<typeof siteStatusSchema>; + /** One service of a provider as the engines pane shows it (`voltip_core::engines::ServiceStatus`). */ export const serviceStatusSchema = z.object({ /** Model in effect (`""` when none is chosen); the catalogue id for the on-device provider. */ @@ -1534,6 +1555,8 @@ export const serviceStatusSchema = z.object({ * (docs/dictation.md §3.3), fetched when a model list opens (`provider_models_refresh`) and by * a 测试连接 with the saved values; absent until a list arrived. */ listed: z.array(z.string()).optional(), + /** The vendor's sites, the default first (§3.2); absent for a vendor with one address. */ + sites: z.array(siteStatusSchema).optional(), }); export type ServiceStatus = z.infer<typeof serviceStatusSchema>; @@ -1687,6 +1710,9 @@ export const probeReportSchema = z.object({ latency_ms: z.number().int().nonnegative().optional(), reason: probeFailureSchema.optional(), status: z.number().int().optional(), + /** What the service said, or the connection's error (docs/dictation.md §3.12); absent when it + * adds nothing to the reason. */ + detail: z.string().optional(), }); export type ProbeReport = z.infer<typeof probeReportSchema>; @@ -2700,7 +2726,7 @@ export interface CommandArgs { provider_models_refresh: { provider: ProviderId; kind: ServiceKind }; /** Open the vendor's API-key page (`page` absent or `key`) or its page of free quota and usage * (`quota`) in the browser (catalogue URLs only). */ - provider_console_open: { provider: ProviderId; page?: ConsolePage }; + provider_console_open: { provider: ProviderId; page?: ConsolePage; site?: SiteId }; /** Open a project page in the browser; the shell builds the URL from its repository. */ project_link_open: { link: ProjectLink }; guide_open: { page: GuidePage; locale: string }; diff --git a/packages/shared/src/tauri-backend.ts b/packages/shared/src/tauri-backend.ts index 5a6d1121..8f46fbb7 100644 --- a/packages/shared/src/tauri-backend.ts +++ b/packages/shared/src/tauri-backend.ts @@ -18,6 +18,7 @@ import { type GuidePage, type ProjectLink, type ProviderId, + type SiteId, type VocabularyBacktestArgs, UI_EVENT_NAME, appRefSchema, @@ -212,11 +213,16 @@ export class TauriBackend implements Backend { await this.transport.invoke("permissions_request", { permission }); } - async providerConsoleOpen(provider: ProviderId, page?: ConsolePage): Promise<void> { - await this.transport.invoke( - "provider_console_open", - page === undefined ? { provider } : { provider, page }, - ); + async providerConsoleOpen( + provider: ProviderId, + page?: ConsolePage, + site?: SiteId, + ): Promise<void> { + await this.transport.invoke("provider_console_open", { + provider, + ...(page === undefined ? {} : { page }), + ...(site === undefined ? {} : { site }), + }); } async guideOpen(page: GuidePage, locale: string): Promise<void> { diff --git a/packages/ui/src/components/Pill.test.tsx b/packages/ui/src/components/Pill.test.tsx index 0d89261f..f85d19b0 100644 --- a/packages/ui/src/components/Pill.test.tsx +++ b/packages/ui/src/components/Pill.test.tsx @@ -391,6 +391,16 @@ describe("Pill width", () => { }); describe("the inserted pill's note (docs/dictation.md §3.6)", () => { + it("regression: a failure with the service's words is cut in the pill and whole in its tooltip (docs/dictation.md section 3.12)", () => { + const text = + "失败 · 识别服务出错:密钥被拒绝:Incorrect API key provided: sk-***. You can find your API key at https://platform.openai.com/account/api-keys. (invalid_api_key)"; + render(<Pill state="error" label={text} />); + const shown = screen.getByTestId("pill-error-text"); + expect(shown).toHaveTextContent(text); + expect(shown).toHaveAttribute("title", text); + expect(shown.className).toContain("truncate"); + }); + it("says why the text went in without polish, and only once the text is in", () => { const { rerender } = render( <Pill state="inserted" label="已插入 9 字" note="未润色 · 服务繁忙" />, diff --git a/packages/ui/src/components/Pill.tsx b/packages/ui/src/components/Pill.tsx index 68ae425e..7c30798c 100644 --- a/packages/ui/src/components/Pill.tsx +++ b/packages/ui/src/components/Pill.tsx @@ -439,8 +439,15 @@ export function Pill({ )} {state === "error" && ( <> - <Icon name="alert" size={14} className="text-danger" /> - <span>{text}</span> + <Icon name="alert" size={14} className="shrink-0 text-danger" /> + {/* A failure carries what the service said (docs/dictation.md §3.12): long ones are cut + here and read in full in the tooltip and the history. */} + <span + className="min-w-0 max-w-[380px] truncate" + title={text} + data-testid="pill-error-text"> + {text} + </span> {/* Only when there is something to copy: a silent take has no text to offer. */} {onCopy && ( <Button size="sm" variant="primary" onClick={onCopy} className="h-6"> diff --git a/packages/ui/src/features/engines/ProviderCard.test.tsx b/packages/ui/src/features/engines/ProviderCard.test.tsx index c59ea634..6dc8a436 100644 --- a/packages/ui/src/features/engines/ProviderCard.test.tsx +++ b/packages/ui/src/features/engines/ProviderCard.test.tsx @@ -112,6 +112,60 @@ describe("ProviderCards (desktop model pages and phone settings)", () => { backend.destroy(); }); + it("switches Kimi between its China and international sites, each with its own key page", async () => { + const user = userEvent.setup(); + const backend = new MockBackend(); + const invoke = vi.spyOn(backend, "invoke"); + renderCard(backend, { id: "kimi", kind: "llm" }); + const form = await screen.findByTestId("provider-form"); + const sites = within(form).getByRole("radiogroup", { name: t("engines.field.site") }); + const china = within(sites).getByRole("radio", { name: t("engines.site.cn") }); + const intl = within(sites).getByRole("radio", { name: t("engines.site.intl") }); + // The default site while the URL field is empty. + expect(china).toHaveAttribute("aria-checked", "true"); + const url = within(form).getByLabelText(t("engines.field.baseUrl")); + expect(url).toHaveAttribute("placeholder", "https://api.moonshot.cn/v1"); + await user.click(intl); + expect(url).toHaveValue("https://api.moonshot.ai/v1"); + expect(intl).toHaveAttribute("aria-checked", "true"); + await user.click(within(form).getByRole("button", { name: t("engines.getKey") })); + expect(backend.consolesOpened).toEqual(["kimi"]); + expect(backend.consoleSites).toEqual(["intl"]); + await user.type(within(form).getByLabelText(t("engines.field.key")), "sk-test"); + await user.click(within(form).getByRole("button", { name: t("engines.save") })); + expect(invoke).toHaveBeenCalledWith("settings_set_engines", { + engines: expect.objectContaining({ + providers: expect.objectContaining({ kimi: { llm_url: "https://api.moonshot.ai/v1" } }), + }), + }); + // Back to the default site: the field empties, so the catalogue's address follows. + await user.click(china); + expect(url).toHaveValue(""); + // Any other address is neither site. + await user.type(url, "https://proxy.example.test/v1"); + expect(china).toHaveAttribute("aria-checked", "false"); + expect(intl).toHaveAttribute("aria-checked", "false"); + backend.destroy(); + }); + + it("shows no site switch for a vendor with one address", async () => { + const backend = new MockBackend(); + renderCard(backend, { id: "volcengine", kind: "llm" }); + const form = await screen.findByTestId("provider-form"); + expect(within(form).queryByTestId("provider-site")).toBeNull(); + expect( + within(within(form).getByRole("combobox", { name: t("engines.field.model") })) + .getAllByRole("option") + .map((o) => o.textContent), + ).toEqual([ + "doubao-seed-2-1-lite-260915", + "doubao-seed-2-1-turbo-260628", + "doubao-seed-2-0-mini-260428", + t("engines.field.modelOther"), + ]); + backend.destroy(); + }); + it("offers Model Studio's realtime model first and explains its workspace address", async () => { const user = userEvent.setup(); const backend = new MockBackend(); @@ -127,8 +181,6 @@ describe("ProviderCards (desktop model pages and phone settings)", () => { ).toEqual([ "qwen-audio-3.1-asr-flash-streaming", "qwen-audio-3.1-asr-flash", - "qwen-audio-3.1-asr-flash-message", - "qwen3-asr-flash", "fun-asr-realtime", t("engines.field.modelOther"), ]); @@ -564,20 +616,18 @@ describe("ProviderCards (desktop model pages and phone settings)", () => { const form = await screen.findByTestId("provider-form"); const select = within(form).getByRole("combobox", { name: t("engines.field.model") }); expect([...select.querySelectorAll("option")].map((o) => o.value)).toEqual([ - "gemini-3.8-flash", "gemini-3.5-flash-lite", + "gemini-3.8-flash", "gemini-3.1-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-pro", "__other__", ]); expect(screen.getByText(t("engines.providerNote.google"))).toBeInTheDocument(); - await user.selectOptions(select, "gemini-3.5-flash-lite"); + await user.selectOptions(select, "gemini-3.8-flash"); await user.type(within(form).getByLabelText(t("engines.field.key")), "AIza-test"); await user.click(within(form).getByRole("button", { name: t("engines.save") })); expect(invoke).toHaveBeenCalledWith("settings_set_engines", { engines: expect.objectContaining({ - providers: expect.objectContaining({ google: { llm_model: "gemini-3.5-flash-lite" } }), + providers: expect.objectContaining({ google: { llm_model: "gemini-3.8-flash" } }), }), }); expect(invoke).toHaveBeenCalledWith("provider_key_set", { diff --git a/packages/ui/src/features/engines/ProviderCard.tsx b/packages/ui/src/features/engines/ProviderCard.tsx index 468c4161..f12859ed 100644 --- a/packages/ui/src/features/engines/ProviderCard.tsx +++ b/packages/ui/src/features/engines/ProviderCard.tsx @@ -3,6 +3,7 @@ import { type ProviderStatus, type ServiceKind, type ServiceStatus, + type SiteId, REASONING_EFFORTS, type ReasoningEffort, applyProviderDraft, @@ -16,6 +17,8 @@ import { providerDraft, providerTagline, secretStateLabel, + siteOf, + siteUrl, withProvider, } from "@voltip/shared"; import { type ReactNode, useEffect, useState } from "react"; @@ -27,6 +30,7 @@ import { Icon, type IconName } from "../../components/Icon"; import { IconButton } from "../../components/IconButton"; import { Input } from "../../components/Input"; import { LampText } from "../../components/LampText"; +import { Segmented } from "../../components/Segmented"; import { Select } from "../../components/Select"; import { CardGrid } from "../../components/SettingsLayout"; import { cx } from "../../cx"; @@ -53,6 +57,12 @@ const ICONS: Readonly<Record<ProviderId, IconName>> = { siliconflow: "cloud", aliyun: "cloud", deepseek: "cloud", + kimi: "cloud", + glm: "cloud", + volcengine: "cloud", + hunyuan: "cloud", + mimo: "cloud", + openrouter: "cloud", ollama: "monitor", custom: "link", }; @@ -462,6 +472,9 @@ function ProviderForm({ const shared = provider.asr !== undefined && provider.llm !== undefined && provider.id !== "custom"; const secret = secretStateLabel(service.key, locale); + // docs/dictation.md §3.2: a vendor with a Chinese and an international site. + const sites = service.sites ?? []; + const site = siteOf(service, draft.baseUrl); // docs/dictation.md §3.3: the provider's latest models as the drawer opens, and again each time // the model list opens. const refreshModels = useModelRefresh(provider.id, kind); @@ -600,6 +613,23 @@ function ProviderForm({ )} </Group> <Group title={t("engines.detail.connection")}> + {sites.length > 1 && ( + <div className="flex flex-col gap-1" data-testid="provider-site" data-site={site}> + <span className="text-[12px] text-fg-muted">{t("engines.field.site")}</span> + {/* Any other address in the field selects neither site. */} + <Segmented<SiteId | "other"> + size="sm" + label={t("engines.field.site")} + value={site ?? "other"} + onChange={(id) => { + if (id !== "other") setDraft({ ...draft, baseUrl: siteUrl(service, id) }); + }} + options={sites.map((s) => ({ value: s.id, label: t(`engines.site.${s.id}`) }))} + className="self-start" + /> + <Help>{t("engines.field.siteHelp")}</Help> + </div> + )} <Input label={t("engines.field.baseUrl")} mono @@ -662,7 +692,7 @@ function ProviderForm({ variant="text" icon="external" onClick={() => { - void backend.providerConsoleOpen(provider.id); + void backend.providerConsoleOpen(provider.id, undefined, site); }}> {t("engines.getKey")} </Button>