merge upstream main into windows local asr

This commit is contained in:
millionart committed 2026-05-06 20:55:25 +08:00
commit 4fa34b486e
18 files changed
+734 -101

No files matched your search

+9 -4
View File
@@ -32,10 +32,15 @@ jobs:
rust-target: aarch64-apple-darwin
updater-target: darwin
updater-arch: aarch64
- platform: macos-13
rust-target: x86_64-apple-darwin
updater-target: darwin
updater-arch: x86_64
# 暂时搁置 Intel mac 构建:GitHub Actions 把 macos-13 标记为 deprecated
# runner,pool 容量紧张到每次 dispatch 都 queue 1-2h 拿不到分配,多次
# release 都因这个 job 卡住整个 run 的 conclusion。等 GH 把 macOS x86_64
# 迁移到非 deprecated runner(macos-14 / macos-15-large)或 macos-13
# pool 缓解后再恢复。Apple Silicon dmg 在 Intel mac 上跑 Rosetta 仍可用。
# - platform: macos-13
# rust-target: x86_64-apple-darwin
# updater-target: darwin
# updater-arch: x86_64
- platform: windows-latest
rust-target: x86_64-pc-windows-msvc
updater-target: windows
+3 -3
View File
@@ -64,7 +64,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS
asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP
polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.)
insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback
persistence.rs History/preferences/vocab JSON + Keychain credentials
persistence.rs History/preferences/vocab JSON + platform credential vault
coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing
permissions.rs TCC checks (Accessibility / Microphone)
@@ -91,9 +91,9 @@ Invariants:
### Permissions, credentials, on-disk state
- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks Keychain lookups *and* every existing TCC grant.
- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks system credential vault lookups *and* every existing TCC grant.
- **TCC**: Microphone + Accessibility + AppleEvents. `NSMicrophoneUsageDescription` / `NSAccessibilityUsageDescription` / `NSAppleEventsUsageDescription` live in `openless-all/app/src-tauri/Info.plist`. After a fresh build that resets TCC, the app must be **fully quit and relaunched** after granting Accessibility before the global hotkey tap installs.
- **Credentials** live in Keychain under accounts in `CredentialAccount` (`volcengine.app_key`, `volcengine.access_key`, `volcengine.resource_id`, `ark.api_key`, `ark.model_id`, `ark.endpoint`). The plaintext fallback at `~/.openless/credentials.json` is read on first launch so legacy users keep their creds without re-entering. Never hard-code keys.
- **Credentials** live in the OS credential vault (macOS Keychain, Windows Credential Manager, Linux keyring) under service `com.openless.app`. The legacy plaintext JSON (`~/.openless/credentials.json` on macOS/Linux, `%APPDATA%\OpenLess\credentials.json` on Windows) is only a migration source and is removed after a successful vault write. Never hard-code keys or include legacy credential files in logs, exports, build artifacts, or bug reports.
- **Per-user data**:
- macOS: `~/Library/Application Support/OpenLess/{history.json, preferences.json, dictionary.json}` — capped at 200 history entries. **Do not rename `dictionary.json` to `vocab.json`** (drops user data).
- Windows: `%APPDATA%\OpenLess\`
+3 -3
View File
@@ -64,7 +64,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS
asr/{mod,frame,volcengine,whisper}.rs ASR providers: Volcengine streaming WebSocket + Whisper HTTP
polish.rs OpenAI-compatible chat completions (Ark / DeepSeek / etc.)
insertion.rs AX focused-element write → clipboard + Cmd+V → copy-only fallback
persistence.rs History/preferences/vocab JSON + Keychain credentials
persistence.rs History/preferences/vocab JSON + platform credential vault
coordinator.rs + commands.rs + lib.rs State machine, IPC surface, tray icon, window plumbing
permissions.rs TCC checks (Accessibility / Microphone)
@@ -91,9 +91,9 @@ Invariants:
### Permissions, credentials, on-disk state
- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks Keychain lookups *and* every existing TCC grant.
- **Bundle ID `com.openless.app`** is hard-coded in `openless-all/app/src-tauri/tauri.conf.json` and `CredentialsVault.serviceName`. Changing it breaks system credential vault lookups *and* every existing TCC grant.
- **TCC**: Microphone + Accessibility + AppleEvents. `NSMicrophoneUsageDescription` / `NSAccessibilityUsageDescription` / `NSAppleEventsUsageDescription` live in `openless-all/app/src-tauri/Info.plist`. After a fresh build that resets TCC, the app must be **fully quit and relaunched** after granting Accessibility before the global hotkey tap installs.
- **Credentials** live in Keychain under accounts in `CredentialAccount` (`volcengine.app_key`, `volcengine.access_key`, `volcengine.resource_id`, `ark.api_key`, `ark.model_id`, `ark.endpoint`). The plaintext fallback at `~/.openless/credentials.json` is read on first launch so legacy users keep their creds without re-entering. Never hard-code keys.
- **Credentials** live in the OS credential vault (macOS Keychain, Windows Credential Manager, Linux keyring) under service `com.openless.app`. The legacy plaintext JSON (`~/.openless/credentials.json` on macOS/Linux, `%APPDATA%\OpenLess\credentials.json` on Windows) is only a migration source and is removed after a successful vault write. Never hard-code keys or include legacy credential files in logs, exports, build artifacts, or bug reports.
- **Per-user data**:
- macOS: `~/Library/Application Support/OpenLess/{history.json, preferences.json, dictionary.json}` — capped at 200 history entries. **Do not rename `dictionary.json` to `vocab.json`** (drops user data).
- Windows: `%APPDATA%\OpenLess\`
+8 -3
View File
@@ -195,9 +195,14 @@ Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\
## Credentials
Credentials live in the local Keychain (service = `com.openless.app`). A plaintext JSON file at `~/.openless/credentials.json` (mode 0600, dir 0700) is kept as a dev-mode fallback when Keychain is unavailable.
Credentials live in the OS credential vault (service = `com.openless.app`): macOS Keychain, Windows Credential Manager, or Linux keyring. A legacy plaintext JSON file is read only as a migration source and removed after a successful vault write:
The repository contains no API keys, tokens, or private endpoints.
```text
macOS / Linux: ~/.openless/credentials.json
Windows: %APPDATA%\OpenLess\credentials.json
```
New credential writes do not persist plaintext secrets. The repository contains no API keys, tokens, or private endpoints.
You'll need:
@@ -247,7 +252,7 @@ recorder.rs Mic → 16 kHz mono Int16 PCM, RMS callback
asr/ Volcengine streaming ASR (WebSocket) + Whisper HTTP
polish.rs OpenAI-compatible chat-completions (Ark / DeepSeek / etc.)
insertion.rs AX focused-element → clipboard + Cmd+V → copy-only fallback
persistence.rs History / preferences / vocab JSON + Keychain credentials
persistence.rs History / preferences / vocab JSON + platform credential vault
permissions.rs TCC checks (Accessibility / Microphone)
coordinator.rs State machine: Idle → Starting → Listening → Processing
commands.rs Tauri IPC surface
+5 -4
View File
@@ -198,13 +198,14 @@ npm run build
## 凭据
凭据保存在本机 Keychain(service = `com.openless.app`)。开发期同时维护一份明文 JSON 兜底,用于在 Keychain 不可用时回退:
凭据保存在系统凭据库(service = `com.openless.app`):macOS Keychain、Windows Credential Manager 或 Linux keyring。旧版明文 JSON 只作为迁移来源读取,成功写入系统凭据库后会被删除:
```text
~/.openless/credentials.json # 0600,目录 0700
macOS / Linux: ~/.openless/credentials.json
Windows: %APPDATA%\OpenLess\credentials.json
```
仓库本身不包含任何 API Key、Token 或 Endpoint 之外的私有信息。
新的凭据写入不会继续保存明文 secrets。仓库本身不包含任何 API Key、Token 或 Endpoint 之外的私有信息。
需要配置的字段:
@@ -254,7 +255,7 @@ recorder.rs 麦克风 → 16 kHz 单声道 Int16 PCM,RMS 回调
asr/ 火山引擎流式 ASR(WebSocket)+ Whisper HTTP
polish.rs OpenAI 兼容 chat-completions(Ark / DeepSeek 等)
insertion.rs AX focused-element → 剪贴板 + Cmd+V → 仅复制兜底
persistence.rs 历史记录 / 偏好设置 / 词典 JSON + Keychain 凭据
persistence.rs 历史记录 / 偏好设置 / 词典 JSON + 系统凭据库
permissions.rs TCC 权限检查(辅助功能 / 麦克风)
coordinator.rs 状态机:Idle → Starting → Listening → Processing
commands.rs Tauri IPC 接口
@@ -71,7 +71,7 @@ if (-not $credentialStatus.ArkConfigured) {
Write-Host "[warn] Ark LLM credentials are not configured; polishing will fall back or fail depending on mode."
}
if ($RequireCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) {
throw "Real regression requires configured Volcengine ASR and Ark LLM credentials."
Write-Warning "Legacy credentials.json is incomplete; continuing because the app uses the OS credential vault."
}
Write-Host ""
@@ -90,7 +90,6 @@ try {
Invoke-Step "Runtime smoke" {
Invoke-Script (Join-Path $PSScriptRoot "windows-runtime-smoke.ps1") @{
ExePath = $ExePath
RequireCredentials = $true
}
}
}
+306 -14
View File
@@ -424,6 +424,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block-sys"
version = "0.2.1"
@@ -637,6 +646,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.61"
@@ -1162,6 +1180,24 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
"num",
"once_cell",
"sha2",
"zeroize",
]
[[package]]
name = "deflate64"
version = "0.1.12"
@@ -2226,6 +2262,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
@@ -2573,6 +2618,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -2808,6 +2854,23 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"linux-keyutils",
"log",
"secret-service",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2891,6 +2954,16 @@ dependencies = [
"redox_syscall 0.7.5",
]
[[package]]
name = "linux-keyutils"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
dependencies = [
"bitflags 2.11.1",
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -3110,7 +3183,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"tempfile",
]
@@ -3174,6 +3247,19 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -3184,6 +3270,39 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -3201,6 +3320,37 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3618,6 +3768,7 @@ dependencies = [
"foundry-local-sdk",
"futures-util",
"global-hotkey",
"keyring",
"libc",
"log",
"objc2 0.5.2",
@@ -4613,7 +4764,7 @@ dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.7.0",
]
[[package]]
@@ -4641,7 +4792,7 @@ dependencies = [
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
@@ -4751,6 +4902,38 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
"num",
"once_cell",
"rand 0.8.6",
"serde",
"sha2",
"zbus 4.4.0",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.11.1",
"core-foundation 0.9.4",
"core-foundation-sys 0.8.7",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -5157,6 +5340,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -5565,7 +5754,7 @@ dependencies = [
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
"zbus 5.15.0",
]
[[package]]
@@ -7594,6 +7783,16 @@ dependencies = [
"rustix",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "xkbcommon"
version = "0.7.0"
@@ -7643,6 +7842,38 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-process",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix",
"ordered-stream",
"rand 0.8.6",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.15.0"
@@ -7673,9 +7904,22 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.2",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 5.15.0",
"zbus_names 4.3.2",
"zvariant 5.11.0",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -7688,9 +7932,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"zbus_names",
"zvariant",
"zvariant_utils",
"zbus_names 4.3.2",
"zvariant 5.11.0",
"zvariant_utils 3.3.1",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant 4.2.0",
]
[[package]]
@@ -7701,7 +7956,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [
"serde",
"winnow 1.0.2",
"zvariant",
"zvariant 5.11.0",
]
[[package]]
@@ -7901,6 +8156,19 @@ dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.11.0"
@@ -7911,8 +8179,21 @@ dependencies = [
"enumflags2",
"serde",
"winnow 1.0.2",
"zvariant_derive",
"zvariant_utils",
"zvariant_derive 5.11.0",
"zvariant_utils 3.3.1",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -7925,7 +8206,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"zvariant_utils",
"zvariant_utils 3.3.1",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
+15
View File
@@ -51,6 +51,21 @@ enigo = "0.2"
arboard = "3"
rdev = "0.5"
[target.'cfg(target_os = "macos")'.dependencies.keyring]
version = "3.6.3"
default-features = false
features = ["apple-native"]
[target.'cfg(target_os = "windows")'.dependencies.keyring]
version = "3.6.3"
default-features = false
features = ["windows-native"]
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies.keyring]
version = "3.6.3"
default-features = false
features = ["linux-native-sync-persistent", "crypto-rust"]
[target.'cfg(target_os = "macos")'.dependencies]
block2 = "0.5"
core-foundation = "0.10"
+14 -4
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use serde::Serialize;
use serde_json::Value;
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, State, Window};
use crate::asr::local::foundry::{
model_alias_is_known, FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus,
@@ -148,7 +148,8 @@ fn configured(field: &Option<String>) -> bool {
}
#[tauri::command]
pub fn set_credential(account: String, value: String) -> Result<(), String> {
pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> {
ensure_main_window(&window)?;
let acc = parse_account(&account)?;
if value.is_empty() {
CredentialsVault::remove(acc).map_err(|e| e.to_string())
@@ -184,13 +185,22 @@ pub fn set_active_llm_provider(provider: String) -> Result<(), String> {
}
/// 读出某个账号的实际值(用于设置页预填表单)。
/// 与 Swift `CredentialsVault.get` 同语义,先 Keychain,缺则回落 ~/.openless/credentials.json。
/// 凭据来自系统凭据库;只允许主设置窗口读取 raw secret,避免胶囊 / QA 等辅助窗口默认暴露。
#[tauri::command]
pub fn read_credential(account: String) -> Result<Option<String>, String> {
pub fn read_credential(window: Window, account: String) -> Result<Option<String>, String> {
ensure_main_window(&window)?;
let acc = parse_account(&account)?;
CredentialsVault::get(acc).map_err(|e| e.to_string())
}
fn ensure_main_window(window: &Window) -> Result<(), String> {
if window.label() == "main" {
Ok(())
} else {
Err("credential access is only allowed from the main window".to_string())
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderCheckResult {
+340 -62
View File
@@ -1,16 +1,15 @@
//! Local persistence: history JSON, user preferences JSON, vocab JSON, and
//! Keychain-backed credentials vault.
//! platform-backed credentials vault.
//!
//! Storage roots:
//! - macOS: `~/Library/Application Support/OpenLess`
//! - Windows: `%APPDATA%\OpenLess`
//! - Linux: `$XDG_DATA_HOME/OpenLess` or `~/.local/share/OpenLess`
//!
//! Divergence from Swift: the Swift `CredentialsVault` falls back to a JSON
//! file (`~/.openless/credentials.json`) when Keychain is unavailable. The
//! Rust port intentionally does NOT replicate that fallback — we rely solely
//! on the platform keyring. The macOS service name (`com.openless.app`) is
//! preserved so existing Keychain entries from the Swift app remain readable.
//! Credential storage policy: provider credentials are stored in the OS
//! credential vault (macOS Keychain, Windows Credential Manager, Linux keyring).
//! A legacy plaintext JSON file is read once as a migration source and removed
//! after a successful vault write; new writes never persist plaintext secrets.
use std::fs;
use std::path::{Path, PathBuf};
@@ -32,12 +31,16 @@ const PREFERENCES_FILE: &str = "preferences.json";
const VOCAB_FILE: &str = "dictionary.json";
const VOCAB_PRESETS_FILE: &str = "vocab-presets.json";
/// Swift 老 `CredentialsVault` 的 JSON 备用路径。
/// 升级到 Tauri 版后,先尝试 Keychain;Keychain 没有时回落读这个文件,
/// 让用户在 Swift 版填过的凭据无需重输。
/// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。
const LEGACY_CREDS_DIR: &str = ".openless";
const LEGACY_CREDS_FILE: &str = "credentials.json";
const KEYRING_CREDENTIALS_ACCOUNT: &str = "credentials.v1";
const KEYRING_CREDENTIALS_CHUNK_PREFIX: &str = "credentials.v1.chunk.";
// Windows Credential Manager caps one credential blob at 2560 bytes. keyring stores
// passwords as UTF-16 on Windows, so keep each JSON chunk comfortably below that.
const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000;
static CREDENTIALS_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn credentials_lock() -> &'static Mutex<()> {
@@ -115,12 +118,11 @@ fn read_or_default<T: for<'de> Deserialize<'de> + Default>(path: &Path) -> Resul
.with_context(|| format!("decode failed: {}", path.display()))
}
// ───────────────────────── credentials JSON store ─────────────────────────
// ───────────────────────── credentials vault ─────────────────────────
//
// 与 Swift `Sources/OpenLessPersistence/CredentialsVault.swift` 同源——纯 JSON 文件,
// 路径 `~/.openless/credentials.json`,权限 0600。**故意不用 Keychain**:
// ad-hoc 签名每次构建 hash 都变,Keychain ACL 失效后会触发逐账号弹框;用户已明确
// 选择"直接写本地文件"。
// 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider
// schema 与 active provider 状态,凭据库里保存一个 v1 JSON payload;payload 会按平台
// 凭据库限制拆成多个条目,避免 Windows 单条凭据 2560 bytes 限制。
//
// v1 schema:
// {
@@ -269,56 +271,311 @@ fn credentials_path() -> Result<PathBuf> {
}
}
fn ensure_credentials_dir(path: &Path) -> Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).with_context(|| format!("create dir {} failed", dir.display()))?;
// 0700 on parent so other users can't peek
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
fn keyring_entry() -> Result<keyring::Entry> {
keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT)
}
fn keyring_entry_for(account: &str) -> Result<keyring::Entry> {
keyring::Entry::new(CredentialsVault::SERVICE_NAME, account)
.context("open system credential vault")
}
fn clean_credentials(root: &CredsRoot) -> CredsRoot {
let mut cleaned = root.clone();
cleaned.providers.asr.retain(|_, v| !v.is_empty());
cleaned.providers.llm.retain(|_, v| !v.is_empty());
cleaned
}
fn read_legacy_credentials_file(path: &Path) -> Option<CredsRoot> {
if !path.exists() {
return None;
}
let bytes = match fs::read(path) {
Ok(b) => b,
Err(e) => {
log::warn!("[vault] read legacy {} failed: {}", path.display(), e);
return None;
}
};
match serde_json::from_slice::<CredsRoot>(&bytes) {
Ok(root) => Some(root),
Err(e) => {
log::warn!("[vault] parse legacy {} failed: {}", path.display(), e);
None
}
}
}
fn remove_legacy_credentials_file() -> Result<()> {
let Ok(path) = credentials_path() else {
return Ok(());
};
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("remove legacy credentials file {}", path.display()))?;
}
Ok(())
}
fn load_credentials() -> CredsRoot {
let path = match credentials_path() {
Ok(p) => p,
Err(_) => return CredsRoot::default(),
};
if !path.exists() {
return CredsRoot::default();
fn remove_legacy_credentials_file_best_effort() {
if let Err(e) = remove_legacy_credentials_file() {
log::warn!("[vault] remove legacy credentials file failed: {e}");
}
let bytes = match fs::read(&path) {
Ok(b) => b,
Err(e) => {
log::warn!("[vault] read {} failed: {}", path.display(), e);
return CredsRoot::default();
}
#[derive(Debug, Serialize, Deserialize)]
struct CredsChunkManifest {
openless_credentials_storage: String,
version: u32,
/// 旧版本(v1 早期)每次 save 都生成新 UUID 作为 chunk account 命名前缀,
/// 这让 macOS Keychain 的「始终允许」每次保存后失效 → 反复弹 ACL 弹窗。
/// 现在 save 总用稳定 chunk.{index} 名,此字段仅向后兼容旧 manifest 读取。
#[serde(default, skip_serializing_if = "Option::is_none")]
generation: Option<String>,
chunks: usize,
}
/// 旧版(generation=Some):`credentials.v1.chunk.<UUID>.{index}`
/// 新版(generation=None):`credentials.v1.chunk.{index}` —— 稳定名,ACL 长期有效
fn chunk_account(generation: Option<&str>, index: usize) -> String {
match generation {
Some(gen) => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{gen}.{index}"),
None => format!("{KEYRING_CREDENTIALS_CHUNK_PREFIX}{index}"),
}
}
fn chunk_json_payload(json: &str) -> Vec<String> {
let mut chunks = Vec::new();
let mut current = String::new();
let mut current_units = 0usize;
for ch in json.chars() {
let units = ch.len_utf16();
if !current.is_empty() && current_units + units > KEYRING_CHUNK_MAX_UTF16_UNITS {
chunks.push(std::mem::take(&mut current));
current_units = 0;
}
current.push(ch);
current_units += units;
}
if !current.is_empty() || json.is_empty() {
chunks.push(current);
}
chunks
}
fn read_chunk_manifest(json: &str) -> Option<CredsChunkManifest> {
let manifest = serde_json::from_str::<CredsChunkManifest>(json).ok()?;
if manifest.openless_credentials_storage == "chunked" && manifest.version == 1 {
Some(manifest)
} else {
None
}
}
fn get_keyring_password(account: &str) -> Result<Option<String>> {
match keyring_entry_for(account)?.get_password() {
Ok(value) => Ok(Some(value)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => {
Err(anyhow!(e)).with_context(|| format!("read system credential vault {account}"))
}
}
}
fn delete_keyring_password(account: &str) {
match keyring_entry_for(account).and_then(|entry| {
entry
.delete_credential()
.with_context(|| format!("delete system credential vault {account}"))
}) {
Ok(()) | Err(_) => {}
}
}
fn load_keyring_credentials() -> Result<Option<CredsRoot>> {
let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else {
return Ok(None);
};
serde_json::from_slice::<CredsRoot>(&bytes).unwrap_or_else(|e| {
log::warn!("[vault] parse {} failed: {}", path.display(), e);
CredsRoot::default()
})
let manifest = read_chunk_manifest(&json_or_manifest)
.ok_or_else(|| anyhow!("invalid system credential vault manifest"))?;
let mut json = String::new();
for index in 0..manifest.chunks {
let account = chunk_account(manifest.generation.as_deref(), index);
let chunk = get_keyring_password(&account)?
.ok_or_else(|| anyhow!("missing system credential vault chunk {index}"))?;
json.push_str(&chunk);
}
serde_json::from_str::<CredsRoot>(&json)
.map(Some)
.context("decode system credential vault payload")
}
fn load_legacy_keyring_credentials() -> CredsRoot {
match load_legacy_keyring_credentials_for_update() {
Ok(root) => root,
Err(e) => {
log::warn!("[vault] read legacy vault credentials failed: {e}");
CredsRoot::default()
}
}
}
fn load_legacy_keyring_credentials_for_update() -> Result<CredsRoot> {
let mut root = CredsRoot::default();
for account in CredentialAccount::all() {
let legacy_account = account.keyring_account();
match get_keyring_password(legacy_account) {
Ok(Some(value)) => write_account(&mut root, *account, Some(value)),
Ok(None) => {}
Err(e) => return Err(e.context(format!("read legacy vault {legacy_account}"))),
}
}
Ok(clean_credentials(&root))
}
fn remove_legacy_keyring_credentials() {
for account in CredentialAccount::all() {
delete_keyring_password(account.keyring_account());
}
}
fn load_legacy_credentials() -> Option<CredsRoot> {
credentials_path()
.ok()
.and_then(|p| read_legacy_credentials_file(&p))
}
fn legacy_vault_has_credentials(root: &CredsRoot) -> bool {
!root.providers.asr.is_empty() || !root.providers.llm.is_empty()
}
fn load_legacy_sources_without_migration() -> CredsRoot {
if let Some(legacy) = load_legacy_credentials() {
return legacy;
}
let legacy_vault = load_legacy_keyring_credentials();
if legacy_vault_has_credentials(&legacy_vault) {
return legacy_vault;
}
CredsRoot::default()
}
fn migrate_legacy_sources() -> CredsRoot {
match migrate_legacy_sources_for_update() {
Ok(root) => root,
Err(e) => {
log::warn!("[vault] legacy credential migration failed: {e}");
load_legacy_sources_without_migration()
}
}
}
fn migrate_legacy_sources_for_update() -> Result<CredsRoot> {
if let Some(legacy) = load_legacy_credentials() {
save_credentials(&legacy)?;
remove_legacy_keyring_credentials();
return Ok(legacy);
}
let legacy_vault = load_legacy_keyring_credentials_for_update()?;
if legacy_vault_has_credentials(&legacy_vault) {
save_credentials(&legacy_vault)?;
remove_legacy_keyring_credentials();
return Ok(legacy_vault);
}
Ok(CredsRoot::default())
}
fn load_credentials() -> CredsRoot {
match load_keyring_credentials() {
Ok(Some(root)) => {
// 不在这里调 remove_legacy_keyring_credentials() —— 它内部对 9 个
// 旧 account 各做一次 keyring delete,每次 delete 在 macOS Keychain
// 上仍要触发 ACL 检查。第一次成功 load 时 legacy entries 通常已经
// 被 migrate_legacy_sources_for_update 清理过了;这里若再无脑跑,
// 只会反复弹「OpenLess 想删除 X」十几次。文件 legacy(plaintext
// JSON)不需要 ACL,可继续 best-effort 删除。
remove_legacy_credentials_file_best_effort();
root
}
Ok(None) => migrate_legacy_sources(),
Err(e) => {
log::warn!("[vault] system credential read failed: {e}");
load_legacy_sources_without_migration()
}
}
}
fn load_credentials_for_update() -> Result<CredsRoot> {
match load_keyring_credentials() {
Ok(Some(root)) => {
// 同 load_credentials:不再每次 update 都尝试 delete legacy keyring
// entries,避免反复触发 macOS Keychain ACL 弹窗。
remove_legacy_credentials_file_best_effort();
Ok(root)
}
Ok(None) => migrate_legacy_sources_for_update(),
Err(e) => Err(e),
}
}
fn save_credentials(root: &CredsRoot) -> Result<()> {
let path = credentials_path()?;
ensure_credentials_dir(&path)?;
// 写盘前过滤掉空 entry,保持 JSON 干净(mirrors Swift cleanedSchema)。
let mut cleaned = root.clone();
cleaned.providers.asr.retain(|_, v| !v.is_empty());
cleaned.providers.llm.retain(|_, v| !v.is_empty());
let json = serde_json::to_vec_pretty(&cleaned).context("encode credentials failed")?;
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, &json).with_context(|| format!("write {} failed", tmp.display()))?;
fs::rename(&tmp, &path).with_context(|| format!("rename to {} failed", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
let cleaned = clean_credentials(root);
let json = serde_json::to_string(&cleaned).context("encode credentials failed")?;
let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)
.ok()
.flatten()
.and_then(|value| read_chunk_manifest(&value));
let chunks = chunk_json_payload(&json);
// 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让
// manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后
// 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation
// 设计的回退)。
for (index, chunk) in chunks.iter().enumerate() {
let account = chunk_account(None, index);
keyring_entry_for(&account)?
.set_password(chunk)
.with_context(|| format!("write system credential vault chunk {index}"))?;
}
let manifest = CredsChunkManifest {
openless_credentials_storage: "chunked".to_string(),
version: 1,
generation: None,
chunks: chunks.len(),
};
let manifest_json =
serde_json::to_string(&manifest).context("encode credential manifest failed")?;
keyring_entry()?
.set_password(&manifest_json)
.context("write system credential vault manifest")?;
// 清理旧 chunks:
// 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name)
// 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx
if let Some(previous) = previous_manifest {
match previous.generation.as_deref() {
Some(prev_gen) => {
for index in 0..previous.chunks {
delete_keyring_password(&chunk_account(Some(prev_gen), index));
}
}
None => {
for index in chunks.len()..previous.chunks {
delete_keyring_password(&chunk_account(None, index));
}
}
}
}
remove_legacy_credentials_file_best_effort();
Ok(())
}
@@ -689,12 +946,11 @@ pub struct CredentialsSnapshot {
pub ark_endpoint: Option<String>,
}
/// 凭据存储——纯 JSON 文件,**不**走 Keychain。详见文件头部注释。
/// 凭据存储——系统凭据库;旧 JSON 文件只作为迁移来源。
pub struct CredentialsVault;
impl CredentialsVault {
/// 历史保留:Swift 时代以此名作为 Keychain service。Rust 不再使用 Keychain,
/// 但暴露此常量给可能仍依赖它的代码点。
/// 系统凭据库 service name;macOS 下对应 Keychain service。
pub const SERVICE_NAME: &'static str = "com.openless.app";
pub fn get(account: CredentialAccount) -> Result<Option<String>> {
@@ -704,7 +960,7 @@ impl CredentialsVault {
pub fn set(account: CredentialAccount, value: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let mut root = load_credentials();
let mut root = load_credentials_for_update()?;
let v = if value.is_empty() {
None
} else {
@@ -716,7 +972,7 @@ impl CredentialsVault {
pub fn remove(account: CredentialAccount) -> Result<()> {
let _guard = credentials_lock().lock();
let mut root = load_credentials();
let mut root = load_credentials_for_update()?;
write_account(&mut root, account, None);
save_credentials(&root)
}
@@ -728,14 +984,14 @@ impl CredentialsVault {
pub fn set_active_asr_provider(id: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let mut root = load_credentials();
let mut root = load_credentials_for_update()?;
root.active.asr = id.to_string();
save_credentials(&root)
}
pub fn set_active_llm_provider(id: &str) -> Result<()> {
let _guard = credentials_lock().lock();
let mut root = load_credentials();
let mut root = load_credentials_for_update()?;
root.active.llm = id.to_string();
save_credentials(&root)
}
@@ -764,14 +1020,33 @@ impl CredentialsVault {
#[cfg(test)]
mod tests {
use super::{list_vocab_presets, save_vocab_presets};
use super::{
chunk_json_payload, list_vocab_presets, save_vocab_presets, KEYRING_CHUNK_MAX_UTF16_UNITS,
};
use crate::types::{VocabPreset, VocabPresetStore};
use std::fs;
use std::path::PathBuf;
#[test]
fn credential_payload_chunks_stay_under_windows_blob_limit() {
let payload = format!(
"{}{}{}",
"a".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25),
"😀".repeat(20),
"b".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS + 25)
);
let chunks = chunk_json_payload(&payload);
assert!(chunks.len() > 1);
assert_eq!(chunks.concat(), payload);
assert!(chunks
.iter()
.all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS));
}
#[test]
fn vocab_presets_roundtrip_json_file() {
let tmp: PathBuf = std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4()));
let tmp: PathBuf =
std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&tmp).expect("create temp dir");
// Linux path helper uses XDG_DATA_HOME first.
unsafe {
@@ -790,7 +1065,10 @@ mod tests {
let loaded = list_vocab_presets().expect("list presets");
assert_eq!(loaded.custom.len(), 1);
assert_eq!(loaded.custom[0].id, "test");
assert_eq!(loaded.custom[0].phrases, vec!["PR".to_string(), "CI".to_string()]);
assert_eq!(
loaded.custom[0].phrases,
vec!["PR".to_string(), "CI".to_string()]
);
assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]);
let _ = fs::remove_dir_all(&tmp);
}
+22 -2
View File
@@ -147,18 +147,38 @@ function isWindowHotkeyCandidate(event: KeyboardEvent): boolean {
}
function StartupShell() {
// 用透明背景:main window 是 transparent + macOSPrivateApi(NSVisualEffectView 磨砂)。
// 之前用 linear-gradient(rgba(245,245,247,0.96)...) 会盖过 macOS vibrancy,启动时
// 长时间在 'checking' phase(凭据迁移 / 权限 probe 慢)会让窗口看起来「左侧白屏 +
// 右侧磨砂」割裂。现在背景全透明,让磨砂统一展开,提示文字 + icon 用一个轻量
// pill 卡片承载,跟 capsule 视觉一致。
return (
<div
style={{
minHeight: '100vh',
display: 'grid',
placeItems: 'center',
background: 'linear-gradient(180deg, rgba(245,245,247,0.96) 0%, rgba(232,232,236,0.96) 100%)',
background: 'transparent',
color: 'var(--ol-ink-3)',
fontFamily: 'var(--ol-font-sans)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, fontWeight: 500 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 13,
fontWeight: 500,
padding: '10px 16px',
borderRadius: 999,
background: 'rgba(255, 255, 255, 0.55)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
border: '0.5px solid rgba(0, 0, 0, 0.06)',
boxShadow: '0 4px 14px -6px rgba(0, 0, 0, 0.18), 0 0 0 0.5px rgba(0,0,0,0.04)',
}}
>
<img src="AppIcon.png" alt="" style={{ width: 18, height: 18, borderRadius: 4 }} />
<span>OpenLess 正在启动</span>
</div>
+1
View File
@@ -291,6 +291,7 @@ export const en: typeof zhCN = {
llmDesc: 'OpenAI-compatible protocol. Multiple vendors supported.',
providerLabel: 'Provider',
llmProviderDesc: 'Selecting a preset auto-fills the default Base URL.',
credentialStorageNotice: 'Credentials are stored in the OS credential vault. Legacy local JSON credentials are migrated into the vault and removed after a successful write.',
asrProviderDesc: 'Switching providers automatically loads the matching credentials.',
asrTitle: 'ASR (transcription)',
asrDesc: 'Used to turn speech into text in real time.',
+1
View File
@@ -293,6 +293,7 @@ export const ja: typeof zhCN = {
llmDesc: 'OpenAI 互換プロトコル、複数のサプライヤー切り替えに対応。',
providerLabel: 'サプライヤー',
llmProviderDesc: '選択するとデフォルトの Base URL が自動入力されます。',
credentialStorageNotice: '認証情報は OS の認証情報ストアに保存されます。旧バージョンのローカル JSON 認証情報はストアへ移行され、書き込み成功後に削除されます。',
asrProviderDesc: '切り替えると対応する認証情報が自動選択されます。',
asrTitle: 'ASR 音声(転写)',
asrDesc: '口述をリアルタイムでテキストに転写。',
+1
View File
@@ -293,6 +293,7 @@ export const ko: typeof zhCN = {
llmDesc: 'OpenAI 호환 프로토콜, 다양한 공급자 전환 지원.',
providerLabel: '공급자',
llmProviderDesc: '선택 시 Base URL 기본값이 자동 입력됩니다.',
credentialStorageNotice: '자격 증명은 OS 자격 증명 저장소에 저장됩니다. 이전 로컬 JSON 자격 증명은 저장소로 마이그레이션되고, 쓰기 성공 후 삭제됩니다.',
asrProviderDesc: '전환 시 해당하는 자격 증명이 자동 선택됩니다.',
asrTitle: 'ASR 음성(전사)',
asrDesc: '구술을 실시간으로 텍스트로 전사합니다.',
+1
View File
@@ -289,6 +289,7 @@ export const zhCN = {
llmDesc: 'OpenAI 兼容协议,支持多家供应商切换。',
providerLabel: '供应商',
llmProviderDesc: '选择后将自动填入 Base URL 默认值。',
credentialStorageNotice: '凭据保存在系统凭据库中。旧版本地 JSON 凭据会迁移到系统凭据库,并在成功写入后删除。',
asrProviderDesc: '切换后将自动选用对应凭据。',
asrTitle: 'ASR 语音(转写)',
asrDesc: '用于将口述实时转写为文本。',
+1
View File
@@ -291,6 +291,7 @@ export const zhTW: typeof zhCN = {
llmDesc: 'OpenAI 兼容協議,支持多家供應商切換。',
providerLabel: '供應商',
llmProviderDesc: '選擇後將自動填入 Base URL 默認值。',
credentialStorageNotice: '憑據儲存在系統憑據庫中。舊版本機 JSON 憑據會遷移到系統憑據庫,並在成功寫入後刪除。',
asrProviderDesc: '切換後將自動選用對應憑據。',
asrTitle: 'ASR 語音(轉寫)',
asrDesc: '用於將口述實時轉寫爲文本。',
+3
View File
@@ -514,6 +514,9 @@ function ProvidersSection() {
return (
<>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginBottom: 10 }}>
{t('settings.providers.credentialStorageNotice')}
</div>
<Card>
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.providers.llmTitle')}</div>