Store credentials in the system vault

Provider credentials should follow the platform credential-store threat model instead of continuing normal plaintext JSON writes. The vault now stores the existing multi-provider credentials payload in the OS credential store, migrates the legacy JSON file only when the vault has no entry, removes the legacy file after a successful write, and keeps raw credential IPC restricted to the main settings window.

Constraint: User explicitly chose Keychain/Credential Manager/Linux keyring for issue #230.

Constraint: Preserve the existing provider schema and active-provider routing with minimal frontend churn.

Rejected: Keep plaintext JSON as the documented product choice | user rejected this direction.

Rejected: Split every credential field into separate vault entries | larger migration surface and risks losing active provider state.

Confidence: medium

Scope-risk: moderate

Directive: Security-model and storage-strategy choices must be confirmed with the user before implementation.

Tested: cargo check --manifest-path src-tauri/Cargo.toml; npm run -s build; git diff --check; grep for stale plaintext/Keychain fallback wording.

Not-tested: Manual migration on macOS Keychain, Windows Credential Manager, or Linux desktop keyring.

Related: https://github.com/appergb/openless/issues/230
This commit is contained in:
H-Chris233 committed 2026-05-06 14:39:40 +08:00
1 parent 2bd77fe87e
commit 13ccf85a48
13 files changed
+185 -82

No files matched your search

+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 接口
@@ -597,7 +597,7 @@ if ($RequireJsonCredentials -and (-not $credentialStatus.VolcengineConfigured -o
throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials."
}
if (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured) {
Write-Warning "Legacy credentials.json is incomplete; continuing because the app may use the OS credential vault."
Write-Warning "Legacy credentials.json is incomplete; continuing because the app uses the OS credential vault."
}
$logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log"
+42 -3
View File
@@ -2600,6 +2600,21 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"linux-keyutils",
"log",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zeroize",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2683,6 +2698,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"
@@ -2881,7 +2906,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"tempfile",
]
@@ -3388,6 +3413,7 @@ dependencies = [
"ferrous-opencc",
"futures-util",
"global-hotkey",
"keyring",
"libc",
"log",
"objc2 0.5.2",
@@ -4369,7 +4395,7 @@ dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.7.0",
]
[[package]]
@@ -4397,7 +4423,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.52.0",
@@ -4507,6 +4533,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[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"
+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"]
[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::coordinator::Coordinator;
use crate::permissions::{self, PermissionStatus};
@@ -143,7 +143,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())
@@ -173,13 +174,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 {
+88 -61
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,12 @@ 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";
static CREDENTIALS_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn credentials_lock() -> &'static Mutex<()> {
@@ -115,12 +114,10 @@ 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,而不是逐字段散落。
//
// v1 schema:
// {
@@ -262,56 +259,87 @@ 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));
}
}
Ok(())
fn keyring_entry() -> Result<keyring::Entry> {
keyring::Entry::new(CredentialsVault::SERVICE_NAME, KEYRING_CREDENTIALS_ACCOUNT)
.context("open system credential vault")
}
fn load_credentials() -> CredsRoot {
let path = match credentials_path() {
Ok(p) => p,
Err(_) => return CredsRoot::default(),
};
if !path.exists() {
return CredsRoot::default();
}
let bytes = match fs::read(&path) {
Ok(b) => b,
Err(e) => {
log::warn!("[vault] read {} failed: {}", path.display(), e);
return CredsRoot::default();
}
};
serde_json::from_slice::<CredsRoot>(&bytes).unwrap_or_else(|e| {
log::warn!("[vault] parse {} failed: {}", path.display(), e);
CredsRoot::default()
})
}
fn save_credentials(root: &CredsRoot) -> Result<()> {
let path = credentials_path()?;
ensure_credentials_dir(&path)?;
// 写盘前过滤掉空 entry,保持 JSON 干净(mirrors Swift cleanedSchema)。
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());
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));
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() {
let Ok(path) = credentials_path() else { return };
if path.exists() {
if let Err(e) = fs::remove_file(&path) {
log::warn!("[vault] remove legacy {} failed: {}", path.display(), e);
}
}
}
fn load_keyring_credentials() -> Result<Option<CredsRoot>> {
let entry = keyring_entry()?;
let json = match entry.get_password() {
Ok(json) => json,
Err(keyring::Error::NoEntry) => return Ok(None),
Err(e) => return Err(anyhow!(e)).context("read system credential vault"),
};
serde_json::from_str::<CredsRoot>(&json)
.map(Some)
.context("decode system credential vault payload")
}
fn load_credentials() -> CredsRoot {
match load_keyring_credentials() {
Ok(Some(root)) => return root,
Ok(None) => {}
Err(e) => {
log::warn!("[vault] system credential read failed: {e}");
return CredsRoot::default();
}
}
let Some(legacy) = credentials_path().ok().and_then(|p| read_legacy_credentials_file(&p)) else {
return CredsRoot::default();
};
if let Err(e) = save_credentials(&legacy) {
log::warn!("[vault] legacy credential migration failed: {e}");
return CredsRoot::default();
}
legacy
}
fn save_credentials(root: &CredsRoot) -> Result<()> {
let cleaned = clean_credentials(root);
let json = serde_json::to_string(&cleaned).context("encode credentials failed")?;
keyring_entry()?
.set_password(&json)
.context("write system credential vault")?;
remove_legacy_credentials_file();
Ok(())
}
@@ -682,12 +710,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>> {
+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
@@ -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
@@ -508,6 +508,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>