merge upstream main into Windows IME PR
This commit is contained in:
commit
104793cf18
14 files changed
+433
-80
No files matched your search
@@ -4,7 +4,7 @@ name: Release Tauri (cross-platform)
|
||||
# - 手动 dispatch(用于测试构建,不发版)
|
||||
#
|
||||
# 输出:
|
||||
# macOS arm64 .dmg + Windows x64 .msi/.exe + Linux x64 .deb/.rpm/.AppImage,自动作为 GitHub Release 资产上传。
|
||||
# macOS arm64/x64 .dmg + Windows x64 .msi/.exe + Linux x64 .deb/.rpm/.AppImage,自动作为 GitHub Release 资产上传。
|
||||
#
|
||||
# macOS 分发:
|
||||
# - 配好 APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID /
|
||||
@@ -32,6 +32,10 @@ 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
|
||||
- platform: windows-latest
|
||||
rust-target: x86_64-pc-windows-msvc
|
||||
updater-target: windows
|
||||
@@ -97,7 +101,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check Apple signing availability
|
||||
if: matrix.platform == 'macos-latest' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri')
|
||||
if: startsWith(matrix.platform, 'macos') && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri')
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -117,7 +121,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Import Apple Developer ID certificate
|
||||
if: matrix.platform == 'macos-latest'
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -158,7 +162,7 @@ jobs:
|
||||
echo "Imported Apple signing identity: $CERT_ID"
|
||||
|
||||
- name: Configure Apple notarization
|
||||
if: matrix.platform == 'macos-latest'
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
@@ -175,7 +179,7 @@ jobs:
|
||||
|
||||
# ── macOS:用我们自己的 build-mac.sh,统一处理签名、公证和 artifact 清理 ──
|
||||
- name: Build (macOS)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
working-directory: 'openless-all/app'
|
||||
env:
|
||||
INSTALL: '0' # CI 不要装到 /Applications,也不要 reset TCC
|
||||
@@ -213,6 +217,18 @@ jobs:
|
||||
npm run tauri -- build --bundles deb,rpm,appimage
|
||||
fi
|
||||
|
||||
- name: Disambiguate macOS updater bundle filename
|
||||
if: startsWith(matrix.platform, 'macos') && env.TAURI_SIGNING_PRIVATE_KEY != ''
|
||||
shell: bash
|
||||
working-directory: 'openless-all/app/src-tauri/target/release/bundle/macos'
|
||||
run: |
|
||||
if [ -f OpenLess.app.tar.gz ]; then
|
||||
mv OpenLess.app.tar.gz "OpenLess_${{ matrix.updater-arch }}.app.tar.gz"
|
||||
fi
|
||||
if [ -f OpenLess.app.tar.gz.sig ]; then
|
||||
mv OpenLess.app.tar.gz.sig "OpenLess_${{ matrix.updater-arch }}.app.tar.gz.sig"
|
||||
fi
|
||||
|
||||
- name: Write updater manifest
|
||||
if: env.TAURI_SIGNING_PRIVATE_KEY != ''
|
||||
shell: bash
|
||||
@@ -237,7 +253,7 @@ jobs:
|
||||
# 让"云端 artifact 一定干净"成为可验证的承诺。用户下载后再被本地浏览器
|
||||
# 加 quarantine 时,按 release notes 的 `xattr -cr` 一行即可消除。
|
||||
- name: Strip xattr / quarantine on macOS bundles
|
||||
if: matrix.platform == 'macos-latest'
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
shell: bash
|
||||
working-directory: 'openless-all/app/src-tauri/target/release/bundle'
|
||||
run: |
|
||||
@@ -250,24 +266,24 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
if: matrix.platform == 'macos-latest'
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openless-macos-arm64
|
||||
name: openless-macos-${{ matrix.updater-arch }}
|
||||
path: |
|
||||
openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload macOS updater artifacts
|
||||
if: matrix.platform == 'macos-latest' && env.TAURI_SIGNING_PRIVATE_KEY != ''
|
||||
if: startsWith(matrix.platform, 'macos') && env.TAURI_SIGNING_PRIVATE_KEY != ''
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openless-macos-arm64-updater
|
||||
name: openless-macos-${{ matrix.updater-arch }}-updater
|
||||
path: |
|
||||
openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz
|
||||
openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz.sig
|
||||
openless-all/app/src-tauri/target/release/bundle/latest-darwin-aarch64.json
|
||||
openless-all/app/src-tauri/target/release/bundle/latest-darwin-aarch64-mirror.json
|
||||
openless-all/app/src-tauri/target/release/bundle/latest-darwin-${{ matrix.updater-arch }}.json
|
||||
openless-all/app/src-tauri/target/release/bundle/latest-darwin-${{ matrix.updater-arch }}-mirror.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
@@ -325,7 +341,7 @@ jobs:
|
||||
prerelease: false
|
||||
# Matrix jobs all upload assets to the same release. Generate notes once
|
||||
# so macOS, Windows, and Linux jobs do not duplicate the release body.
|
||||
generate_release_notes: ${{ matrix.platform == 'macos-latest' }}
|
||||
generate_release_notes: ${{ matrix.updater-target == 'darwin' && matrix.updater-arch == 'aarch64' }}
|
||||
files: |
|
||||
openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz
|
||||
|
||||
Generated
+12
-2
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "openless-app",
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openless-app",
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.13",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-shell": "^2.0.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"i18next": "^26.0.8",
|
||||
@@ -1342,6 +1343,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-autostart": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz",
|
||||
"integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-shell": "^2.0.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"i18next": "^26.0.8",
|
||||
|
||||
@@ -17,7 +17,10 @@ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.me
|
||||
const bundleDir = fileURLToPath(new URL('../src-tauri/target/release/bundle/', import.meta.url));
|
||||
|
||||
const candidatesByTarget = {
|
||||
darwin: ['macos/OpenLess.app.tar.gz'],
|
||||
darwin: [
|
||||
`macos/OpenLess_${arch}.app.tar.gz`,
|
||||
'macos/OpenLess.app.tar.gz',
|
||||
],
|
||||
windows: ['nsis/OpenLess_*_x64-setup.exe', 'nsis/OpenLess*_x64-setup.exe'],
|
||||
linux: ['appimage/OpenLess_*.AppImage', 'appimage/OpenLess*.AppImage'],
|
||||
};
|
||||
|
||||
Generated
+73
-7
@@ -308,6 +308,17 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "auto-launch"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
|
||||
dependencies = [
|
||||
"dirs 4.0.0",
|
||||
"thiserror 1.0.69",
|
||||
"winreg 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -1090,13 +1101,33 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
|
||||
dependencies = [
|
||||
"dirs-sys 0.3.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
"dirs-sys 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users 0.4.6",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1107,7 +1138,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -3278,6 +3309,7 @@ dependencies = [
|
||||
"simplelog",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
@@ -4042,6 +4074,17 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
@@ -5026,7 +5069,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"embed_plist",
|
||||
"getrandom 0.3.4",
|
||||
@@ -5076,7 +5119,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"glob",
|
||||
"heck 0.5.0",
|
||||
"json-patch",
|
||||
@@ -5148,6 +5191,20 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-autostart"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
|
||||
dependencies = [
|
||||
"auto-launch",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.5"
|
||||
@@ -5191,7 +5248,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -5742,7 +5799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"libappindicator",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
@@ -6927,6 +6984,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.52.0"
|
||||
@@ -7057,7 +7123,7 @@ dependencies = [
|
||||
"block2 0.6.2",
|
||||
"cookie",
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
"dirs 6.0.0",
|
||||
"dom_query",
|
||||
"dpi",
|
||||
"dunce",
|
||||
|
||||
@@ -18,6 +18,7 @@ tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"core:webview:default",
|
||||
"core:event:default",
|
||||
"shell:allow-open",
|
||||
"updater:default"
|
||||
"updater:default",
|
||||
"autostart:default"
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::permissions::{self, PermissionStatus};
|
||||
@@ -67,8 +67,17 @@ fn persist_settings<T: SettingsWriter>(coord: &T, prefs: UserPreferences) -> Res
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_settings(coord: CoordinatorState<'_>, prefs: UserPreferences) -> Result<(), String> {
|
||||
persist_settings(&*coord, prefs)
|
||||
pub fn set_settings(
|
||||
coord: CoordinatorState<'_>,
|
||||
app: AppHandle,
|
||||
prefs: UserPreferences,
|
||||
) -> Result<(), String> {
|
||||
// 广播给所有 webview。issue #205:QaPanel 跑在独立 webview,
|
||||
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
|
||||
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
|
||||
persist_settings(&*coord, prefs.clone())?;
|
||||
let _ = app.emit("prefs:changed", &prefs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1008,7 +1008,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
|
||||
let active_asr = CredentialsVault::get_active_asr();
|
||||
|
||||
if active_asr == "whisper" {
|
||||
if is_whisper_compatible_provider(&active_asr) {
|
||||
let (api_key, base_url, model) = read_whisper_credentials();
|
||||
let whisper = Arc::new(WhisperBatchASR::new(api_key, base_url, model));
|
||||
store_asr_for_session(
|
||||
@@ -1817,13 +1817,13 @@ fn ensure_microphone_permission(_inner: &Arc<Inner>) -> Result<(), String> {
|
||||
|
||||
fn ensure_asr_credentials() -> Result<(), String> {
|
||||
let active_asr = CredentialsVault::get_active_asr();
|
||||
if active_asr == "whisper" {
|
||||
if is_whisper_compatible_provider(&active_asr) {
|
||||
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("请先在设置中填写 Whisper ASR API Key".to_string());
|
||||
return Err("请先在设置中填写 ASR 服务商 API Key".to_string());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1836,6 +1836,29 @@ fn ensure_asr_credentials() -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露
|
||||
/// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。
|
||||
/// 新增 OpenAI 兼容 ASR 时只需在这里加一项。
|
||||
///
|
||||
/// 注:DashScope 的 Qwen3-ASR-Flash 不在此列——它用 MultiModalConversation
|
||||
/// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要
|
||||
/// 单独 ASR 客户端,留给 V2。
|
||||
fn is_whisper_compatible_provider(id: &str) -> bool {
|
||||
matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq")
|
||||
}
|
||||
|
||||
/// QA 路径专用:begin_qa_session 永远走 Volcengine 流式(低延迟要求),所以
|
||||
/// 凭据校验也只看 Volcengine 字段,不依赖 active_asr。dictation 路径请用
|
||||
/// `ensure_asr_credentials`。
|
||||
fn ensure_qa_volcengine_credentials() -> Result<(), String> {
|
||||
let creds = read_volc_credentials();
|
||||
if creds.app_id.trim().is_empty() || creds.access_token.trim().is_empty() {
|
||||
Err("请先在设置中填写火山引擎 ASR App Key 和 Access Key".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。
|
||||
/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。
|
||||
async fn polish_or_passthrough(
|
||||
@@ -2022,7 +2045,11 @@ async fn begin_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
// 2. 凭据缺失走静默 fallback:与 dictation 一致的"用户的话不丢"约定。
|
||||
// 缺火山凭据 → 后续 Recorder 仍会跑,只是 ASR 拿不到结果,end_qa_session
|
||||
// 会发 idle 事件关浮窗。
|
||||
if let Err(message) = ensure_asr_credentials() {
|
||||
// 注意:QA 强制走 Volcengine 流式(见下方注释),所以这里必须直接校验
|
||||
// Volcengine 字段,不能复用 `ensure_asr_credentials`——后者会按用户在设置
|
||||
// 里选的 active_asr 走 OpenAI 兼容分支,让 QA 把 `asr.api_key` 当成必要项,
|
||||
// 或在 Volcengine 凭据其实为空时误判通过。Codex P1,PR #213。
|
||||
if let Err(message) = ensure_qa_volcengine_credentials() {
|
||||
log::warn!("[coord] QA: ASR credentials missing: {message}");
|
||||
finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}"));
|
||||
return Err(message);
|
||||
|
||||
@@ -60,6 +60,13 @@ pub fn run() {
|
||||
}))
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
// 跨平台开机自启:mac 写 LaunchAgent plist,linux 写 ~/.config/autostart/*.desktop,
|
||||
// windows 写 HKCU\Software\Microsoft\Windows\CurrentVersion\Run。前端 toggle 直接
|
||||
// 调插件 isEnabled / enable / disable,不维持本地 prefs,让 OS 当唯一真相。issue #194。
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))
|
||||
.manage(coordinator.clone())
|
||||
.setup(move |app| {
|
||||
// Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。
|
||||
|
||||
@@ -38,15 +38,15 @@ export const en: typeof zhCN = {
|
||||
thinking: 'Thinking…',
|
||||
error: 'Something went wrong. Please try again.',
|
||||
errorRetry: 'Retry',
|
||||
errorRetryHint: 'Press Option again to retry.',
|
||||
errorRetryHint: 'Press {{recordHotkey}} again to retry.',
|
||||
pinTooltip: 'Pin (stay open)',
|
||||
unpinTooltip: 'Unpin',
|
||||
closeTooltip: 'Close',
|
||||
selectionPreview: 'From selected text:',
|
||||
emptyTitle: 'Press Option to ask',
|
||||
emptyDesc: 'Select text in any app, press Option once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.',
|
||||
recordingHint: 'Recording… press Option again to submit',
|
||||
statusIdle: 'Press Option to ask',
|
||||
emptyTitle: 'Press {{recordHotkey}} to ask',
|
||||
emptyDesc: 'Select text in any app, press {{recordHotkey}} once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.',
|
||||
recordingHint: 'Recording… press {{recordHotkey}} again to submit',
|
||||
statusIdle: 'Press {{recordHotkey}} to ask',
|
||||
statusRecording: 'Recording',
|
||||
statusThinking: 'Thinking',
|
||||
statusError: 'Error',
|
||||
@@ -268,6 +268,9 @@ export const en: typeof zhCN = {
|
||||
restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.',
|
||||
allowNonTsfFallbackLabel: 'Allow non-TSF fallback',
|
||||
allowNonTsfFallbackDesc: 'Windows only: if direct TSF insertion fails, allow Unicode SendInput, shortcut paste, or WM_PASTE. Turn off to verify that insertion is really coming from TSF.',
|
||||
startupAtBoot: 'Launch at login',
|
||||
startupAtBootDesc: 'Start OpenLess automatically when you sign in. macOS uses a LaunchAgent, Linux writes ~/.config/autostart, Windows writes HKCU\\Run (no admin required). See issue #194.',
|
||||
startupAtBootError: 'Failed to toggle launch at login: {{message}}',
|
||||
},
|
||||
providers: {
|
||||
llmTitle: 'LLM (polishing)',
|
||||
@@ -285,6 +288,8 @@ export const en: typeof zhCN = {
|
||||
custom: 'Custom',
|
||||
asrVolcengine: 'Volcengine bigasr',
|
||||
asrSiliconflow: 'SiliconFlow SenseVoice',
|
||||
asrZhipu: 'Zhipu GLM-ASR',
|
||||
asrGroq: 'Groq Whisper-large-v3',
|
||||
asrWhisper: 'OpenAI Whisper (compatible)',
|
||||
},
|
||||
volcengineAppKeyLabel: 'APP ID',
|
||||
|
||||
@@ -36,15 +36,15 @@ export const zhCN = {
|
||||
thinking: '思考中…',
|
||||
error: '出错了,请稍后再试。',
|
||||
errorRetry: '重试',
|
||||
errorRetryHint: '再按 Option 重新提问。',
|
||||
errorRetryHint: '再按 {{recordHotkey}} 重新提问。',
|
||||
pinTooltip: '固定(不自动关闭)',
|
||||
unpinTooltip: '取消固定',
|
||||
closeTooltip: '关闭',
|
||||
selectionPreview: '基于选中文本:',
|
||||
emptyTitle: '按 Option 开始提问',
|
||||
emptyDesc: '在任意 app 选中一段文字后,按一次 Option 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。',
|
||||
recordingHint: '录音中…再按一次 Option 结束并提问',
|
||||
statusIdle: '按 Option 提问',
|
||||
emptyTitle: '按 {{recordHotkey}} 开始提问',
|
||||
emptyDesc: '在任意 app 选中一段文字后,按一次 {{recordHotkey}} 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。',
|
||||
recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问',
|
||||
statusIdle: '按 {{recordHotkey}} 提问',
|
||||
statusRecording: '录音中',
|
||||
statusThinking: '思考中',
|
||||
statusError: '出错了',
|
||||
@@ -266,6 +266,9 @@ export const zhCN = {
|
||||
restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。',
|
||||
allowNonTsfFallbackLabel: '允许非 TSF 兜底',
|
||||
allowNonTsfFallbackDesc: '仅 Windows:TSF 直接上屏失败后,允许改用 Unicode SendInput、快捷键粘贴或 WM_PASTE。关闭后可验证是否真实使用 TSF 输入。',
|
||||
startupAtBoot: '开机自启',
|
||||
startupAtBootDesc: '登录后自动启动 OpenLess。macOS 写 LaunchAgent,Linux 写 ~/.config/autostart,Windows 写 HKCU\\Run(不需要管理员)。详见 issue #194。',
|
||||
startupAtBootError: '开机自启切换失败:{{message}}',
|
||||
},
|
||||
providers: {
|
||||
llmTitle: 'LLM 模型(润色)',
|
||||
@@ -283,6 +286,8 @@ export const zhCN = {
|
||||
custom: '自定义',
|
||||
asrVolcengine: '火山引擎 bigasr',
|
||||
asrSiliconflow: '硅基流动 SenseVoice',
|
||||
asrZhipu: '智谱 GLM-ASR',
|
||||
asrGroq: 'Groq Whisper-large-v3',
|
||||
asrWhisper: 'OpenAI Whisper(兼容)',
|
||||
},
|
||||
volcengineAppKeyLabel: 'APP ID',
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { marked } from 'marked';
|
||||
import { isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc';
|
||||
import type { QaChatMessage, QaStatePayload } from '../lib/types';
|
||||
import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc';
|
||||
import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types';
|
||||
import { getHotkeyTriggerLabel } from '../lib/hotkey';
|
||||
|
||||
const SELECTION_PREVIEW_MAX = 60;
|
||||
|
||||
@@ -23,7 +24,7 @@ marked.setOptions({ gfm: true, breaks: true });
|
||||
type Status = 'idle' | 'recording' | 'thinking' | 'error';
|
||||
|
||||
export function QaPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [messages, setMessages] = useState<QaChatMessage[]>([]);
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
@@ -33,6 +34,12 @@ export function QaPanel() {
|
||||
const [streamingAnswer, setStreamingAnswer] = useState<string>('');
|
||||
/** 录音电平:0..1。后端每帧 33ms 通过 qa:level emit。详见 issue #162。 */
|
||||
const [level, setLevel] = useState<number>(0);
|
||||
/** 用户当前的录音热键 label(如 "右 Option" / "Right Alt")。issue #205:
|
||||
* 原版硬编码 "Option",Windows 用户没这个键,文案失真。读 prefs 后由 i18n
|
||||
* 插值动态显示,平台与用户配置都能跟上。 */
|
||||
const [recordHotkeyLabel, setRecordHotkeyLabel] = useState<string>(() =>
|
||||
i18n.t('hotkey.fallback'),
|
||||
);
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
@@ -42,6 +49,7 @@ export function QaPanel() {
|
||||
let unlistenState: (() => void) | undefined;
|
||||
let unlistenDismiss: (() => void) | undefined;
|
||||
let unlistenLevel: (() => void) | undefined;
|
||||
let unlistenPrefs: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -111,14 +119,22 @@ export function QaPanel() {
|
||||
const levelHandle = await listen<{ level: number }>('qa:level', event => {
|
||||
setLevel(event.payload.level ?? 0);
|
||||
});
|
||||
// prefs:changed — 后端在 set_settings 后广播。issue #205:QA 浮窗在独立
|
||||
// webview,没有 HotkeySettingsContext;如果用户在主窗口改了录音键,
|
||||
// 浮窗里的 "{recordHotkey}" 文案必须立刻跟上,否则会一直停在旧值。
|
||||
const prefsHandle = await listen<UserPreferences>('prefs:changed', event => {
|
||||
setRecordHotkeyLabel(getHotkeyTriggerLabel(event.payload?.hotkey?.trigger));
|
||||
});
|
||||
if (cancelled) {
|
||||
stateHandle();
|
||||
dismissHandle();
|
||||
levelHandle();
|
||||
prefsHandle();
|
||||
} else {
|
||||
unlistenState = stateHandle;
|
||||
unlistenDismiss = dismissHandle;
|
||||
unlistenLevel = levelHandle;
|
||||
unlistenPrefs = prefsHandle;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[QaPanel] listener setup failed', error);
|
||||
@@ -129,6 +145,7 @@ export function QaPanel() {
|
||||
unlistenState?.();
|
||||
unlistenDismiss?.();
|
||||
unlistenLevel?.();
|
||||
unlistenPrefs?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -144,6 +161,29 @@ export function QaPanel() {
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, []);
|
||||
|
||||
// ── 读取用户当前的录音热键 label,给 i18n 插值用(issue #205)。
|
||||
// QaPanel 跑在独立 webview(label="qa"),没有 HotkeySettingsContext
|
||||
// 注入,所以直接走 IPC 拿一次 prefs。语言切换时 i18n.t('hotkey.fallback')
|
||||
// 也要跟着重算,所以 i18n.language 入依赖。
|
||||
useEffect(() => {
|
||||
if (!isTauri) {
|
||||
setRecordHotkeyLabel(i18n.t('hotkey.fallback'));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getSettings()
|
||||
.then(prefs => {
|
||||
if (cancelled) return;
|
||||
setRecordHotkeyLabel(getHotkeyTriggerLabel(prefs.hotkey?.trigger));
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('[QaPanel] load hotkey label failed', err);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [i18n.language]);
|
||||
|
||||
const onTogglePin = () => {
|
||||
const next = !pinned;
|
||||
setPinned(next);
|
||||
@@ -166,13 +206,26 @@ export function QaPanel() {
|
||||
<div style={shellStyle}>
|
||||
<Toolbar pinned={pinned} onTogglePin={onTogglePin} onClose={onClose} />
|
||||
<div ref={scrollRef} style={contentStyle}>
|
||||
{messages.length === 0 && status === 'idle' && <EmptyHint t={t} />}
|
||||
{messages.length === 0 && status === 'idle' && (
|
||||
<EmptyHint t={t} recordHotkey={recordHotkeyLabel} />
|
||||
)}
|
||||
{messages.length === 0 && status === 'recording' && (
|
||||
<RecordingHeader preview={selectionPreview} t={t} level={level} />
|
||||
<RecordingHeader
|
||||
preview={selectionPreview}
|
||||
t={t}
|
||||
level={level}
|
||||
recordHotkey={recordHotkeyLabel}
|
||||
/>
|
||||
)}
|
||||
<MessageList messages={messages} />
|
||||
{status === 'recording' && messages.length > 0 && (
|
||||
<TurnIndicator kind="recording" t={t} preview={selectionPreview} level={level} />
|
||||
<TurnIndicator
|
||||
kind="recording"
|
||||
t={t}
|
||||
preview={selectionPreview}
|
||||
level={level}
|
||||
recordHotkey={recordHotkeyLabel}
|
||||
/>
|
||||
)}
|
||||
{streamingAnswer && (
|
||||
<StreamingAssistantBubble markdown={streamingAnswer} />
|
||||
@@ -180,9 +233,11 @@ export function QaPanel() {
|
||||
{status === 'thinking' && !streamingAnswer && (
|
||||
<TurnIndicator kind="thinking" t={t} />
|
||||
)}
|
||||
{status === 'error' && <ErrorRow message={errorMsg} t={t} />}
|
||||
{status === 'error' && (
|
||||
<ErrorRow message={errorMsg} t={t} recordHotkey={recordHotkeyLabel} />
|
||||
)}
|
||||
</div>
|
||||
<StatusBar status={status} t={t} />
|
||||
<StatusBar status={status} t={t} recordHotkey={recordHotkeyLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -197,11 +252,16 @@ interface ToolbarProps {
|
||||
|
||||
function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
// 拖动靠 NSWindow.movableByWindowBackground=YES(lib.rs::make_qa_window_draggable_macos)
|
||||
// 在 AppKit 层处理。前端不需要 onMouseDown / data-tauri-drag-region。
|
||||
// 拖动 (issue #205):
|
||||
// - macOS: lib.rs::make_qa_window_draggable_macos 在 NSWindow 层把整窗口设
|
||||
// movableByWindowBackground=YES,所以 macOS 上整片背景都可拖。
|
||||
// - Windows: NSWindow 走不了;用 Tauri 标准 data-tauri-drag-region —— mousedown
|
||||
// 走 startDragging() → WM_NCLBUTTONDOWN(HTCAPTION),在 focus:false 浮窗上也能用。
|
||||
// 两条路径并存不冲突;data-tauri-drag-region 放在 toolbar 的空白 spacer 上,IconBtn
|
||||
// 作为 button 子元素仍然正常 click。
|
||||
return (
|
||||
<div style={toolbarStyle}>
|
||||
<div style={{ flex: 1, height: '100%' }} />
|
||||
<div data-tauri-drag-region style={{ flex: 1, height: '100%' }} />
|
||||
<IconBtn
|
||||
label={pinned ? t('qa.unpinTooltip') : t('qa.pinTooltip')}
|
||||
active={pinned}
|
||||
@@ -255,14 +315,20 @@ function IconBtn({ label, active, onClick, children }: IconBtnProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHint({ t }: { t: ReturnType<typeof useTranslation>['t'] }) {
|
||||
function EmptyHint({
|
||||
t,
|
||||
recordHotkey,
|
||||
}: {
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
recordHotkey: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={emptyHintStyle}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--ol-ink)' }}>
|
||||
{t('qa.emptyTitle')}
|
||||
{t('qa.emptyTitle', { recordHotkey })}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}>
|
||||
{t('qa.emptyDesc')}
|
||||
{t('qa.emptyDesc', { recordHotkey })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -272,10 +338,12 @@ function RecordingHeader({
|
||||
preview,
|
||||
t,
|
||||
level,
|
||||
recordHotkey,
|
||||
}: {
|
||||
preview: string;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
level: number;
|
||||
recordHotkey: string;
|
||||
}) {
|
||||
const truncated = useMemo(() => truncate(preview, SELECTION_PREVIEW_MAX), [preview]);
|
||||
return (
|
||||
@@ -290,7 +358,7 @@ function RecordingHeader({
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--ol-ink-2)' }}>
|
||||
<span style={recordingDotStyle} />
|
||||
{t('qa.recordingHint')}
|
||||
{t('qa.recordingHint', { recordHotkey })}
|
||||
</div>
|
||||
<LevelBar level={level} />
|
||||
</div>
|
||||
@@ -418,11 +486,13 @@ function TurnIndicator({
|
||||
preview,
|
||||
t,
|
||||
level,
|
||||
recordHotkey,
|
||||
}: {
|
||||
kind: 'recording' | 'thinking';
|
||||
preview?: string;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
level?: number;
|
||||
recordHotkey?: string;
|
||||
}) {
|
||||
if (kind === 'recording') {
|
||||
const truncated = preview ? truncate(preview, SELECTION_PREVIEW_MAX) : '';
|
||||
@@ -438,7 +508,7 @@ function TurnIndicator({
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--ol-ink-2)' }}>
|
||||
<span style={recordingDotStyle} />
|
||||
{t('qa.recordingHint')}
|
||||
{t('qa.recordingHint', { recordHotkey: recordHotkey ?? '' })}
|
||||
</div>
|
||||
<LevelBar level={level ?? 0} />
|
||||
</div>
|
||||
@@ -459,14 +529,18 @@ function TurnIndicator({
|
||||
function ErrorRow({
|
||||
message,
|
||||
t,
|
||||
recordHotkey,
|
||||
}: {
|
||||
message: string;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
recordHotkey: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={errorRowStyle}>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--ol-err)', lineHeight: 1.55 }}>{message}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>{t('qa.errorRetryHint')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
|
||||
{t('qa.errorRetryHint', { recordHotkey })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -474,15 +548,17 @@ function ErrorRow({
|
||||
function StatusBar({
|
||||
status,
|
||||
t,
|
||||
recordHotkey,
|
||||
}: {
|
||||
status: Status;
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
recordHotkey: string;
|
||||
}) {
|
||||
let label = '';
|
||||
let dotColor = 'transparent';
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
label = t('qa.statusIdle');
|
||||
label = t('qa.statusIdle', { recordHotkey });
|
||||
dotColor = 'rgba(0,0,0,0.18)';
|
||||
break;
|
||||
case 'recording':
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { disable as disableAutostart, enable as enableAutostart, isEnabled as isAutostartEnabled } from '@tauri-apps/plugin-autostart';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate';
|
||||
import { APP_VERSION_LABEL } from '../lib/appVersion';
|
||||
@@ -246,6 +247,7 @@ function RecordingSection() {
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
<AutostartRow />
|
||||
{capability.statusHint && (
|
||||
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
|
||||
{capability.statusHint}
|
||||
@@ -255,6 +257,65 @@ function RecordingSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop /
|
||||
// windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。
|
||||
function AutostartRow() {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
// 切 plist / 注册表失败时给用户看的错误。null = 没有失败/上次操作已成功。
|
||||
// 不渲染等于把失败吞掉 —— Windows 写 HKCU\Run 被组策略拦、macOS 写
|
||||
// LaunchAgent plist 权限不够 都是真实可能。issue #194。
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
isAutostartEnabled()
|
||||
.then(v => {
|
||||
if (!cancelled) {
|
||||
setEnabled(v);
|
||||
setLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[autostart] isEnabled failed', err);
|
||||
if (!cancelled) setLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onToggle = async (next: boolean) => {
|
||||
setEnabled(next);
|
||||
setError(null);
|
||||
try {
|
||||
if (next) await enableAutostart();
|
||||
else await disableAutostart();
|
||||
} catch (err) {
|
||||
console.error('[autostart] toggle failed', err);
|
||||
setEnabled(!next);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
label={t('settings.recording.startupAtBoot')}
|
||||
desc={t('settings.recording.startupAtBootDesc')}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{loaded ? <Toggle on={enabled} onToggle={onToggle} /> : null}
|
||||
{error && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-err)', marginTop: 4, lineHeight: 1.5 }}>
|
||||
{t('settings.recording.startupAtBootError', { message: error })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
@@ -289,11 +350,17 @@ type LlmPresetId = typeof LLM_PRESETS[number]['id'];
|
||||
|
||||
const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration';
|
||||
|
||||
// SiliconFlow ASR 暂未在后端实现(coordinator.rs 只路由 whisper / volcengine)。
|
||||
// 在后端接入前不暴露给用户,避免选了之后必然失败。重新启用见 issue #58 的 follow-up。
|
||||
// `volcengine` 走自建流式客户端;其余走 OpenAI 兼容 `/audio/transcriptions`
|
||||
// (`coordinator.rs::is_whisper_compatible_provider`)。新增兼容厂商:
|
||||
// 1. 在这里加一项 `{ id, nameKey, baseUrl, model }`;
|
||||
// 2. `coordinator.rs::is_whisper_compatible_provider` 加同名 id;
|
||||
// 3. 在 i18n 的 `settings.providers.presets.<nameKey>` 加文案。
|
||||
const ASR_PRESETS = [
|
||||
{ id: 'volcengine', nameKey: 'asrVolcengine' },
|
||||
{ id: 'whisper', nameKey: 'asrWhisper' },
|
||||
{ id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' },
|
||||
{ id: 'siliconflow', nameKey: 'asrSiliconflow', baseUrl: 'https://api.siliconflow.cn/v1', model: 'FunAudioLLM/SenseVoiceSmall' },
|
||||
{ id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' },
|
||||
{ id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' },
|
||||
{ id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' },
|
||||
] as const;
|
||||
|
||||
type AsrPresetId = typeof ASR_PRESETS[number]['id'];
|
||||
@@ -301,42 +368,100 @@ type AsrPresetId = typeof ASR_PRESETS[number]['id'];
|
||||
function ProvidersSection() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
// `*Provider` 立即跟随 <select> 改动(受控组件必须实时反映用户输入);
|
||||
// `committed*Provider` 才决定 CredentialField 的 key,仅在后端 active
|
||||
// 切换 + 默认值写完后再 commit。两者拆开是为了同时满足:
|
||||
// - <select> 立刻显示用户的选择(issue #220 P2:codex 指出受控选不应等 await)
|
||||
// - CredentialField 不要在后端 active 切完前 remount(issue #219:避免读到旧 entry)
|
||||
// `*SwitchSeq` 是 stale-write 守卫:用户 100ms 内连点两次时,先发的请求晚到不
|
||||
// 会覆盖后发的 commit。
|
||||
const [llmProvider, setLlmProvider] = useState<LlmPresetId>('ark');
|
||||
const [asrProvider, setAsrProvider] = useState<AsrPresetId>('volcengine');
|
||||
const [committedLlmProvider, setCommittedLlmProvider] = useState<LlmPresetId>('ark');
|
||||
const [committedAsrProvider, setCommittedAsrProvider] = useState<AsrPresetId>('volcengine');
|
||||
const llmSwitchSeqRef = useRef(0);
|
||||
const asrSwitchSeqRef = useRef(0);
|
||||
const [llmModelRevision, setLlmModelRevision] = useState(0);
|
||||
const [asrModelRevision, setAsrModelRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefs) return;
|
||||
const knownLlm = LLM_PRESETS.find(x => x.id === prefs.activeLlmProvider);
|
||||
setLlmProvider(knownLlm ? knownLlm.id : 'custom');
|
||||
const llmId = knownLlm ? knownLlm.id : 'custom';
|
||||
setLlmProvider(llmId);
|
||||
setCommittedLlmProvider(llmId);
|
||||
const knownAsr = ASR_PRESETS.find(x => x.id === prefs.activeAsrProvider);
|
||||
setAsrProvider(knownAsr ? knownAsr.id : 'volcengine');
|
||||
const asrId = knownAsr ? knownAsr.id : 'volcengine';
|
||||
setAsrProvider(asrId);
|
||||
setCommittedAsrProvider(asrId);
|
||||
}, [prefs]);
|
||||
|
||||
// issue #219 / #220 P2:
|
||||
// 1. 立刻 setLlmProvider —— 受控 <select> 必须反映用户最新选择。
|
||||
// 2. 用 seq 守卫每个 await:用户连点两次时旧请求晚到也不会盖掉新选择。
|
||||
// 3. 仅 setCommittedLlmProvider 之后 CredentialField 才 remount 读新 entry,
|
||||
// 此时后端 root.active.llm 已经是 id,lookup_account 落到正确 entry。
|
||||
// 4. endpoint/model 默认值仅在该 provider entry 该字段为空时才填,不覆盖用户自定义。
|
||||
const onLlmProviderChange = async (id: LlmPresetId) => {
|
||||
setLlmProvider(id);
|
||||
const seq = ++llmSwitchSeqRef.current;
|
||||
await setActiveLlmProvider(id);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
if (prefs) {
|
||||
const next = { ...prefs, activeLlmProvider: id };
|
||||
await updatePrefs(next);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
}
|
||||
const preset = LLM_PRESETS.find(p => p.id === id);
|
||||
if (preset?.baseUrl) {
|
||||
await setCredential('ark.endpoint', preset.baseUrl);
|
||||
const existing = await readCredential('ark.endpoint');
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('ark.endpoint', preset.baseUrl);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
setCommittedLlmProvider(id);
|
||||
};
|
||||
|
||||
const onAsrProviderChange = async (id: AsrPresetId) => {
|
||||
setAsrProvider(id);
|
||||
const seq = ++asrSwitchSeqRef.current;
|
||||
await setActiveAsrProvider(id);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (prefs) {
|
||||
const next = { ...prefs, activeAsrProvider: id };
|
||||
await updatePrefs(next);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
}
|
||||
// OpenAI 兼容厂商首次切换时预填 baseUrl / model 默认值,省得用户必踩
|
||||
// 「跨厂商 model 名根本不一样」的坑;但用户已自定义后就不再覆盖。
|
||||
// volcengine 走另一套凭据,跳过。
|
||||
const preset = ASR_PRESETS.find(p => p.id === id);
|
||||
if (preset && preset.baseUrl) {
|
||||
const existing = await readCredential('asr.endpoint');
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('asr.endpoint', preset.baseUrl);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
if (preset && preset.model) {
|
||||
const existing = await readCredential('asr.model');
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('asr.model', preset.model);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
setCommittedAsrProvider(id);
|
||||
};
|
||||
|
||||
const preset = LLM_PRESETS.find(p => p.id === llmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1];
|
||||
// preset 决定 placeholder 与 default —— 必须跟着 committed*Provider 走,
|
||||
// 否则受控 <select> 立刻切到新厂商,但凭据字段还在显示旧 entry,placeholder
|
||||
// 会先于实际数据切换、视觉上对不上。
|
||||
const preset = LLM_PRESETS.find(p => p.id === committedLlmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1];
|
||||
const asrPreset = ASR_PRESETS.find(p => p.id === committedAsrProvider);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -358,12 +483,12 @@ function ProvidersSection() {
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
<CredentialField key={`${llmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
|
||||
<CredentialField key={`${llmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
|
||||
<CredentialField key={`${committedLlmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
|
||||
<CredentialField key={`${committedLlmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
|
||||
placeholder={preset.baseUrl || 'https://your-endpoint/v1'} />
|
||||
<CredentialField key={`${llmProvider}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')} account="ark.model_id"
|
||||
<CredentialField key={`${committedLlmProvider}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')} account="ark.model_id"
|
||||
placeholder={preset.modelPlaceholder || 'model-name'} mono />
|
||||
<ProviderTools key={llmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />
|
||||
<ProviderTools key={committedLlmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -382,24 +507,24 @@ function ProvidersSection() {
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
{asrProvider === 'volcengine' ? (
|
||||
{committedAsrProvider === 'volcengine' ? (
|
||||
<>
|
||||
<CredentialField
|
||||
key={`${asrProvider}:app_key`}
|
||||
key={`${committedAsrProvider}:app_key`}
|
||||
label={t('settings.providers.volcengineAppKeyLabel')}
|
||||
account="volcengine.app_key"
|
||||
mono
|
||||
mask
|
||||
/>
|
||||
<CredentialField
|
||||
key={`${asrProvider}:access_key`}
|
||||
key={`${committedAsrProvider}:access_key`}
|
||||
label={t('settings.providers.volcengineAccessKeyLabel')}
|
||||
account="volcengine.access_key"
|
||||
mono
|
||||
mask
|
||||
/>
|
||||
<CredentialField
|
||||
key={`${asrProvider}:resource_id`}
|
||||
key={`${committedAsrProvider}:resource_id`}
|
||||
label={t('settings.providers.volcengineResourceIdLabel')}
|
||||
account="volcengine.resource_id"
|
||||
mono
|
||||
@@ -410,11 +535,12 @@ function ProvidersSection() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CredentialField key={`${asrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" mono mask />
|
||||
<CredentialField key={`${asrProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="asr.endpoint"
|
||||
placeholder="https://api.openai.com/v1" defaultValue="https://api.openai.com/v1" />
|
||||
<CredentialField key={`${asrProvider}:model:${asrModelRevision}`} label={t('settings.providers.modelLabel')} account="asr.model"
|
||||
placeholder="whisper-1" />
|
||||
<CredentialField key={`${committedAsrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" mono mask />
|
||||
<CredentialField key={`${committedAsrProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="asr.endpoint"
|
||||
placeholder={asrPreset?.baseUrl || 'https://api.openai.com/v1'}
|
||||
defaultValue={asrPreset?.baseUrl || undefined} />
|
||||
<CredentialField key={`${committedAsrProvider}:model:${asrModelRevision}`} label={t('settings.providers.modelLabel')} account="asr.model"
|
||||
placeholder={asrPreset?.model || 'whisper-1'} />
|
||||
<ProviderTools kind="asr" modelAccount="asr.model" onModelSelected={() => setAsrModelRevision(v => v + 1)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user