diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 6b8a2ba9..2c3588c8 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -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 diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 15112f79..a2085ffa 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -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", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 8bd82c33..0a243db6 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -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", diff --git a/openless-all/app/scripts/write-updater-manifest.mjs b/openless-all/app/scripts/write-updater-manifest.mjs index eb85a906..eb0e7762 100755 --- a/openless-all/app/scripts/write-updater-manifest.mjs +++ b/openless-all/app/scripts/write-updater-manifest.mjs @@ -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'], }; diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index a40216c3..cf0dc120 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -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", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index a56e3196..b39e017d 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -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"] } diff --git a/openless-all/app/src-tauri/capabilities/default.json b/openless-all/app/src-tauri/capabilities/default.json index a17c96af..b04dca4a 100644 --- a/openless-all/app/src-tauri/capabilities/default.json +++ b/openless-all/app/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "core:webview:default", "core:event:default", "shell:allow-open", - "updater:default" + "updater:default", + "autostart:default" ] } diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index c93da9ac..508000d3 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -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(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] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 2ef328b6..9cea8bd8 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1008,7 +1008,7 @@ async fn begin_session(inner: &Arc) -> 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) -> 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) -> 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); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4ee2d46e..8bf20894 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -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 按需显示。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 58220cbe..b4a3804a 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 7f021732..99380f66 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -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', diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 50ca1f15..5703ac82 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -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([]); const [status, setStatus] = useState('idle'); const [errorMsg, setErrorMsg] = useState(''); @@ -33,6 +34,12 @@ export function QaPanel() { const [streamingAnswer, setStreamingAnswer] = useState(''); /** 录音电平:0..1。后端每帧 33ms 通过 qa:level emit。详见 issue #162。 */ const [level, setLevel] = useState(0); + /** 用户当前的录音热键 label(如 "右 Option" / "Right Alt")。issue #205: + * 原版硬编码 "Option",Windows 用户没这个键,文案失真。读 prefs 后由 i18n + * 插值动态显示,平台与用户配置都能跟上。 */ + const [recordHotkeyLabel, setRecordHotkeyLabel] = useState(() => + 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('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() {
- {messages.length === 0 && status === 'idle' && } + {messages.length === 0 && status === 'idle' && ( + + )} {messages.length === 0 && status === 'recording' && ( - + )} {status === 'recording' && messages.length > 0 && ( - + )} {streamingAnswer && ( @@ -180,9 +233,11 @@ export function QaPanel() { {status === 'thinking' && !streamingAnswer && ( )} - {status === 'error' && } + {status === 'error' && ( + + )}
- +
); } @@ -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 (
-
+
['t'] }) { +function EmptyHint({ + t, + recordHotkey, +}: { + t: ReturnType['t']; + recordHotkey: string; +}) { return (
- {t('qa.emptyTitle')} + {t('qa.emptyTitle', { recordHotkey })}
- {t('qa.emptyDesc')} + {t('qa.emptyDesc', { recordHotkey })}
); @@ -272,10 +338,12 @@ function RecordingHeader({ preview, t, level, + recordHotkey, }: { preview: string; t: ReturnType['t']; level: number; + recordHotkey: string; }) { const truncated = useMemo(() => truncate(preview, SELECTION_PREVIEW_MAX), [preview]); return ( @@ -290,7 +358,7 @@ function RecordingHeader({ )}
- {t('qa.recordingHint')} + {t('qa.recordingHint', { recordHotkey })}
@@ -418,11 +486,13 @@ function TurnIndicator({ preview, t, level, + recordHotkey, }: { kind: 'recording' | 'thinking'; preview?: string; t: ReturnType['t']; level?: number; + recordHotkey?: string; }) { if (kind === 'recording') { const truncated = preview ? truncate(preview, SELECTION_PREVIEW_MAX) : ''; @@ -438,7 +508,7 @@ function TurnIndicator({ )}
- {t('qa.recordingHint')} + {t('qa.recordingHint', { recordHotkey: recordHotkey ?? '' })}
@@ -459,14 +529,18 @@ function TurnIndicator({ function ErrorRow({ message, t, + recordHotkey, }: { message: string; t: ReturnType['t']; + recordHotkey: string; }) { return (
{message}
-
{t('qa.errorRetryHint')}
+
+ {t('qa.errorRetryHint', { recordHotkey })} +
); } @@ -474,15 +548,17 @@ function ErrorRow({ function StatusBar({ status, t, + recordHotkey, }: { status: Status; t: ReturnType['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': diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index a6d17ab1..7ab68f90 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -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() { /> )} + {capability.statusHint && (
{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(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 ( + +
+ {loaded ? : null} + {error && ( +
+ {t('settings.recording.startupAtBootError', { message: error })} +
+ )} +
+
+ ); +} + function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) { return (