From 8ae83f5383d6e8e47f864d033c01bfd5dc71147d Mon Sep 17 00:00:00 2001 From: cooper Date: Tue, 19 May 2026 12:55:14 +0800 Subject: [PATCH] feat(onboarding): guide first-run ASR setup --- README.zh.md | 6 +- .../onboarding-startup-contract.test.mjs | 251 +++ .../app/src-tauri/src/asr/volcengine.rs | 6 +- openless-all/app/src-tauri/src/persistence.rs | 16 +- openless-all/app/src-tauri/src/types.rs | 23 +- openless-all/app/src/App.tsx | 163 +- .../app/src/components/FloatingShell.tsx | 35 +- .../app/src/components/Onboarding.tsx | 1609 +++++++++++++++-- .../app/src/components/SettingsModal.tsx | 14 +- openless-all/app/src/i18n/en.ts | 172 +- openless-all/app/src/i18n/ja.ts | 172 +- openless-all/app/src/i18n/ko.ts | 172 +- openless-all/app/src/i18n/zh-CN.ts | 172 +- openless-all/app/src/i18n/zh-TW.ts | 172 +- openless-all/app/src/lib/ipc.ts | 66 +- openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 1 + openless-all/app/src/pages/Settings.tsx | 88 +- .../design_handoff_openless/pages.jsx | 2 +- 19 files changed, 2804 insertions(+), 337 deletions(-) create mode 100644 openless-all/app/scripts/onboarding-startup-contract.test.mjs diff --git a/README.zh.md b/README.zh.md index d1737e31..4d1d745f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -107,7 +107,7 @@ OpenLess 是一个跨平台(macOS & Windows)语音输入应用,对标 [Typ OpenLess 想做的是同一类体验,但是: - **完全开源、本地优先**。代码在仓库里,所有数据写在你的机器上。 -- **自带云凭据**。火山引擎 ASR + Ark / DeepSeek 兼容 chat-completions,不强绑某家。 +- **自备云凭据**。火山引擎 ASR + Ark / DeepSeek 兼容 chat-completions,不强绑某家。 - **专门为 AI prompt 优化**。「清晰结构」模式会把零散口语补成有上下文、有约束、有要求的 prompt,复制粘贴就能直接喂给 ChatGPT / Claude / Cursor。 - **不会替你回答**。模型只整理你的话,不会把「我们这个应用还有哪些功能没做?」变成一份功能清单——只会补成一句通顺的问题,让你拿去问真正的 AI。 @@ -131,8 +131,8 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI | 工具 | 形态 | OpenLess 的差异 | | --- | --- | --- | -| [Typeless](https://www.typeless.com/) | 闭源 macOS / Windows / iOS,订阅制 | 开源;专门暴露 AI prompt 模式;自带 ASR + LLM 凭据;数据和词典留在本机 | -| [Wispr Flow](https://wisprflow.ai) | 闭源 macOS / Windows,订阅制 | 开源;自带 ASR + LLM 凭据;提示词处理原则透明可改 | +| [Typeless](https://www.typeless.com/) | 闭源 macOS / Windows / iOS,订阅制 | 开源;专门暴露 AI prompt 模式;自备 ASR + LLM 凭据;数据和词典留在本机 | +| [Wispr Flow](https://wisprflow.ai) | 闭源 macOS / Windows,订阅制 | 开源;自备 ASR + LLM 凭据;提示词处理原则透明可改 | | [Lazy](https://heylazy.com) | 闭源笔记/捕获工具 | 不做笔记容器,专做「插入到任意输入框」 | | [Superwhisper](https://superwhisper.com) | 闭源 macOS,订阅制 | 开源;目前云端 ASR 优先,本地 ASR 在 roadmap | diff --git a/openless-all/app/scripts/onboarding-startup-contract.test.mjs b/openless-all/app/scripts/onboarding-startup-contract.test.mjs new file mode 100644 index 00000000..ca8e3f9f --- /dev/null +++ b/openless-all/app/scripts/onboarding-startup-contract.test.mjs @@ -0,0 +1,251 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const appTsx = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8"); +const onboardingTsx = await readFile( + new URL("../src/components/Onboarding.tsx", import.meta.url), + "utf8", +); +const floatingShellTsx = await readFile( + new URL("../src/components/FloatingShell.tsx", import.meta.url), + "utf8", +); +const settingsModalTsx = await readFile( + new URL("../src/components/SettingsModal.tsx", import.meta.url), + "utf8", +); +const settingsTsx = await readFile(new URL("../src/pages/Settings.tsx", import.meta.url), "utf8"); +const frontendTypesTs = await readFile(new URL("../src/lib/types.ts", import.meta.url), "utf8"); +const rustTypesRs = await readFile(new URL("../src-tauri/src/types.rs", import.meta.url), "utf8"); + +function assertIncludes(source, expected, message) { + assert.ok(source.includes(expected), message); +} + +assertIncludes( + onboardingTsx, + "export const ONBOARDING_COMPLETE_KEY = 'openless:onboarding-complete:v3';", + "new onboarding flow must not be skipped by the old v2 completion marker", +); + +assertIncludes( + onboardingTsx, + "export const REQUIRED_ONBOARDING_VERSION = 3;", + "startup must compare against a persisted onboarding version", +); + +assert.match( + appTsx, + /async function resolveStartupGate\(\): Promise \{\s*const prefs = await getSettings\(\)\.catch\(\(\) => null\);\s*if \(!onboardingMarkedComplete\(prefs\)\) \{\s*return 'onboarding';\s*\}/s, + "startup must read persisted preferences before provider readiness can bypass onboarding", +); + +assert.match( + appTsx, + /function onboardingMarkedComplete\(prefs: Pick \| null\) \{\s*if \(prefs\) \{\s*return prefs\.onboardingVersion >= REQUIRED_ONBOARDING_VERSION;\s*\}/s, + "startup must require the current onboarding version in persisted preferences", +); + +assertIncludes( + appTsx, + "if (prefs.startMinimized && gate !== 'onboarding') return;", + "startMinimized must not hide a required onboarding window", +); + +assertIncludes( + appTsx, + "initialSettings={postOnboardingSettingsSection !== undefined}", + "main shell must open settings after onboarding when requested", +); + +assertIncludes( + appTsx, + "initialSettingsSection={postOnboardingSettingsSection}", + "main shell must receive the requested settings section after onboarding", +); + +assertIncludes( + appTsx, + "window.sessionStorage.setItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY, '1');", + "settings jump from onboarding must not be covered by the provider setup prompt", +); + +assert.doesNotMatch( + appTsx, + /asrConfigured\s*&&\s*credentials\.value\.llmConfigured/, + "startup gate must not treat provider readiness as a replacement for onboarding", +); + +assertIncludes( + onboardingTsx, + "onboardingVersion: REQUIRED_ONBOARDING_VERSION,", + "completing onboarding must persist the current onboarding version", +); + +assert.match( + onboardingTsx, + /const asrReady = Boolean\(credentials\?\.volcengineConfigured \|\| asrSaveState === 'saved'\);/, + "onboarding ASR step must only consider Volcengine online ASR ready", +); + +assert.match( + onboardingTsx, + /const LOCAL_ASR_PROVIDER_IDS = new Set\(\['local-qwen3', 'foundry-local-whisper'\]\);/, + "onboarding must recognize local ASR providers", +); + +assert.match( + onboardingTsx, + /setActiveAsrProvider\(VOLCENGINE_PROVIDER_ID\)/, + "onboarding must switch local ASR back to the online default", +); + +assertIncludes( + onboardingTsx, + "Resource ID: volc.seedasr.sauc.duration", + "onboarding must visibly remind users which Volcengine Resource ID is expected", +); + +assertIncludes( + onboardingTsx, + "https://console.volcengine.com/auth/login/", + "Volcengine guide must link directly to console login", +); + +assertIncludes( + onboardingTsx, + "https://console.volcengine.com/speech/app?opt=create", + "Volcengine guide must link directly to legacy app creation", +); + +assertIncludes( + onboardingTsx, + "https://console.volcengine.com/speech/service/10038?AppID=&opt=create", + "Volcengine guide must link directly to the Doubao streaming ASR 2.0 management page", +); + +assertIncludes( + onboardingTsx, + "onboarding.hig.asr.volcGuide", + "ASR onboarding must expose a compact Volcengine setup guide", +); + +assertIncludes( + onboardingTsx, + "到底部复制 AppID 和 Access Token", + "Volcengine guide must tell users where to find AppID and Access Token", +); + +assertIncludes( + onboardingTsx, + "openSettingsSection?: 'providers' | 'advanced';", + "onboarding must be able to request a settings jump after completion", +); + +assertIncludes( + onboardingTsx, + "onboarding.hig.asr.otherOnline", + "onboarding ASR step must offer a route for other online ASR", +); + +assertIncludes( + onboardingTsx, + "onboarding.hig.asr.localAi", + "onboarding ASR step must offer a separate route for local AI", +); + +assert.doesNotMatch( + onboardingTsx, + /activeSlide === 'asr' && !asrReady[\s\S]{0,260}onboarding\.hig\.asr\.(otherOnline|localAi)/, + "other ASR and local AI routes must remain visible even when Volcengine credentials are already ready", +); + +assertIncludes( + onboardingTsx, + "complete({ openSettingsSection: 'providers' })", + "other online ASR route must jump directly to provider settings", +); + +assertIncludes( + onboardingTsx, + "complete({ openSettingsSection: 'advanced' })", + "local AI route must jump directly to advanced settings", +); + +assertIncludes( + frontendTypesTs, + "onboardingVersion: number;", + "frontend preferences must include onboardingVersion", +); + +assertIncludes( + rustTypesRs, + "pub onboarding_version: u32,", + "persisted Rust preferences must include onboarding_version", +); + +assertIncludes( + rustTypesRs, + "onboarding_version: 0,", + "new profiles must default to incomplete onboarding", +); + +assertIncludes( + floatingShellTsx, + "onStartOnboarding?: () => void;", + "FloatingShell must expose the manual onboarding callback", +); + +assertIncludes( + floatingShellTsx, + "initialSettingsSection?: SettingsSectionId;", + "FloatingShell must accept an initial settings section", +); + +assertIncludes( + floatingShellTsx, + "useState(initialSettingsSection)", + "FloatingShell must seed the settings modal with the requested section", +); + +assertIncludes( + settingsModalTsx, + "onStartOnboarding?: () => void;", + "SettingsModal must pass through the manual onboarding callback", +); + +assertIncludes( + settingsTsx, + "onStartOnboarding?: () => void;", + "Settings must accept the manual onboarding callback", +); + +assertIncludes( + settingsTsx, + "export type SettingsSectionId = 'setup' | 'recording'", + "Settings must have a setup section before recording", +); + +assertIncludes( + settingsTsx, + "const SECTION_ORDER: SettingsSectionId[] = ['setup', 'recording'", + "Settings setup section must be visible in the left rail", +); + +assertIncludes( + settingsTsx, + "{section === 'setup' && }", + "Settings setup section must render the onboarding entry", +); + +assertIncludes( + settingsTsx, + "onboardingVersion: 0,", + "manual onboarding entry must reset persisted onboarding completion", +); + +assertIncludes( + settingsTsx, + "window.localStorage.removeItem(ONBOARDING_COMPLETE_KEY);", + "manual onboarding entry must clear the legacy webview completion marker", +); diff --git a/openless-all/app/src-tauri/src/asr/volcengine.rs b/openless-all/app/src-tauri/src/asr/volcengine.rs index 83708fe2..d8a347de 100644 --- a/openless-all/app/src-tauri/src/asr/volcengine.rs +++ b/openless-all/app/src-tauri/src/asr/volcengine.rs @@ -40,7 +40,7 @@ pub struct VolcengineCredentials { impl VolcengineCredentials { pub fn default_resource_id() -> &'static str { - "volc.bigasr.sauc.duration" + "volc.seedasr.sauc.duration" } } @@ -734,10 +734,10 @@ mod tests { } #[test] - fn default_resource_id_is_sauc_duration() { + fn default_resource_id_is_seedasr_sauc_duration() { assert_eq!( VolcengineCredentials::default_resource_id(), - "volc.bigasr.sauc.duration" + "volc.seedasr.sauc.duration" ); } diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index cd0131a4..11b31875 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -388,14 +388,7 @@ impl Default for CredsActive { } fn creds_default_asr() -> String { - #[cfg(target_os = "windows")] - { - return crate::asr::local::foundry::PROVIDER_ID.into(); - } - #[cfg(not(target_os = "windows"))] - { - "volcengine".into() - } + "volcengine".into() } fn creds_default_llm() -> String { "ark".into() @@ -2260,13 +2253,18 @@ impl CredentialsVault { #[cfg(test)] mod tests { use super::{ - chunk_json_payload, list_vocab_presets, read_preferences, save_vocab_presets, + chunk_json_payload, creds_default_asr, list_vocab_presets, read_preferences, save_vocab_presets, sync_style_pack_preferences, validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS, }; use crate::types::{builtin_style_packs, CustomStylePrompts, VocabPreset, VocabPresetStore}; use std::fs; use std::path::PathBuf; + #[test] + fn credentials_default_asr_starts_on_cloud_provider() { + assert_eq!(creds_default_asr(), "volcengine"); + } + #[test] fn credential_payload_chunks_stay_under_windows_blob_limit() { let payload = format!( diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 0b8b3752..0737f4f5 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -522,6 +522,8 @@ pub struct UserPreferences { pub custom_style_prompts: CustomStylePrompts, pub launch_at_login: bool, pub show_capsule: bool, + #[serde(default)] + pub onboarding_version: u32, /// 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 #[serde(default)] pub mute_during_recording: bool, @@ -717,14 +719,7 @@ fn default_foundry_local_runtime_source() -> String { } fn default_active_asr_provider() -> String { - #[cfg(target_os = "windows")] - { - return crate::asr::local::foundry::PROVIDER_ID.into(); - } - #[cfg(not(target_os = "windows"))] - { - "volcengine".into() - } + "volcengine".into() } #[derive(Debug, Clone, Deserialize)] @@ -743,6 +738,8 @@ struct UserPreferencesWire { launch_at_login: bool, show_capsule: bool, #[serde(default)] + onboarding_version: u32, + #[serde(default)] mute_during_recording: bool, #[serde(default)] microphone_device_name: String, @@ -820,6 +817,7 @@ impl Default for UserPreferencesWire { custom_style_prompts: prefs.custom_style_prompts, launch_at_login: prefs.launch_at_login, show_capsule: prefs.show_capsule, + onboarding_version: prefs.onboarding_version, mute_during_recording: prefs.mute_during_recording, microphone_device_name: prefs.microphone_device_name, active_asr_provider: prefs.active_asr_provider, @@ -895,6 +893,7 @@ impl<'de> Deserialize<'de> for UserPreferences { custom_style_prompts: wire.custom_style_prompts, launch_at_login: wire.launch_at_login, show_capsule: wire.show_capsule, + onboarding_version: wire.onboarding_version, mute_during_recording: wire.mute_during_recording, microphone_device_name: wire.microphone_device_name, active_asr_provider: wire.active_asr_provider, @@ -1582,6 +1581,7 @@ impl Default for UserPreferences { custom_style_prompts: CustomStylePrompts::default(), launch_at_login: false, show_capsule: true, + onboarding_version: 0, mute_during_recording: false, microphone_device_name: String::new(), active_asr_provider: default_active_asr_provider(), @@ -2214,6 +2214,13 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + #[test] + fn active_asr_provider_defaults_to_cloud_provider() { + let prefs = UserPreferences::default(); + + assert_eq!(prefs.active_asr_provider, "volcengine"); + } + #[test] fn missing_non_tsf_insertion_fallback_pref_defaults_to_enabled() { let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 08ce2665..ec50cfc0 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -2,7 +2,12 @@ import { useEffect, useState } from 'react'; import { AutoUpdateGate } from './components/AutoUpdateGate'; import { Capsule } from './components/Capsule'; import { FloatingShell } from './components/FloatingShell'; -import { Onboarding } from './components/Onboarding'; +import { + ONBOARDING_COMPLETE_KEY, + Onboarding, + REQUIRED_ONBOARDING_VERSION, + type OnboardingCompleteOptions, +} from './components/Onboarding'; import { detectOS } from './components/WindowChrome'; import { checkAccessibilityPermission, @@ -12,12 +17,15 @@ import { handleWindowHotkeyEvent, isTauri, } from './lib/ipc'; +import type { PermissionStatus, UserPreferences } from './lib/types'; import { isWindowHotkeyKeyboardCandidate, windowMouseHotkeyCode, } from './lib/windowHotkeyFallback'; import { QaPanel } from './pages/QaPanel'; +import type { SettingsSectionId } from './pages/Settings'; import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; +import { PROVIDER_SETUP_PROMPT_DEFERRED_KEY } from './lib/providerSetup'; interface AppProps { isCapsule: boolean; @@ -35,8 +43,8 @@ export function App({ isCapsule, isQa }: AppProps) { } const os = detectOS(); - // Windows 启动不应被权限探测阻塞首屏。 - const [gate, setGate] = useState(isTauri ? 'checking' : 'ready'); + const [gate, setGate] = useState('checking'); + const [postOnboardingSettingsSection, setPostOnboardingSettingsSection] = useState(); useEffect(() => { if (!isTauri) return; @@ -51,7 +59,7 @@ export function App({ isCapsule, isQa }: AppProps) { // 的最后一条路径(Rust log 里看不到,因为走的是 plugin-window 的 IPC)。 try { const prefs = await getSettings(); - if (prefs.startMinimized) return; + if (prefs.startMinimized && gate !== 'onboarding') return; } catch (err) { // 安全侧默认 = 不弹窗。Rust 端 get_settings 签名是 // `pub fn get_settings(...) -> UserPreferences`(非 Result),所以 @@ -81,55 +89,26 @@ export function App({ isCapsule, isQa }: AppProps) { }, [gate, os]); useEffect(() => { - if (!isTauri) return; let cancelled = false; - if (os === 'win') { - // 超时保护:50 次 × 200ms = 10s。hotkey hook 永远 starting(被反作弊 / EDR - // / UAC 拦)时不让 UI 死锁灰屏,过 10s 强 setGate('ready') 让用户进 - // Permissions 页看 hotkey_status.lastError 处理。详见 issue #163。 - const POLL_INTERVAL_MS = 200; - const POLL_MAX_ATTEMPTS = 50; - const pollHotkeyStatus = async () => { - let attempts = 0; - while (!cancelled && attempts < POLL_MAX_ATTEMPTS) { - attempts += 1; - const status = await getHotkeyStatus(); - if (cancelled) return; - if (status.state !== 'starting') { - setGate('ready'); - return; - } - await new Promise(resolve => window.setTimeout(resolve, POLL_INTERVAL_MS)); - } - if (!cancelled) { - console.warn( - `[startup] hotkey gate timed out after ${POLL_MAX_ATTEMPTS * POLL_INTERVAL_MS}ms; forcing ready so user can reach Permissions page` - ); - setGate('ready'); - } - }; - void pollHotkeyStatus().catch(error => { - console.warn('[startup] hotkey status polling failed', error); - if (!cancelled) { - setGate('ready'); - } - }); - return () => { - cancelled = true; - }; - } + const decideGate = async () => { + if (isTauri && os === 'win') { + await waitForWindowsHotkeyGate(() => cancelled); + if (cancelled) return; + } + const nextGate = await resolveStartupGate(); + if (!cancelled) { + setGate(nextGate); + } + }; + + void decideGate().catch(error => { + console.warn('[startup] startup gate failed, showing onboarding fallback', error); + if (!cancelled) { + setGate('onboarding'); + } + }); - (async () => { - const [a, m] = await Promise.all([ - checkAccessibilityPermission(), - checkMicrophonePermission(), - ]); - if (cancelled) return; - const aOk = a === 'granted' || a === 'notApplicable'; - const mOk = m === 'granted' || m === 'notApplicable'; - setGate(aOk && mOk ? 'ready' : 'onboarding'); - })(); return () => { cancelled = true; }; @@ -171,14 +150,96 @@ export function App({ isCapsule, isQa }: AppProps) { if (gate === 'checking') { return ; } + const startOnboarding = () => { + try { + window.localStorage.removeItem(ONBOARDING_COMPLETE_KEY); + } catch { + // Ignore unavailable localStorage; persisted preferences are the source of truth. + } + setPostOnboardingSettingsSection(undefined); + setGate('onboarding'); + }; + const finishOnboarding = (options?: OnboardingCompleteOptions) => { + const nextSection = options?.openSettingsSection; + setPostOnboardingSettingsSection(nextSection); + if (nextSection) { + try { + window.sessionStorage.setItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY, '1'); + } catch { + // Avoid covering the settings jump with the provider setup prompt when sessionStorage is unavailable. + } + } + setGate('ready'); + }; return ( - {gate === 'onboarding' ? setGate('ready')} /> : } + {gate === 'onboarding' + ? + : ( + + )} {gate === 'ready' && } ); } +async function waitForWindowsHotkeyGate(isCancelled: () => boolean) { + const POLL_INTERVAL_MS = 200; + const POLL_MAX_ATTEMPTS = 50; + let attempts = 0; + while (!isCancelled() && attempts < POLL_MAX_ATTEMPTS) { + attempts += 1; + const status = await getHotkeyStatus(); + if (status.state !== 'starting') return; + await new Promise(resolve => window.setTimeout(resolve, POLL_INTERVAL_MS)); + } + if (!isCancelled()) { + console.warn( + `[startup] hotkey gate timed out after ${POLL_MAX_ATTEMPTS * POLL_INTERVAL_MS}ms; continuing to startup onboarding decision` + ); + } +} + +async function resolveStartupGate(): Promise { + const prefs = await getSettings().catch(() => null); + if (!onboardingMarkedComplete(prefs)) { + return 'onboarding'; + } + + const [accessibility, microphone] = await Promise.allSettled([ + checkAccessibilityPermission(), + checkMicrophonePermission(), + ]); + + const aOk = accessibility.status === 'fulfilled' + ? permissionReady(accessibility.value) + : false; + const mOk = microphone.status === 'fulfilled' + ? permissionReady(microphone.value) + : false; + if (!aOk || !mOk) return 'onboarding'; + return 'ready'; +} + +function permissionReady(status: PermissionStatus) { + return status === 'granted' || status === 'notApplicable'; +} + +function onboardingMarkedComplete(prefs: Pick | null) { + if (prefs) { + return prefs.onboardingVersion >= REQUIRED_ONBOARDING_VERSION; + } + try { + return window.localStorage.getItem(ONBOARDING_COMPLETE_KEY) === '1'; + } catch { + return false; + } +} + function StartupShell() { // 用透明背景:main window 是 transparent + macOSPrivateApi(NSVisualEffectView 磨砂)。 // 之前用 linear-gradient(rgba(245,245,247,0.96)...) 会盖过 macOS vibrancy,启动时 diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 7abce175..36435e47 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -53,21 +53,47 @@ interface FloatingShellProps { os?: OS; initialTab?: AppTab; initialSettings?: boolean; + initialSettingsSection?: SettingsSectionId; + onStartOnboarding?: () => void; } -export function FloatingShell({ os: osProp, initialTab = 'overview', initialSettings = false }: FloatingShellProps) { +export function FloatingShell({ + os: osProp, + initialTab = 'overview', + initialSettings = false, + initialSettingsSection, + onStartOnboarding, +}: FloatingShellProps) { const os = osProp ?? detectOS(); return ( - + ); } -function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initialTab: AppTab; initialSettings: boolean }) { +function FloatingShellBody({ + os, + initialTab, + initialSettings, + initialSettingsSection, + onStartOnboarding, +}: { + os: OS; + initialTab: AppTab; + initialSettings: boolean; + initialSettingsSection?: SettingsSectionId; + onStartOnboarding?: () => void; +}) { const { t } = useTranslation(); const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings); - const [settingsInitialSection, setSettingsInitialSection] = useState(); + const [settingsInitialSection, setSettingsInitialSection] = useState(initialSettingsSection); const [providerPromptOpen, setProviderPromptOpen] = useState(false); const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false); @@ -387,6 +413,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia os={os} initialSettingsSection={settingsInitialSection} onClose={() => setSettingsOpen(false)} + onStartOnboarding={onStartOnboarding} /> } diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index 2d23ed21..4c93f783 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -1,72 +1,315 @@ -// Onboarding.tsx — 首次运行权限引导。 -// -// 触发条件:App.tsx 启动检查 accessibility + microphone,任一未授权则渲染本组件而非主 Shell。 -// 与 Swift `Sources/OpenLessApp/Onboarding/` 同语义,但简化为单页三步。 - -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; +import { Icon } from './Icon'; +import { ShortcutRecorder } from './ShortcutRecorder'; import { checkAccessibilityPermission, checkMicrophonePermission, + getCredentials, + getHotkeyStatus, + isTauri, + openExternal, openSystemSettings, + readCredential, requestAccessibilityPermission, requestMicrophonePermission, + setActiveAsrProvider, + setActiveStylePack, + setCredential, + setDictationHotkey, + startMicrophoneLevelMonitor, + stopMicrophoneLevelMonitor, } from '../lib/ipc'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; -import type { PermissionStatus } from '../lib/types'; +import { formatComboLabel } from '../lib/hotkey'; +import type { CredentialsStatus, HotkeyStatus, PermissionStatus, ShortcutBinding } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; +export const ONBOARDING_COMPLETE_KEY = 'openless:onboarding-complete:v3'; +export const REQUIRED_ONBOARDING_VERSION = 3; + +const VOLCENGINE_SETUP_URL = 'https://github.com/appergb/openless/blob/main/docs/volcengine-setup.md'; +const VOLCENGINE_LOGIN_URL = 'https://console.volcengine.com/auth/login/'; +const VOLCENGINE_APP_CREATE_URL = 'https://console.volcengine.com/speech/app?opt=create'; +const VOLCENGINE_SERVICE_URL = 'https://console.volcengine.com/speech/service/10038?AppID=&opt=create'; +const BUILTIN_RAW_STYLE_PACK_ID = 'builtin.raw'; +const VOLCENGINE_PROVIDER_ID = 'volcengine'; +const VOLCENGINE_RESOURCE_ID = 'volc.seedasr.sauc.duration'; +const LOCAL_ASR_PROVIDER_IDS = new Set(['local-qwen3', 'foundry-local-whisper']); + +type SlideId = 'mic' | 'shortcut' | 'asr'; +type MicTestState = 'idle' | 'listening' | 'heard' | 'error'; +type HotkeyTestState = 'idle' | 'listening' | 'matched' | 'missed'; +type SaveState = 'idle' | 'saving' | 'saved' | 'error'; +type StatusTone = 'neutral' | 'success' | 'warning'; +type MotionPhase = 'settled' | 'leaving' | 'entering'; + +const SLIDES: SlideId[] = ['mic', 'shortcut', 'asr']; +const DEFAULT_SHORTCUT: ShortcutBinding = { primary: 'RightControl', modifiers: [] }; + +export interface OnboardingCompleteOptions { + openSettingsSection?: 'providers' | 'advanced'; +} + interface OnboardingProps { - onComplete: () => void; + onComplete: (options?: OnboardingCompleteOptions) => void; } export function Onboarding({ onComplete }: OnboardingProps) { const { t } = useTranslation(); + const { prefs, capability, loading: settingsLoading, updatePrefs } = useHotkeySettings(); + const [slideIndex, setSlideIndex] = useState(0); + const [motionPhase, setMotionPhase] = useState('settled'); const [accessibility, setAccessibility] = useState('notDetermined'); const [microphone, setMicrophone] = useState('notDetermined'); - const [busy, setBusy] = useState(false); - const refreshTimeoutRef = useRef(null); - const { capability } = useHotkeySettings(); + const [credentials, setCredentials] = useState(null); + const [hotkeyStatus, setHotkeyStatus] = useState(null); + const [permissionBusy, setPermissionBusy] = useState<'accessibility' | 'microphone' | null>(null); + const [micLevel, setMicLevel] = useState(0); + const [micMonitoring, setMicMonitoring] = useState(false); + const [micTestState, setMicTestState] = useState('idle'); + const [micTestError, setMicTestError] = useState(null); + const [hotkeyTestState, setHotkeyTestState] = useState('idle'); + const [shortcutEditorOpen, setShortcutEditorOpen] = useState(false); + const [volcGuideOpen, setVolcGuideOpen] = useState(false); + const [asrSaveState, setAsrSaveState] = useState('idle'); + const [asrForm, setAsrForm] = useState({ appKey: '', accessKey: '' }); + const [viewport, setViewport] = useState(() => ({ width: window.innerWidth, height: window.innerHeight })); + const autoMicStartedRef = useRef(false); + const micUnlistenRef = useRef<(() => void) | null>(null); + const micMockTimerRef = useRef(null); + const hotkeyResetTimerRef = useRef(null); + + const activeSlide = SLIDES[slideIndex]; + const isCompact = viewport.width < 640 || viewport.height < 560; + const isShortViewport = viewport.height < 520; + const microphoneReady = permissionReady(microphone); + const accessibilityReady = permissionReady(accessibility); + const shortcutBinding = prefs?.dictationHotkey ?? DEFAULT_SHORTCUT; + const shortcutConfigured = Boolean(shortcutBinding.primary); + const shortcutReady = shortcutConfigured && (!capability?.requiresAccessibilityPermission || accessibilityReady); + const asrReady = Boolean(credentials?.volcengineConfigured || asrSaveState === 'saved'); + const asrHasInput = Boolean(asrForm.appKey.trim() || asrForm.accessKey.trim()); + const asrFormValid = Boolean(asrForm.appKey.trim() && asrForm.accessKey.trim()); - const refresh = async () => { - const [a, m] = await Promise.all([ + const copy = useCallback((key: string, fallback: string) => ( + t(key, { defaultValue: fallback }) as string + ), [t]); + + const refreshStatus = useCallback(async () => { + const [a, m, c, h] = await Promise.allSettled([ checkAccessibilityPermission(), checkMicrophonePermission(), + getCredentials(), + getHotkeyStatus(), ]); - setAccessibility(a); - setMicrophone(m); - if ((a === 'granted' || a === 'notApplicable') && (m === 'granted' || m === 'notApplicable')) { - onComplete(); - } - }; + if (a.status === 'fulfilled') setAccessibility(a.value); + if (m.status === 'fulfilled') setMicrophone(m.value); + if (c.status === 'fulfilled') setCredentials(c.value); + if (h.status === 'fulfilled') setHotkeyStatus(h.value); + }, []); useEffect(() => { - refresh(); - const id = window.setInterval(refresh, 1000); - // 用户从系统设置切回来时立刻刷新 - const onFocus = () => refresh(); + void refreshStatus(); + const id = window.setInterval(() => void refreshStatus(), 2500); + const onFocus = () => void refreshStatus(); window.addEventListener('focus', onFocus); return () => { window.clearInterval(id); window.removeEventListener('focus', onFocus); - if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current); }; + }, [refreshStatus]); + + const ensureOnlineAsrDefault = useCallback(async () => { + const credentialsProvider = credentials?.activeAsrProvider ?? ''; + const prefsProvider = prefs?.activeAsrProvider ?? ''; + const credentialsOnLocal = isLocalAsrProvider(credentialsProvider); + const prefsOnLocal = isLocalAsrProvider(prefsProvider); + if (!credentialsOnLocal && !prefsOnLocal) return; + + if (credentialsOnLocal) { + await setActiveAsrProvider(VOLCENGINE_PROVIDER_ID); + } + if (prefsOnLocal) { + await updatePrefs(current => ({ + ...current, + activeAsrProvider: VOLCENGINE_PROVIDER_ID, + })); + } + await refreshStatus(); + }, [credentials?.activeAsrProvider, prefs?.activeAsrProvider, refreshStatus, updatePrefs]); + + useEffect(() => { + void ensureOnlineAsrDefault().catch(error => { + console.warn('[onboarding] failed to switch local ASR back to online default', error); + }); + }, [ensureOnlineAsrDefault]); + + useEffect(() => { + let cancelled = false; + async function loadVolcengineCredentials() { + const [appKey, accessKey] = await Promise.all([ + readCredential('volcengine.app_key').catch(() => null), + readCredential('volcengine.access_key').catch(() => null), + ]); + if (cancelled) return; + setAsrForm({ + appKey: appKey ?? '', + accessKey: accessKey ?? '', + }); + } + void loadVolcengineCredentials(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const onResize = () => setViewport({ width: window.innerWidth, height: window.innerHeight }); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + const stopMicTest = useCallback(() => { + micUnlistenRef.current?.(); + micUnlistenRef.current = null; + if (micMockTimerRef.current !== null) { + window.clearInterval(micMockTimerRef.current); + micMockTimerRef.current = null; + } + setMicLevel(0); + setMicMonitoring(false); + setMicTestState(current => current === 'heard' ? current : 'idle'); + void stopMicrophoneLevelMonitor(); + }, []); + + useEffect(() => () => { + micUnlistenRef.current?.(); + if (micMockTimerRef.current !== null) { + window.clearInterval(micMockTimerRef.current); + } + void stopMicrophoneLevelMonitor(); }, []); - const onGrantAccessibility = async () => { - setBusy(true); + const startMicTest = useCallback(async () => { + stopMicTest(); + setMicTestError(null); + setMicLevel(0); + setMicMonitoring(true); + setMicTestState('listening'); try { - await requestAccessibilityPermission(); - await openSystemSettings('accessibility'); - } finally { - setBusy(false); + let status = microphone; + if (!permissionReady(status)) { + status = await requestMicrophonePermission(); + setMicrophone(status); + } + if (!permissionReady(status)) { + setMicMonitoring(false); + setMicTestState('error'); + setMicTestError(copy('onboarding.micPermissionBlocked', '请先允许 OpenLess 使用麦克风。')); + if (status === 'denied' || status === 'restricted') { + await openSystemSettings('microphone'); + } + return; + } + + if (isTauri) { + const { listen } = await import('@tauri-apps/api/event'); + const unlisten = await listen<{ level: number }>('microphone:level', event => { + const level = Math.max(0, Math.min(1, event.payload.level ?? 0)); + setMicLevel(level); + if (level > 0.08) { + setMicTestState('heard'); + } + }); + micUnlistenRef.current = unlisten; + await startMicrophoneLevelMonitor(prefs?.microphoneDeviceName ?? ''); + } else { + micMockTimerRef.current = window.setInterval(() => { + const level = 0.2 + Math.random() * 0.5; + setMicLevel(level); + setMicTestState('heard'); + }, 120); + } + } catch (error) { + console.warn('[onboarding] microphone test failed', error); + setMicMonitoring(false); + setMicTestState('error'); + setMicTestError(error instanceof Error ? error.message : String(error)); + void stopMicrophoneLevelMonitor(); + } + }, [copy, microphone, prefs?.microphoneDeviceName, stopMicTest]); + + useEffect(() => { + if (activeSlide !== 'mic') { + stopMicTest(); + return; + } + if (microphoneReady && micTestState === 'idle' && !autoMicStartedRef.current) { + autoMicStartedRef.current = true; + void startMicTest(); + } + }, [activeSlide, micTestState, microphoneReady, startMicTest, stopMicTest]); + + useEffect(() => { + if (activeSlide !== 'shortcut') { + setHotkeyTestState('idle'); + return; + } + if (shortcutConfigured) { + setHotkeyTestState('listening'); + } + }, [activeSlide, shortcutConfigured]); + + useEffect(() => { + if (hotkeyTestState !== 'listening') return; + const onKeyDown = (event: KeyboardEvent) => { + if (!shortcutBinding.primary) return; + if (event.key === 'Escape') { + setHotkeyTestState('idle'); + return; + } + if (matchesShortcutBinding(event, shortcutBinding)) { + event.preventDefault(); + event.stopPropagation(); + setHotkeyTestState('matched'); + if (hotkeyResetTimerRef.current !== null) { + window.clearTimeout(hotkeyResetTimerRef.current); + } + hotkeyResetTimerRef.current = window.setTimeout(() => setHotkeyTestState('listening'), 1500); + } else if (!isModifierKey(event.key)) { + setHotkeyTestState('missed'); + } + }; + window.addEventListener('keydown', onKeyDown, true); + return () => { + window.removeEventListener('keydown', onKeyDown, true); + if (hotkeyResetTimerRef.current !== null) { + window.clearTimeout(hotkeyResetTimerRef.current); + hotkeyResetTimerRef.current = null; + } + }; + }, [hotkeyTestState, shortcutBinding]); + + const goNext = () => { + const nextIndex = Math.min(SLIDES.length - 1, slideIndex + 1); + if (nextIndex === slideIndex || motionPhase !== 'settled') return; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reduceMotion) { + setSlideIndex(nextIndex); + return; } + setMotionPhase('leaving'); + window.setTimeout(() => { + setSlideIndex(nextIndex); + setMotionPhase('entering'); + window.setTimeout(() => setMotionPhase('settled'), 28); + }, 125); }; - const onRequestMicrophone = async () => { - setBusy(true); + const requestMicrophone = async () => { + setPermissionBusy('microphone'); try { - if (microphone === 'denied') { + if (microphone === 'denied' || microphone === 'restricted') { await openSystemSettings('microphone'); } else { const status = await requestMicrophonePermission(); @@ -75,183 +318,1179 @@ export function Onboarding({ onComplete }: OnboardingProps) { await openSystemSettings('microphone'); } } + await refreshStatus(); } finally { - setBusy(false); + setPermissionBusy(null); } - if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current); - refreshTimeoutRef.current = window.setTimeout(refresh, 800); }; + const requestAccessibility = async () => { + setPermissionBusy('accessibility'); + try { + await requestAccessibilityPermission(); + await openSystemSettings('accessibility'); + await refreshStatus(); + } finally { + setPermissionBusy(null); + } + }; + + const saveShortcut = async (binding: ShortcutBinding) => { + await setDictationHotkey(binding); + if (prefs) { + await updatePrefs({ ...prefs, dictationHotkey: binding }); + } + setShortcutEditorOpen(false); + setHotkeyTestState('listening'); + }; + + const saveAsr = async () => { + if (!asrFormValid || asrSaveState === 'saving') return; + setAsrSaveState('saving'); + try { + await setActiveAsrProvider(VOLCENGINE_PROVIDER_ID); + await Promise.all([ + setCredential('volcengine.app_key', asrForm.appKey.trim()), + setCredential('volcengine.access_key', asrForm.accessKey.trim()), + setCredential('volcengine.resource_id', VOLCENGINE_RESOURCE_ID), + ]); + if (prefs) { + await updatePrefs({ ...prefs, activeAsrProvider: VOLCENGINE_PROVIDER_ID }); + } + await refreshStatus(); + setAsrSaveState('saved'); + } catch (error) { + console.warn('[onboarding] save ASR failed', error); + setAsrSaveState('error'); + } + }; + + const complete = async (options?: OnboardingCompleteOptions) => { + await ensureOnlineAsrDefault(); + const shouldUseRawStyle = !credentials?.llmConfigured; + if (shouldUseRawStyle) { + try { + await setActiveStylePack(BUILTIN_RAW_STYLE_PACK_ID); + } catch (error) { + console.warn('[onboarding] failed to switch to raw style before entering app', error); + } + } + await updatePrefs(current => ({ + ...current, + ...(shouldUseRawStyle + ? { + defaultMode: 'raw' as const, + enabledModes: ['raw' as const], + activeStylePackId: BUILTIN_RAW_STYLE_PACK_ID, + } + : {}), + onboardingVersion: REQUIRED_ONBOARDING_VERSION, + })); + try { + window.localStorage.setItem(ONBOARDING_COMPLETE_KEY, '1'); + } catch { + // Some embedded webviews can reject localStorage access. + } + stopMicTest(); + onComplete(options); + }; + + const openVolcengineDoc = () => { + void openExternal(VOLCENGINE_SETUP_URL).catch(error => { + console.warn('[onboarding] open Volcengine doc failed', error); + }); + }; + + const openVolcengineStep = (url: string) => { + void openExternal(url).catch(error => { + console.warn('[onboarding] open Volcengine setup step failed', error); + }); + }; + + const handlePrimary = () => { + if (activeSlide === 'mic') { + if (!microphoneReady) { + void requestMicrophone(); + return; + } + goNext(); + return; + } + if (activeSlide === 'shortcut') { + if (!shortcutReady) { + void requestAccessibility(); + return; + } + goNext(); + return; + } + if (activeSlide === 'asr') { + if (asrReady) { + void complete(); + return; + } + if (!asrHasInput) { + openVolcengineDoc(); + return; + } + void saveAsr(); + } + }; + + const primaryLabel = (() => { + if (activeSlide === 'mic') { + if (!microphoneReady) return copy('onboarding.actionGrant', '允许'); + return copy('onboarding.next', '继续'); + } + if (activeSlide === 'shortcut') { + if (!shortcutReady) return copy('onboarding.actionGrant', '允许'); + return copy('onboarding.next', '继续'); + } + if (asrReady) return copy('onboarding.enterAppReady', '开始使用'); + if (asrSaveState === 'saving') return copy('common.saving', '保存中...'); + if (!asrHasInput) return copy('onboarding.openVolcengineDoc', '打开火山文档'); + return copy('onboarding.saveAsr', '保存'); + })(); + + const primaryDisabled = Boolean( + permissionBusy + || (activeSlide === 'asr' && asrHasInput && !asrFormValid) + || (activeSlide === 'asr' && asrSaveState === 'saving') + ); + + const micStatus = micTestError + ?? (micTestState === 'heard' + ? copy('onboarding.micHeard', '已检测到声音。') + : micTestState === 'listening' + ? copy('onboarding.micListening', '说句话,正在听。') + : microphoneReady + ? copy('onboarding.micWaiting', '等待声音输入。') + : copy('onboarding.micPermissionLabel', '等待麦克风权限。')); + const micTone: StatusTone = micTestState === 'heard' ? 'success' : micTestState === 'error' ? 'warning' : 'neutral'; + + const shortcutStatus = (() => { + if (hotkeyStatus?.state === 'failed') { + return copy('onboarding.hotkeyFailed', '快捷键监听异常。'); + } + if (!shortcutConfigured) return copy('onboarding.shortcutNotSet', '先设置一个录音键。'); + if (!shortcutReady) return copy('onboarding.accessibilityTitle', '需要辅助功能权限。'); + if (hotkeyTestState === 'matched') return copy('onboarding.hig.shortcut.matched', '已确认。'); + if (hotkeyTestState === 'missed') return copy('onboarding.hig.shortcut.missed', '没有匹配,再按一次。'); + return copy('onboarding.hig.shortcut.listening', '按一次确认。'); + })(); + const shortcutTone: StatusTone = hotkeyTestState === 'matched' ? 'success' : hotkeyTestState === 'missed' || !shortcutReady ? 'warning' : 'neutral'; + + const asrStatus = (() => { + if (asrReady) return copy('onboarding.providerReady', '已连接。'); + if (asrSaveState === 'error') return copy('common.operationFailed', '保存失败,请检查密钥。'); + if (asrHasInput && !asrFormValid) return copy('onboarding.asrMissingFields', '还差一个字段。'); + return copy('onboarding.hig.asr.note', '推荐先用火山在线转写;其他服务稍后在设置中选择。'); + })(); + const asrTone: StatusTone = asrReady ? 'success' : asrSaveState === 'error' || (asrHasInput && !asrFormValid) ? 'warning' : 'neutral'; + const bodyStyle = isShortViewport ? shortWindowBodyStyle : isCompact ? compactWindowBodyStyle : windowBodyStyle; + const stageStyle = { + ...onboardingStageStyle, + width: activeSlide === 'asr' && isCompact + ? 'clamp(328px, calc(100vw - 272px), 520px)' + : 'min(520px, calc(100vw - 24px))', + transform: activeSlide === 'asr' && !isCompact ? 'translateX(-129px)' : 'none', + }; + const windowFrameStyle = { + ...(isCompact ? compactWindowStyle : windowStyle), + width: '100%', + }; + const guideDockStyle = isCompact ? compactVolcGuideDockStyle : volcGuideDockStyle; + return ( -
-
-
-
- OL -
-
-
{t('onboarding.welcome')}
-
- {t('onboarding.intro')} -
-
-
- - + - +
+
+
+
+ + OpenLess +
+ +
-
- {t('onboarding.footerHint')} -
+
+
+ {activeSlide === 'mic' && ( + +
+ +
+ {micStatus} +
+ )} + + {activeSlide === 'shortcut' && ( + +
+
+ {formatComboLabel(shortcutBinding)} + +
+ {shortcutEditorOpen && ( +
+ +
+ )} +
+ {shortcutStatus} +
+ )} + + {activeSlide === 'asr' && ( + +
+
+ { + setAsrSaveState('idle'); + setAsrForm(v => ({ ...v, appKey: value })); + }} + /> + { + setAsrSaveState('idle'); + setAsrForm(v => ({ ...v, accessKey: value })); + }} + secret + /> +
+
+ {copy('onboarding.hig.asr.resourceIdNote', 'Resource ID: volc.seedasr.sauc.duration')} +
+
+ {asrStatus} +
+ )} +
+
+ +
+ {activeSlide === 'asr' && ( +
+ + +
+ )} + +
+
+ + {activeSlide === 'asr' && ( + + )}
); } -interface StepProps { - index: number; +function SlideShell({ + icon, + title, + desc, + dense = false, + children, +}: { + icon: string; title: string; desc: string; - status: PermissionStatus; - actionLabel: string; - onAction: () => void; - disabled: boolean; - hint?: string; + dense?: boolean; + children: ReactNode; +}) { + return ( +
+ + + +

{title}

+

{desc}

+ {children} +
+ ); +} + +function StepDots({ + slides, + activeIndex, + label, +}: { + slides: SlideId[]; + activeIndex: number; + label: string; +}) { + return ( +
+ {slides.map((slide, index) => ( + + ))} +
+ ); } -function PermissionStep({ index, title, desc, status, actionLabel, onAction, disabled, hint }: StepProps) { - const granted = status === 'granted' || status === 'notApplicable'; +function StatusLine({ tone, children }: { tone: StatusTone; children: ReactNode }) { return (
-
- {granted ? '✓' : index} -
-
-
{title}
-
{desc}
- {hint && ( -
- {hint.split('**').map((seg, i) => (i % 2 === 0 ? seg : {seg}))} -
- )} + /> + {children} +
+ ); +} + +function CredentialInput({ + label, + value, + onChange, + secret = false, +}: { + label: string; + value: string; + onChange: (value: string) => void; + secret?: boolean; +}) { + return ( + + ); +} + +function VolcGuideStep({ + index, + title, + desc, + action, + compact = false, + onClick, +}: { + index: number; + title: string; + desc?: string; + action: string; + compact?: boolean; + onClick: () => void; +}) { + return ( +
+ {index} +
+
{title}
+ {desc &&
{desc}
}
-
); } + +function LevelMeter({ level, active }: { level: number; active: boolean }) { + const amplified = Math.min(1, Math.max(0, level * 4.5)); + const bars = [0.28, 0.52, 0.82, 1, 0.82, 0.52, 0.28]; + return ( + + ); +} + +function permissionReady(status: PermissionStatus) { + return status === 'granted' || status === 'notApplicable'; +} + +function isLocalAsrProvider(provider: string | null | undefined) { + return Boolean(provider && LOCAL_ASR_PROVIDER_IDS.has(provider)); +} + +function matchesShortcutBinding(event: KeyboardEvent, binding: ShortcutBinding) { + const primary = primaryFromKeyboardEvent(event); + if (!samePrimary(primary, binding.primary)) return false; + const expected = new Set(binding.modifiers.map(m => m.toLowerCase())); + const ownCtrl = primary === 'RightControl' || primary === 'LeftControl'; + const ownAlt = primary === 'RightOption' || primary === 'LeftOption'; + const ownShift = primary === 'Shift'; + const ownMeta = primary === 'RightCommand'; + return ( + event.ctrlKey === (expected.has('ctrl') || ownCtrl) + && event.shiftKey === (expected.has('shift') || ownShift) + && event.altKey === (expected.has('alt') || ownAlt) + && event.metaKey === (expected.has('cmd') || expected.has('super') || ownMeta) + ); +} + +function samePrimary(a: string, b: string) { + if (a.length === 1 && b.length === 1) { + return a.toLowerCase() === b.toLowerCase(); + } + return a === b; +} + +function primaryFromKeyboardEvent(event: KeyboardEvent) { + if (isModifierKey(event.key)) { + return modifierPrimaryFromCode(event.code, event.key); + } + if (/^Key[A-Z]$/.test(event.code)) return event.code.slice(3); + if (/^Digit[0-9]$/.test(event.code)) return event.code.slice(5); + if (event.code === 'Space') return 'Space'; + if (event.key.length === 1) return event.key; + return event.key; +} + +function modifierPrimaryFromCode(code: string, key: string) { + if (key === 'Shift') return 'Shift'; + if (code === 'ControlRight') return 'RightControl'; + if (code === 'ControlLeft') return 'LeftControl'; + if (code === 'AltRight') return 'RightOption'; + if (code === 'AltLeft') return 'RightOption'; + if (code === 'MetaRight' || code === 'MetaLeft') return 'RightCommand'; + return key; +} + +function isModifierKey(key: string) { + return key === 'Control' || key === 'Alt' || key === 'Shift' || key === 'Meta'; +} + +const pageStyle: CSSProperties = { + flex: 1, + minHeight: '100dvh', + display: 'grid', + placeItems: 'center', + padding: 24, + boxSizing: 'border-box', + background: '#f5f5f7', + color: 'var(--ol-ink, #1d1d1f)', + fontFamily: 'var(--ol-font-sans)', +}; + +const compactPageStyle: CSSProperties = { + ...pageStyle, + padding: 12, +}; + +const onboardingStageStyle: CSSProperties = { + position: 'relative', + width: 520, +}; + +const windowStyle: CSSProperties = { + width: 520, + height: 500, + display: 'grid', + gridTemplateRows: '56px minmax(0, 1fr) 64px', + overflow: 'hidden', + background: 'rgba(255,255,255,0.96)', + border: '0.5px solid rgba(0,0,0,0.12)', + borderRadius: 18, + boxShadow: '0 28px 70px -46px rgba(0,0,0,0.45), 0 12px 32px -26px rgba(0,0,0,0.28)', + position: 'relative', +}; + +const compactWindowStyle: CSSProperties = { + ...windowStyle, + width: 'calc(100vw - 24px)', + height: 'calc(100dvh - 24px)', + maxHeight: 560, +}; + +const windowBarStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + alignItems: 'center', + padding: '0 18px', + borderBottom: '0.5px solid rgba(0,0,0,0.07)', + background: 'rgba(255,255,255,0.82)', + backdropFilter: 'blur(18px)', +}; + +const assistantTitleStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 9, + minWidth: 0, +}; + +const assistantIconStyle: CSSProperties = { + width: 28, + height: 28, + borderRadius: 7, + boxShadow: '0 1px 2px rgba(0,0,0,.12)', +}; + +const assistantNameStyle: CSSProperties = { + fontSize: 13, + fontWeight: 650, + color: 'var(--ol-ink, #1d1d1f)', +}; + +const dotsStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 7, +}; + +const windowBodyStyle: CSSProperties = { + minWidth: 0, + minHeight: 0, + display: 'grid', + alignItems: 'center', + padding: '28px 54px 18px', + overflow: 'hidden', +}; + +const compactWindowBodyStyle: CSSProperties = { + ...windowBodyStyle, + padding: '24px 24px 16px', +}; + +const shortWindowBodyStyle: CSSProperties = { + ...windowBodyStyle, + padding: '18px 30px 10px', +}; + +const slideStyle: CSSProperties = { + minWidth: 0, + minHeight: 0, + transition: 'opacity 0.24s cubic-bezier(0.22, 1, 0.36, 1), transform 0.24s cubic-bezier(0.22, 1, 0.36, 1), filter 0.18s ease', +}; + +const slideMotionStyles: Record = { + settled: { + opacity: 1, + transform: 'translate3d(0, 0, 0) scale(1)', + filter: 'blur(0)', + }, + leaving: { + opacity: 0, + transform: 'translate3d(0, -4px, 0) scale(0.995)', + filter: 'blur(0.2px)', + }, + entering: { + opacity: 0, + transform: 'translate3d(0, 8px, 0) scale(0.992)', + filter: 'blur(0.2px)', + }, +}; + +const slideInnerStyle: CSSProperties = { + minWidth: 0, + display: 'grid', + justifyItems: 'center', + textAlign: 'center', +}; + +const slideIconStyle: CSSProperties = { + width: 48, + height: 48, + borderRadius: 14, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + background: 'rgba(0,122,255,0.10)', + color: 'var(--ol-blue, #007aff)', + marginBottom: 18, +}; + +const denseSlideIconStyle: CSSProperties = { + ...slideIconStyle, + width: 42, + height: 42, + borderRadius: 12, + marginBottom: 12, +}; + +const titleStyle: CSSProperties = { + margin: 0, + fontSize: 25, + lineHeight: 1.18, + fontWeight: 680, + letterSpacing: 0, +}; + +const denseTitleStyle: CSSProperties = { + ...titleStyle, + fontSize: 22, +}; + +const descStyle: CSSProperties = { + margin: '9px 0 0', + maxWidth: 320, + fontSize: 14, + lineHeight: 1.45, + color: 'var(--ol-ink-3, #515154)', +}; + +const denseDescStyle: CSSProperties = { + ...descStyle, + marginTop: 6, + fontSize: 13, + lineHeight: 1.36, +}; + +const operationStyle: CSSProperties = { + width: '100%', + marginTop: 26, + boxSizing: 'border-box', +}; + +const asrOperationStyle: CSSProperties = { + ...operationStyle, + marginTop: 18, +}; + +const levelMeterStyle: CSSProperties = { + height: 132, + borderRadius: 14, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 9, + background: 'rgba(0,122,255,0.06)', + border: '0.5px solid rgba(0,122,255,0.14)', +}; + +const shortcutReadoutStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + alignItems: 'center', + gap: 12, +}; + +const shortcutChipStyle: CSSProperties = { + minWidth: 0, + height: 74, + borderRadius: 14, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + padding: '0 18px', + boxSizing: 'border-box', + background: 'rgba(0,0,0,0.045)', + color: 'var(--ol-ink, #1d1d1f)', + fontFamily: 'var(--ol-font-mono)', + fontSize: 22, + fontWeight: 700, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}; + +const quietButtonStyle: CSSProperties = { + height: 34, + border: '0.5px solid rgba(0,0,0,0.14)', + borderRadius: 8, + background: '#fff', + color: 'var(--ol-ink-2, #2c2c2e)', + fontFamily: 'inherit', + fontSize: 13, + fontWeight: 650, + padding: '0 12px', + whiteSpace: 'nowrap', + cursor: 'default', +}; + +const recorderShellStyle: CSSProperties = { + marginTop: 12, + padding: 12, + borderRadius: 12, + background: 'rgba(0,0,0,0.035)', +}; + +const asrFieldGridStyle: CSSProperties = { + display: 'grid', + gap: 9, +}; + +const asrResourceNoteStyle: CSSProperties = { + marginTop: 8, + fontSize: 11.5, + lineHeight: 1.45, + color: 'var(--ol-ink-4, #6e6e73)', + textAlign: 'left', + userSelect: 'text', +}; + +const volcGuideDockStyle: CSSProperties = { + position: 'absolute', + top: 82, + left: 'calc(100% + 14px)', + width: 244, + zIndex: 20, + display: 'grid', + justifyItems: 'start', + gap: 10, +}; + +const compactVolcGuideDockStyle: CSSProperties = { + ...volcGuideDockStyle, + top: 72, + left: 'calc(100% + 10px)', + width: 126, +}; + +const volcGuideToggleStyle: CSSProperties = { + height: 32, + border: '0.5px solid rgba(0,122,255,0.24)', + borderRadius: 8, + background: 'rgba(0,122,255,0.08)', + color: 'var(--ol-blue, #007aff)', + fontFamily: 'inherit', + fontSize: 12.5, + fontWeight: 650, + padding: '0 12px', + whiteSpace: 'nowrap', + cursor: 'default', +}; + +const volcGuideToggleActiveStyle: CSSProperties = { + ...volcGuideToggleStyle, + background: 'var(--ol-blue, #007aff)', + borderColor: 'var(--ol-blue, #007aff)', + color: '#fff', +}; + +const volcGuidePanelStyle: CSSProperties = { + width: 244, + borderRadius: 14, + background: 'rgba(255,255,255,0.96)', + border: '0.5px solid rgba(0,0,0,0.12)', + boxShadow: '0 18px 45px -28px rgba(0,0,0,0.48), 0 6px 18px -14px rgba(0,0,0,0.24)', + backdropFilter: 'blur(20px) saturate(160%)', + WebkitBackdropFilter: 'blur(20px) saturate(160%)', + padding: 10, + boxSizing: 'border-box', +}; + +const compactVolcGuidePanelStyle: CSSProperties = { + ...volcGuidePanelStyle, + width: 126, + maxHeight: 'calc(100dvh - 124px)', + overflowY: 'auto', + overflowX: 'hidden', + padding: 8, +}; + +const volcGuideHeaderStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + marginBottom: 7, +}; + +const volcGuideTitleStyle: CSSProperties = { + minWidth: 0, + fontSize: 12.5, + fontWeight: 700, + color: 'var(--ol-ink, #1d1d1f)', +}; + +const volcGuideCloseStyle: CSSProperties = { + width: 24, + height: 24, + flex: '0 0 auto', + border: 0, + borderRadius: 999, + background: 'rgba(0,0,0,0.045)', + color: 'var(--ol-ink-4, #6e6e73)', + fontFamily: 'inherit', + fontSize: 13, + lineHeight: 1, + cursor: 'default', +}; + +const volcGuideStepStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: '22px minmax(0, 1fr) auto', + alignItems: 'center', + gap: 8, + padding: '8px 0', + borderTop: '0.5px solid rgba(0,0,0,0.07)', +}; + +const compactVolcGuideStepStyle: CSSProperties = { + ...volcGuideStepStyle, + gridTemplateColumns: '20px minmax(0, 1fr)', + alignItems: 'start', + gap: 6, + padding: '7px 0', +}; + +const volcGuideIndexStyle: CSSProperties = { + width: 20, + height: 20, + borderRadius: 999, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + background: 'rgba(0,122,255,0.10)', + color: 'var(--ol-blue, #007aff)', + fontSize: 11, + fontWeight: 700, +}; + +const volcGuideStepBodyStyle: CSSProperties = { + minWidth: 0, + textAlign: 'left', +}; + +const volcGuideStepTitleStyle: CSSProperties = { + fontSize: 12, + lineHeight: 1.25, + fontWeight: 650, + color: 'var(--ol-ink, #1d1d1f)', +}; + +const volcGuideStepDescStyle: CSSProperties = { + marginTop: 2, + fontSize: 11, + lineHeight: 1.35, + color: 'var(--ol-ink-4, #6e6e73)', +}; + +const volcGuideStepButtonStyle: CSSProperties = { + height: 26, + border: '0.5px solid rgba(0,0,0,0.14)', + borderRadius: 7, + background: '#fff', + color: 'var(--ol-ink-2, #2c2c2e)', + fontFamily: 'inherit', + fontSize: 12, + fontWeight: 650, + padding: '0 9px', + whiteSpace: 'nowrap', + cursor: 'default', +}; + +const compactVolcGuideStepButtonStyle: CSSProperties = { + ...volcGuideStepButtonStyle, + gridColumn: '2', + justifySelf: 'start', + height: 24, + marginTop: 4, + fontSize: 11.5, + padding: '0 8px', +}; + +const volcGuideFootnoteStyle: CSSProperties = { + paddingTop: 8, + borderTop: '0.5px solid rgba(0,0,0,0.07)', + fontSize: 11, + lineHeight: 1.45, + color: 'var(--ol-ink-4, #6e6e73)', + textAlign: 'left', +}; + +const fieldStyle: CSSProperties = { + display: 'grid', + gap: 6, + textAlign: 'left', +}; + +const fieldLabelStyle: CSSProperties = { + fontSize: 12, + lineHeight: 1.2, + fontWeight: 650, + color: 'var(--ol-ink-4, #6e6e73)', +}; + +const inputStyle: CSSProperties = { + width: '100%', + height: 40, + boxSizing: 'border-box', + border: '0.5px solid rgba(0,0,0,0.18)', + borderRadius: 8, + background: '#fff', + color: 'var(--ol-ink, #1d1d1f)', + fontFamily: 'inherit', + fontSize: 13, + padding: '0 11px', + outline: 'none', +}; + +const statusLineStyle: CSSProperties = { + minHeight: 22, + marginTop: 18, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 7, + fontSize: 12.5, + lineHeight: 1.35, + fontWeight: 560, +}; + +const statusDotStyle: CSSProperties = { + width: 6, + height: 6, + flex: '0 0 auto', + borderRadius: 999, +}; + +const windowFooterStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 9, + padding: '0 18px', + borderTop: '0.5px solid rgba(0,0,0,0.07)', + background: 'rgba(255,255,255,0.86)', + backdropFilter: 'blur(18px)', +}; + +const secondaryButtonGroupStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + minWidth: 0, +}; + +const primaryButtonStyle: CSSProperties = { + height: 34, + minWidth: 104, + border: 0, + borderRadius: 8, + background: 'var(--ol-blue, #007aff)', + color: '#fff', + fontFamily: 'inherit', + fontSize: 13, + fontWeight: 680, + padding: '0 15px', + whiteSpace: 'nowrap', + cursor: 'default', +}; + +const secondaryButtonStyle: CSSProperties = { + height: 34, + minWidth: 76, + border: '0.5px solid rgba(0,0,0,0.14)', + borderRadius: 8, + background: '#fff', + color: 'var(--ol-ink-2, #2c2c2e)', + fontFamily: 'inherit', + fontSize: 13, + fontWeight: 650, + padding: '0 12px', + whiteSpace: 'nowrap', + cursor: 'default', +}; diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 0245f64f..b39270d9 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -57,6 +57,7 @@ interface SettingsModalProps { os: OS; onClose: () => void; initialSettingsSection?: SettingsSectionId; + onStartOnboarding?: () => void; } // 稳定 ID(与 i18n key 一致,方便 modal.sections.* 渲染)。 @@ -76,7 +77,12 @@ interface ModalGroup { const HELP_URL = 'https://github.com/appergb/openless#readme'; const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases'; -export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) { +export function SettingsModal({ + os: _os, + onClose, + initialSettingsSection, + onStartOnboarding, +}: SettingsModalProps) { const { t } = useTranslation(); const [section, setSection] = useState('settings'); const savedToast = useSavedToastListener(); @@ -232,7 +238,11 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett {section === 'settings' ? ( // SettingsContent 自己接管 flex:1 + 内部右栏 scroll,外层不能再加 overflow:auto。
- +
) : ( // personalize / about 短内容:单一 scroll wrapper,超出时本块滚动。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 3b177f83..be9e14e4 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -193,20 +193,162 @@ export const en: typeof zhCN = { }, onboarding: { welcome: 'Welcome to OpenLess', - intro: 'Speak locally, type locally. Two system permissions are needed before you start.', + kicker: 'FIRST RUN', + intro: 'Set up hearing, recording, ASR, and LLM before your first real dictation. Most people should start with Volcengine ASR; local models are for offline or privacy-sensitive use.', + refreshStatus: 'Refresh status', + enterApp: 'Enter OpenLess for now', + enterAppRaw: 'Enter with Raw mode', + enterAppReady: 'Enter OpenLess', + providerReady: 'Providers ready', + providerMissing: 'Providers incomplete', + permissionReady: 'Ready', + permissionPending: 'Pending', accessibilityTitle: 'Accessibility', - hotkeyTitle: 'Global hotkey', accessibilityDesc: 'Used to listen to the global hotkey (default {{trigger}}) and write transcripts at the cursor.', - hotkeyDesc: 'Used to confirm that the global hotkey listener is available.', - micTitle: 'Microphone', - micDesc: 'Used to capture your voice input.', - actionNotApplicable: 'Not required', actionGranted: 'Granted', - actionOpenSystem: 'Open System Settings', actionGrant: 'Grant', actionRequestMic: 'Request access', accessibilityHint: 'After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).', - footerHint: 'This onboarding closes automatically once both permissions are granted. If it persists, quit OpenLess from the menu bar and relaunch.', + steps: { + microphone: { + title: 'Test microphone', + desc: 'Grant access and confirm input level', + }, + shortcut: { + title: 'Set hotkey', + desc: 'Record it and press it once', + }, + providers: { + title: 'Configure ASR / LLM', + desc: 'Cloud first, local when needed', + }, + }, + panels: { + microphone: { + title: 'First, make sure OpenLess can hear you', + desc: 'Microphone access and live input level are the first gate. This page opens system permission prompts and shows whether audio is actually coming in.', + }, + shortcut: { + title: 'Choose a recording hotkey that will not get in the way', + desc: 'The default is not perfect for everyone. Record a shortcut, then press it once here so the first dictation does not start with a surprise.', + }, + providers: { + title: 'Choose a transcription path, then finish LLM setup', + desc: 'New users should usually start with Volcengine ASR. Local ASR is better for offline, privacy-sensitive, or network-limited setups. Polishing, translation, and selection Q&A still need an LLM.', + }, + }, + micPanelTitle: 'System permissions', + micPanelDesc: 'OpenLess needs microphone access at minimum. Some platforms also need Accessibility for global hotkeys and text insertion.', + micPermissionLabel: 'Microphone', + micPermissionDesc: 'Captures your voice input.', + micPermissionReady: 'OpenLess is allowed to use the microphone.', + micTestTitle: 'Input level test', + micTestStart: 'Start test', + micTestStop: 'Stop test', + micHeard: 'Audio detected', + micListening: 'Listening', + micWaiting: 'Waiting to test', + micTestFailed: 'Test failed', + micPermissionBlocked: 'Microphone access is not enabled yet. Allow OpenLess to use the microphone in system settings first.', + shortcutPanelTitle: 'Recording hotkey', + shortcutPanelDesc: 'Prefer a shortcut that is not used for chat send, IDE commands, or input-method switching.', + shortcutTestTitle: 'Hotkey test', + shortcutCurrent: 'Current recording hotkey: {{shortcut}}', + shortcutNotSet: 'No recording hotkey is set yet.', + shortcutTestStart: 'Test hotkey', + shortcutListening: 'Press the current hotkey…', + shortcutMatched: 'This hotkey was detected.', + shortcutMissed: 'That key press did not match the current shortcut. Try again.', + shortcutTestHint: 'Click "Test hotkey", then press the current shortcut in this window. Esc cancels.', + hotkeyStatusUnknown: 'Listener status unknown', + hotkeyInstalled: 'Global listener installed', + hotkeyStarting: 'Global listener starting', + hotkeyFailed: 'Global listener failed', + cloudAsrTitle: 'Recommended: Volcengine ASR', + cloudAsrDesc: 'Best fit for most new users: stable speed and stronger Chinese accuracy. Follow the guide, then paste APP ID, Access Token, and Resource ID below.', + openVolcengineDoc: 'Open Volcengine guide', + localAsrTitle: 'Optional: local ASR', + localAsrDesc: 'Use this only when you need offline recognition, privacy isolation, or no cloud ASR API. First preparation downloads models; speed and accuracy depend on the machine.', + openLocalAsrSettings: 'View local models', + providerStatusTitle: 'Current status', + asrStatus: 'Speech to text', + llmStatus: 'Smart polish', + providerSettingsTitle: 'Fill provider configuration', + providerSettingsDesc: 'This is the provider settings area. You can save API keys, models, and local model options directly here.', + providersTab: 'Providers', + localModelTab: 'Local models', + progress: 'Step {{current}} / {{total}}', + back: 'Back', + next: 'Next', + statusDone: 'Done', + statusTodo: 'To do', + slideMicKicker: 'Step one', + slideMicTitle: 'Test the microphone', + slideMicDesc: 'Grant access and confirm live input.', + slideShortcutKicker: 'Step two', + slideShortcutTitle: 'Set the record key', + slideShortcutDesc: 'Record a shortcut, then press it once.', + slideAsrKicker: 'Step three', + slideAsrTitle: 'Connect speech to text', + slideAsrDesc: 'Online transcription is the best first path for speed and accuracy.', + cloudAsrDescShort: 'Cloud speech to text is the best first path.', + asrSetupTitle: 'Connect cloud speech to text', + asrSetupDesc: '{{provider}} is recommended first. You can also choose another online service you already have keys for.', + asrHaveKey: 'I have Volcengine account keys', + asrProviderLabel: 'Online transcription service', + asrConnectSelected: 'Connect selected service', + asrOtherOnlineKey: 'Online ASR API key', + asrAdvancedToggle: 'Advanced: Resource ID', + asrProviderNote: 'OpenLess does not bundle shared credentials. Use your own provider account keys. Local models live in Advanced settings.', + asrDefer: 'Connect later in Settings', + asrSkipNote: 'Until speech-to-text is connected, recordings cannot become text.', + saveAsr: 'Save transcription service', + recommendedBadge: 'Recommended', + slideLlmKicker: 'Optional', + slideLlmTitle: 'Choose output', + slideLlmDesc: 'Start dictation first; decide on smart polish later.', + rawModeReady: 'Raw transcript ready', + llmOptionalHint: 'Without smart polish, OpenLess outputs the raw transcript first.', + llmRawTitle: 'Raw transcript first', + llmRawDesc: 'No polish or translation. Best for getting started.', + llmSmartTitle: 'Connect smart polish', + llmSmartDesc: 'Add an API key when you need polish, translation, and Q&A.', + llmHaveKey: 'Ark / OpenAI-compatible API key', + saveLlm: 'Save smart polish', + rawFallbackBadge: 'Skippable', + slideDoneKicker: 'Done', + slideDoneTitleReady: 'Ready to dictate', + slideDoneTitleRaw: 'Start with raw transcript', + slideDoneDescReady: 'Microphone, shortcut, and providers are ready.', + slideDoneDescRaw: 'Without smart polish, OpenLess outputs the raw transcript first.', + doneNeedsAsrTitle: 'Speech-to-text is still missing', + doneNeedsAsrDesc: 'The microphone and shortcut can be ready first, but dictation needs one online transcription service.', + doneNeedsAsrHint: 'Connect Volcengine first, or use another online ASR key you already have.', + doneEnterAnyway: 'Enter OpenLess for now', + llmOptionalTitle: 'Smart polish can wait', + llmOptionalDesc: 'Without a model, OpenLess first outputs exactly what you said.', + llmPreviewRawTitle: 'Just type what I said', + llmPreviewRawDesc: 'Raw transcript, fastest to start.', + llmPreviewSmartTitle: 'Polish or translate', + llmPreviewSmartDesc: 'Needs an API key; can be enabled later.', + rawMode: 'Raw transcript', + available: 'Available', + enabled: 'Enabled', + primaryMicReady: 'Continue to shortcut', + primaryMicGrant: 'Allow microphone', + primaryMicSkip: 'Grant later, continue', + primaryShortcutReady: 'Continue to transcription', + primaryShortcutSkip: 'Set later, continue', + primaryAsrReady: 'Continue', + primaryAsrConnect: 'Connect transcription', + primaryAsrSave: 'Save transcription', + primaryAsrSkip: 'Connect later, continue', + primaryLlmReady: 'Continue', + primaryLlmSkip: 'Use raw transcript first', + primaryDoneNeedsAsr: 'Connect transcription', + doneAsrMissing: 'Connect later', + doneRawOutput: 'Raw transcript only', + doneSmartOutput: 'Enabled', }, overview: { kicker: 'DASHBOARD', @@ -496,6 +638,7 @@ export const en: typeof zhCN = { title: 'Settings', desc: 'Recording method, model and ASR providers, hotkeys, permissions, and About — everything is here.', sections: { + setup: 'Guide', recording: 'Recording', providers: 'Providers', shortcuts: 'Shortcuts', @@ -504,6 +647,11 @@ export const en: typeof zhCN = { advanced: 'Advanced', about: 'About', }, + setup: { + title: 'Setup guide', + desc: 'Check the microphone, recording key, and online ASR again.', + open: 'Open guide', + }, recording: { title: 'Recording', desc: 'Global recording hotkey and trigger mode.', @@ -514,6 +662,8 @@ export const en: typeof zhCN = { modeDesc: 'Toggle = tap once to start, again to stop. Push-to-talk = hold to record.', modeToggle: 'Toggle', modeHold: 'Push-to-talk', + rightCtrlHoldWarningTitle: 'Right Ctrl may conflict with chat send shortcuts', + rightCtrlHoldWarningDesc: 'Push-to-talk reserves Right Ctrl while recording, so Right Ctrl+Enter in apps like QQ or WeChat may arrive as Enter only. Switch back to Toggle, or choose a shortcut that is not used for sending messages.', migrationNoticeTitle: 'Default recording mode is now Toggle', migrationNoticeDesc: 'This update changes the default; if you prefer push-to-talk, switch it back here.', microphoneLabel: 'Preferred microphone', @@ -595,7 +745,7 @@ export const en: typeof zhCN = { alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', custom: 'Custom', - asrVolcengine: 'Volcengine bigasr', + asrVolcengine: 'Volcengine ASR', asrBailian: 'Alibaba Bailian realtime ASR', asrSiliconflow: 'SiliconFlow SenseVoice', asrZhipu: 'Zhipu GLM-ASR', @@ -607,7 +757,7 @@ export const en: typeof zhCN = { volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', - volcengineMappingNote: 'Secret Key is not required right now. Resource ID defaults to volc.bigasr.sauc.duration.', + volcengineMappingNote: 'Secret Key is not required right now. Confirm Resource ID is volc.seedasr.sauc.duration.', localAsrActiveNotice: 'Local ASR ({{name}}) is currently active. Switch or disable it from the Advanced tab.', localAsrTakeoverHint: 'Once "{{name}}" is enabled, the ASR provider will be taken over.', asrProviderTakenOver: 'ASR provider taken over', @@ -726,7 +876,7 @@ export const en: typeof zhCN = { enable: 'Enable', alreadyActive: 'Active', disableLocalLabel: 'Disable local ASR', - disableLocalDesc: 'Switch back to cloud ASR (defaults to Volcengine bigasr).', + disableLocalDesc: 'Switch back to cloud ASR (defaults to Volcengine ASR).', disable: 'Disable', platformNotSupported: 'Local ASR model integration is not supported on this platform.', confirmEnableLocalTitle: 'Enable local ASR?', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index d7479197..7a0e19a6 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -195,20 +195,162 @@ export const ja: typeof zhCN = { }, onboarding: { welcome: 'OpenLess へようこそ', - intro: 'ローカルで話し、ローカルで文字に。開始前にシステム権限が 2 つ必要です。', + kicker: 'FIRST RUN', + intro: '最初の録音前に、マイク、録音ショートカット、ASR / LLM の経路をまとめて確認します。多くのユーザーにはまず Volcengine ASR を推奨し、ローカルモデルはオフラインやプライバシー重視の用途向けです。', + refreshStatus: '状態を更新', + enterApp: 'ひとまず OpenLess へ', + enterAppRaw: '原文モードで進む', + enterAppReady: 'OpenLess へ進む', + providerReady: 'プロバイダー準備完了', + providerMissing: 'プロバイダー未完了', + permissionReady: '準備完了', + permissionPending: '未処理', accessibilityTitle: 'アクセシビリティ', - hotkeyTitle: 'グローバルショートカット', accessibilityDesc: 'グローバルショートカット(既定 {{trigger}})の検知と、認識結果のカーソル位置への入力に使用します。', - hotkeyDesc: 'グローバルショートカット監視が利用可能か確認するために使用します。', - micTitle: 'マイク', - micDesc: '音声入力の取得に使用します。', - actionNotApplicable: '権限不要', actionGranted: '許可済み', - actionOpenSystem: 'システム設定を開く', actionGrant: '許可する', actionRequestMic: '許可ダイアログを表示', accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。', - footerHint: 'すべての権限が揃うとこのガイドは自動で閉じます。閉じない場合はメニューバーの OpenLess → 終了 から再起動してください。', + steps: { + microphone: { + title: 'マイクをテスト', + desc: '許可して入力レベルを確認', + }, + shortcut: { + title: 'ショートカット設定', + desc: '録音して一度押して確認', + }, + providers: { + title: 'ASR / LLM 設定', + desc: 'クラウド優先、ローカルは必要時', + }, + }, + panels: { + microphone: { + title: 'まず OpenLess が音を拾えるか確認', + desc: 'マイク権限と入力レベルが最初の確認点です。ここでシステム許可を開き、実際に音声が入っているかをライブメーターで確認します。', + }, + shortcut: { + title: '邪魔にならない録音ショートカットを選ぶ', + desc: '既定値が全員に合うとは限りません。ショートカットを録音し、この画面で一度押してから使い始めます。', + }, + providers: { + title: '文字起こし経路を選び、LLM を仕上げる', + desc: '新規ユーザーは通常 Volcengine ASR から始めるのが安定です。ローカル ASR はオフライン、プライバシー重視、ネットワーク制限時に向いています。整文、翻訳、選択範囲 Q&A には LLM も必要です。', + }, + }, + micPanelTitle: 'システム権限', + micPanelDesc: 'OpenLess には少なくともマイク権限が必要です。環境によってはグローバルショートカットと文字入力のためにアクセシビリティも必要です。', + micPermissionLabel: 'マイク', + micPermissionDesc: '音声入力を取得します。', + micPermissionReady: 'OpenLess のマイク使用が許可されています。', + micTestTitle: '入力レベルテスト', + micTestStart: 'テスト開始', + micTestStop: 'テスト停止', + micHeard: '音声を検出', + micListening: '待機中', + micWaiting: 'テスト待ち', + micTestFailed: 'テスト失敗', + micPermissionBlocked: 'マイク権限がまだ有効ではありません。システム設定で OpenLess のマイク使用を許可してください。', + shortcutPanelTitle: '録音ショートカット', + shortcutPanelDesc: 'チャット送信、IDE コマンド、入力方式切替に使わない組み合わせを推奨します。', + shortcutTestTitle: 'ショートカットテスト', + shortcutCurrent: '現在の録音ショートカット:{{shortcut}}', + shortcutNotSet: '録音ショートカットがまだ設定されていません。', + shortcutTestStart: 'ショートカットをテスト', + shortcutListening: '現在のショートカットを押してください…', + shortcutMatched: 'このショートカットを検出しました。', + shortcutMissed: '今回のキー入力は現在の設定と一致しません。もう一度試してください。', + shortcutTestHint: '「ショートカットをテスト」を押してから、このウィンドウで現在の組み合わせを一度押します。Esc でキャンセルできます。', + hotkeyStatusUnknown: '監視状態不明', + hotkeyInstalled: 'グローバル監視はインストール済み', + hotkeyStarting: 'グローバル監視を起動中', + hotkeyFailed: 'グローバル監視に異常', + cloudAsrTitle: '推奨:Volcengine ASR', + cloudAsrDesc: 'ほとんどの新規ユーザーに適しています。速度と中国語精度が安定します。ガイドに従ってアプリを作成し、APP ID、Access Token、Resource ID を下に入力してください。', + openVolcengineDoc: 'Volcengine ガイドを開く', + localAsrTitle: '任意:ローカル ASR', + localAsrDesc: 'オフライン、プライバシー分離、クラウド ASR API を使いたくない場合のみ推奨します。初回準備ではモデルをダウンロードし、速度と精度はマシンに依存します。', + openLocalAsrSettings: 'ローカルモデルを見る', + providerStatusTitle: '現在の状態', + asrStatus: '音声から文字', + llmStatus: 'スマート整形', + providerSettingsTitle: 'プロバイダー設定を入力', + providerSettingsDesc: '下は設定画面のプロバイダー領域です。API Key、モデル、ローカルモデル設定をここで保存できます。', + providersTab: 'プロバイダー', + localModelTab: 'ローカルモデル', + progress: '{{current}} / {{total}}', + back: '戻る', + next: '次へ', + statusDone: '完了', + statusTodo: '未完了', + slideMicKicker: 'STEP 1', + slideMicTitle: 'マイクを試す', + slideMicDesc: '権限を許可し、入力レベルを確認します。', + slideShortcutKicker: 'STEP 2', + slideShortcutTitle: '録音キーを設定', + slideShortcutDesc: 'ショートカットを登録し、一度押して確認します。', + slideAsrKicker: 'STEP 3', + slideAsrTitle: '音声から文字へ接続', + slideAsrDesc: '最初は速度と精度の面でオンライン文字起こしがおすすめです。', + cloudAsrDescShort: '最初はクラウド文字起こしがおすすめです。', + asrSetupTitle: 'クラウド文字起こしに接続', + asrSetupDesc: 'まずは {{provider}} がおすすめです。すでにキーを持っている別のオンラインサービスも選べます。', + asrHaveKey: 'Volcengine アカウントキーを持っている', + asrProviderLabel: 'オンライン文字起こしサービス', + asrConnectSelected: '選択したサービスに接続', + asrOtherOnlineKey: 'オンライン ASR API Key', + asrAdvancedToggle: '詳細:Resource ID', + asrProviderNote: 'OpenLess には共有認証情報は同梱されていません。自分のプロバイダーアカウントキーを使います。ローカルモデルは詳細設定で有効にできます。', + asrDefer: 'あとで設定から接続', + asrSkipNote: '音声から文字へのサービスが接続されるまで、録音は文字になりません。', + saveAsr: '文字起こしサービスを保存', + recommendedBadge: '推奨', + slideLlmKicker: '任意', + slideLlmTitle: '出力方法を選択', + slideLlmDesc: 'まず音声入力を始め、スマート整形はあとで決められます。', + rawModeReady: '生の文字起こしOK', + llmOptionalHint: 'スマート整形なしでは、生の文字起こしを先に出力します。', + llmRawTitle: 'まず生の文字起こし', + llmRawDesc: '整形や翻訳なし。最初に試すのに向いています。', + llmSmartTitle: 'スマート整形に接続', + llmSmartDesc: '整形、翻訳、Q&A が必要なときに API Key を追加します。', + llmHaveKey: 'Ark / OpenAI 互換 API Key', + saveLlm: 'スマート整形を保存', + rawFallbackBadge: 'スキップ可', + slideDoneKicker: '完了', + slideDoneTitleReady: 'すぐに音声入力できます', + slideDoneTitleRaw: '生の文字起こしで開始', + slideDoneDescReady: 'マイク、ショートカット、サービスが準備できました。', + slideDoneDescRaw: 'スマート整形なしでは、生の文字起こしを先に出力します。', + doneNeedsAsrTitle: '音声から文字へのサービスが未接続です', + doneNeedsAsrDesc: 'マイクとショートカットは先に準備できますが、音声入力を始めるにはオンライン文字起こしサービスが必要です。', + doneNeedsAsrHint: 'まず Volcengine、またはすでにキーを持っているオンライン ASR を接続してください。', + doneEnterAnyway: 'ひとまず OpenLess へ', + llmOptionalTitle: 'スマート整形はあとで有効にできます', + llmOptionalDesc: 'モデル未接続の場合、OpenLess はまず話した内容をそのまま出力します。', + llmPreviewRawTitle: '話した内容だけを入力', + llmPreviewRawDesc: '生の文字起こし。最速で始められます。', + llmPreviewSmartTitle: '整形または翻訳', + llmPreviewSmartDesc: 'API Key が必要です。あとで有効にできます。', + rawMode: '生の文字起こし', + available: '利用可', + enabled: '有効', + primaryMicReady: 'ショートカットへ進む', + primaryMicGrant: 'マイクを許可', + primaryMicSkip: 'あとで許可して続ける', + primaryShortcutReady: '文字起こしへ進む', + primaryShortcutSkip: 'あとで設定して続ける', + primaryAsrReady: '続ける', + primaryAsrConnect: '文字起こしに接続', + primaryAsrSave: '文字起こしを保存', + primaryAsrSkip: 'あとで接続して続ける', + primaryLlmReady: '続ける', + primaryLlmSkip: 'まず生の文字起こしを使う', + primaryDoneNeedsAsr: '文字起こしに接続', + doneAsrMissing: 'あとで接続', + doneRawOutput: '生の文字起こしのみ', + doneSmartOutput: '有効', }, overview: { kicker: 'DASHBOARD', @@ -498,6 +640,7 @@ export const ja: typeof zhCN = { title: '設定', desc: '録音方式、モデルと音声プロバイダー、ショートカット、権限、バージョン情報——すべてここに集約。', sections: { + setup: 'ガイド', recording: '録音', providers: 'プロバイダー', shortcuts: 'ショートカット', @@ -506,6 +649,11 @@ export const ja: typeof zhCN = { advanced: '詳細設定', about: '情報', }, + setup: { + title: 'セットアップガイド', + desc: 'マイク、録音キー、オンライン ASR をもう一度確認します。', + open: 'ガイドを開く', + }, recording: { title: '録音', desc: 'グローバル録音のショートカットとトリガー方式を定義します。', @@ -516,6 +664,8 @@ export const ja: typeof zhCN = { modeDesc: 'トグル式 = 1 回押して開始、もう 1 回押して終了;押し続けて話す = 押している間だけ録音。', modeToggle: 'トグル式', modeHold: '押し続けて話す', + rightCtrlHoldWarningTitle: '右 Ctrl はチャット送信ショートカットと競合する場合があります', + rightCtrlHoldWarningDesc: '押し続けて話すでは録音中に右 Ctrl を予約するため、QQ / WeChat などの右 Ctrl+Enter が Enter だけとして届く場合があります。トグル式に戻すか、送信に使わないショートカットを選んでください。', migrationNoticeTitle: 'デフォルトがトグル式に変更されました', migrationNoticeDesc: '以前にトリガー方式を変更していた場合は、ここで再度確認してください。今回のアップデートではショートカット方式のデフォルト値と読み込みロジックが変更されています。「押し続けて話す」が好みであれば再度切り替えてください。', microphoneLabel: '優先マイク', @@ -597,7 +747,7 @@ export const ja: typeof zhCN = { alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', custom: 'カスタム', - asrVolcengine: 'Volcengine bigasr', + asrVolcengine: 'Volcengine ASR', asrBailian: 'Alibaba Bailian リアルタイム ASR', asrSiliconflow: 'SiliconFlow SenseVoice', asrZhipu: 'Zhipu GLM-ASR', @@ -609,7 +759,7 @@ export const ja: typeof zhCN = { volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', - volcengineMappingNote: 'Secret Key は現在不要。Resource ID のデフォルトは volc.bigasr.sauc.duration。', + volcengineMappingNote: 'Secret Key は現在不要です。Resource ID が volc.seedasr.sauc.duration であることを確認してください。', localAsrActiveNotice: '現在「{{name}}」を使用中。「詳細設定」タブから切り替えまたは無効化できます。', localAsrTakeoverHint: '「{{name}}」を有効化すると ASR プロバイダーが引き継がれます。', asrProviderTakenOver: 'ASR プロバイダーは引き継ぎ済み', @@ -728,7 +878,7 @@ export const ja: typeof zhCN = { enable: '有効化', alreadyActive: '有効', disableLocalLabel: 'ローカル ASR を無効化', - disableLocalDesc: 'クラウド ASR(既定は Volcengine bigasr)に戻します。', + disableLocalDesc: 'クラウド ASR(既定は Volcengine ASR)に戻します。', disable: '無効化', platformNotSupported: 'このプラットフォームではローカル ASR モデル統合に対応していません。', confirmEnableLocalTitle: 'ローカル ASR を有効化しますか?', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index dbd068b1..b4ba9468 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -195,20 +195,162 @@ export const ko: typeof zhCN = { }, onboarding: { welcome: 'OpenLess 에 오신 것을 환영합니다', - intro: '로컬에서 말하고 로컬에서 입력합니다. 시작 전에 두 가지 시스템 권한이 필요합니다.', + kicker: 'FIRST RUN', + intro: '첫 받아쓰기 전에 마이크, 녹음 단축키, ASR / LLM 경로를 한 번에 확인합니다. 대부분의 사용자는 Volcengine ASR 로 시작하는 것이 좋고, 로컬 모델은 오프라인 또는 개인정보 민감 환경에 적합합니다.', + refreshStatus: '상태 새로고침', + enterApp: '일단 OpenLess 로 이동', + enterAppRaw: '원문 모드로 이동', + enterAppReady: 'OpenLess 로 이동', + providerReady: '공급자 준비 완료', + providerMissing: '공급자 미완료', + permissionReady: '준비 완료', + permissionPending: '대기 중', accessibilityTitle: '접근성', - hotkeyTitle: '전역 단축키', accessibilityDesc: '전역 단축키(기본 {{trigger}}) 감지와 인식 결과를 커서 위치에 입력하기 위해 사용합니다.', - hotkeyDesc: '전역 단축키 감지가 사용 가능한지 확인하기 위해 사용합니다.', - micTitle: '마이크', - micDesc: '음성 입력을 캡처하기 위해 사용합니다.', - actionNotApplicable: '권한 불필요', actionGranted: '허용됨', - actionOpenSystem: '시스템 설정 열기', actionGrant: '허용', actionRequestMic: '권한 대화상자 표시', accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).', - footerHint: '모든 권한이 부여되면 이 가이드는 자동으로 닫힙니다. 닫히지 않으면 메뉴 막대의 OpenLess → 종료 후 앱을 다시 실행해 주세요.', + steps: { + microphone: { + title: '마이크 테스트', + desc: '권한 허용 및 입력 레벨 확인', + }, + shortcut: { + title: '단축키 설정', + desc: '녹음하고 한 번 눌러 확인', + }, + providers: { + title: 'ASR / LLM 구성', + desc: '클라우드 우선, 로컬은 필요 시', + }, + }, + panels: { + microphone: { + title: '먼저 OpenLess 가 내 목소리를 들을 수 있는지 확인', + desc: '마이크 권한과 입력 레벨이 첫 번째 관문입니다. 여기서 시스템 권한을 열고 실시간 레벨 바로 실제 입력을 확인합니다.', + }, + shortcut: { + title: '방해되지 않는 녹음 단축키 선택', + desc: '기본값이 모두에게 맞지는 않습니다. 단축키를 녹음한 뒤 이 창에서 한 번 눌러 확인하세요.', + }, + providers: { + title: '전사 경로를 선택하고 LLM 설정 완료', + desc: '신규 사용자는 보통 Volcengine ASR 로 시작하는 것이 안정적입니다. 로컬 ASR 은 오프라인, 개인정보 민감, 네트워크 제한 상황에 적합합니다. 다듬기, 번역, 선택 영역 Q&A 에는 LLM 도 필요합니다.', + }, + }, + micPanelTitle: '시스템 권한', + micPanelDesc: 'OpenLess 는 최소한 마이크 권한이 필요합니다. 일부 플랫폼에서는 전역 단축키와 텍스트 입력을 위해 접근성 권한도 필요합니다.', + micPermissionLabel: '마이크', + micPermissionDesc: '음성 입력을 캡처합니다.', + micPermissionReady: 'OpenLess 의 마이크 사용이 허용되었습니다.', + micTestTitle: '입력 레벨 테스트', + micTestStart: '테스트 시작', + micTestStop: '테스트 중지', + micHeard: '오디오 감지됨', + micListening: '듣는 중', + micWaiting: '테스트 대기', + micTestFailed: '테스트 실패', + micPermissionBlocked: '마이크 권한이 아직 켜져 있지 않습니다. 먼저 시스템 설정에서 OpenLess 의 마이크 사용을 허용해 주세요.', + shortcutPanelTitle: '녹음 단축키', + shortcutPanelDesc: '채팅 전송, IDE 명령, 입력기 전환에 쓰지 않는 조합을 권장합니다.', + shortcutTestTitle: '단축키 테스트', + shortcutCurrent: '현재 녹음 단축키: {{shortcut}}', + shortcutNotSet: '아직 녹음 단축키가 설정되지 않았습니다.', + shortcutTestStart: '단축키 테스트', + shortcutListening: '현재 단축키를 누르세요…', + shortcutMatched: '이 단축키가 감지되었습니다.', + shortcutMissed: '이번 키 입력은 현재 설정과 일치하지 않습니다. 다시 시도해 주세요.', + shortcutTestHint: '"단축키 테스트"를 누른 뒤 이 창에서 현재 조합을 한 번 누르세요. Esc 로 취소할 수 있습니다.', + hotkeyStatusUnknown: '리스너 상태 알 수 없음', + hotkeyInstalled: '전역 리스너 설치됨', + hotkeyStarting: '전역 리스너 시작 중', + hotkeyFailed: '전역 리스너 오류', + cloudAsrTitle: '추천: Volcengine ASR', + cloudAsrDesc: '대부분의 신규 사용자에게 적합하며 속도와 중국어 정확도가 더 안정적입니다. 가이드에 따라 앱을 만든 뒤 APP ID, Access Token, Resource ID 를 아래에 입력하세요.', + openVolcengineDoc: 'Volcengine 가이드 열기', + localAsrTitle: '선택: 로컬 ASR', + localAsrDesc: '오프라인, 개인정보 분리, 클라우드 ASR API 미사용이 필요할 때만 권장합니다. 첫 준비 시 모델을 다운로드하며 속도와 정확도는 기기에 따라 달라집니다.', + openLocalAsrSettings: '로컬 모델 보기', + providerStatusTitle: '현재 상태', + asrStatus: '음성 텍스트 변환', + llmStatus: '스마트 정리', + providerSettingsTitle: '공급자 설정 입력', + providerSettingsDesc: '아래는 설정 화면의 공급자 영역입니다. API Key, 모델, 로컬 모델 옵션을 여기서 바로 저장할 수 있습니다.', + providersTab: '공급자', + localModelTab: '로컬 모델', + progress: '{{current}} / {{total}} 단계', + back: '이전', + next: '다음', + statusDone: '완료', + statusTodo: '대기', + slideMicKicker: '1단계', + slideMicTitle: '마이크 테스트', + slideMicDesc: '권한을 허용하고 실제 입력 레벨을 확인합니다.', + slideShortcutKicker: '2단계', + slideShortcutTitle: '녹음 키 설정', + slideShortcutDesc: '단축키를 기록한 뒤 한 번 눌러 확인합니다.', + slideAsrKicker: '3단계', + slideAsrTitle: '음성을 텍스트로 연결', + slideAsrDesc: '처음에는 속도와 정확도가 좋은 온라인 전사를 권장합니다.', + cloudAsrDescShort: '처음에는 클라우드 음성 텍스트 변환을 권장합니다.', + asrSetupTitle: '클라우드 음성 텍스트 변환 연결', + asrSetupDesc: '먼저 {{provider}} 연결을 권장합니다. 이미 키가 있는 다른 온라인 전사 서비스도 선택할 수 있습니다.', + asrHaveKey: 'Volcengine 계정 키가 있습니다', + asrProviderLabel: '온라인 전사 서비스', + asrConnectSelected: '선택한 서비스 연결', + asrOtherOnlineKey: '온라인 ASR API Key', + asrAdvancedToggle: '고급: Resource ID', + asrProviderNote: 'OpenLess 는 공유 인증 정보를 포함하지 않습니다. 본인 공급자 계정 키를 사용하세요. 로컬 모델은 고급 설정에서 켤 수 있습니다.', + asrDefer: '나중에 설정에서 연결', + asrSkipNote: '음성 텍스트 변환 서비스가 연결되기 전에는 녹음이 텍스트가 되지 않습니다.', + saveAsr: '전사 서비스 저장', + recommendedBadge: '추천', + slideLlmKicker: '선택', + slideLlmTitle: '출력 방식 선택', + slideLlmDesc: '먼저 받아쓰기를 시작하고, 스마트 정리는 나중에 정할 수 있습니다.', + rawModeReady: '원본 전사 준비', + llmOptionalHint: '스마트 정리를 연결하지 않으면 원본 전사를 먼저 출력합니다.', + llmRawTitle: '먼저 원본 전사만', + llmRawDesc: '다듬기나 번역 없이 먼저 실행해 보기 좋습니다.', + llmSmartTitle: '스마트 정리 연결', + llmSmartDesc: '다듬기, 번역, Q&A가 필요할 때 API Key를 추가하세요.', + llmHaveKey: 'Ark / OpenAI 호환 API Key', + saveLlm: '스마트 정리 저장', + rawFallbackBadge: '건너뛰기 가능', + slideDoneKicker: '완료', + slideDoneTitleReady: '받아쓰기를 시작할 수 있습니다', + slideDoneTitleRaw: '원본 전사로 시작', + slideDoneDescReady: '마이크, 단축키, 공급자가 준비되었습니다.', + slideDoneDescRaw: '스마트 정리가 없으면 원본 전사를 먼저 출력합니다.', + doneNeedsAsrTitle: '음성 텍스트 변환 서비스가 아직 필요합니다', + doneNeedsAsrDesc: '마이크와 단축키는 먼저 준비할 수 있지만, 받아쓰기를 시작하려면 온라인 전사 서비스가 필요합니다.', + doneNeedsAsrHint: '먼저 Volcengine 또는 이미 키가 있는 온라인 ASR 을 연결하세요.', + doneEnterAnyway: '일단 OpenLess 들어가기', + llmOptionalTitle: '스마트 정리는 나중에 켤 수 있습니다', + llmOptionalDesc: '모델을 연결하지 않으면 OpenLess 는 먼저 말한 내용을 그대로 출력합니다.', + llmPreviewRawTitle: '말한 내용만 입력', + llmPreviewRawDesc: '원본 전사, 가장 빠르게 시작합니다.', + llmPreviewSmartTitle: '다듬기 또는 번역', + llmPreviewSmartDesc: 'API Key 가 필요하며 나중에 켤 수 있습니다.', + rawMode: '원본 전사', + available: '사용 가능', + enabled: '사용 중', + primaryMicReady: '단축키 설정으로', + primaryMicGrant: '마이크 허용', + primaryMicSkip: '나중에 허용하고 계속', + primaryShortcutReady: '전사 연결로', + primaryShortcutSkip: '나중에 설정하고 계속', + primaryAsrReady: '계속', + primaryAsrConnect: '전사 서비스 연결', + primaryAsrSave: '전사 저장', + primaryAsrSkip: '나중에 연결하고 계속', + primaryLlmReady: '계속', + primaryLlmSkip: '먼저 원본 전사 사용', + primaryDoneNeedsAsr: '전사 서비스 연결', + doneAsrMissing: '나중에 연결', + doneRawOutput: '원본 전사만', + doneSmartOutput: '사용 중', }, overview: { kicker: 'DASHBOARD', @@ -498,6 +640,7 @@ export const ko: typeof zhCN = { title: '설정', desc: '녹음 방식, 모델과 음성 공급자, 단축키, 권한, 정보 — 모두 여기 모여 있습니다.', sections: { + setup: '가이드', recording: '녹음', providers: '공급자', shortcuts: '단축키', @@ -506,6 +649,11 @@ export const ko: typeof zhCN = { advanced: '고급', about: '정보', }, + setup: { + title: '설정 가이드', + desc: '마이크, 녹음 키, 온라인 ASR 을 다시 확인합니다.', + open: '가이드 열기', + }, recording: { title: '녹음', desc: '전역 녹음의 단축키와 트리거 방식을 정의합니다.', @@ -516,6 +664,8 @@ export const ko: typeof zhCN = { modeDesc: '토글 방식 = 한 번 누르면 시작, 다시 누르면 종료; 눌러서 말하기 = 누르고 있는 동안만 녹음.', modeToggle: '토글 방식', modeHold: '눌러서 말하기', + rightCtrlHoldWarningTitle: '오른쪽 Ctrl 이 채팅 전송 단축키와 충돌할 수 있습니다', + rightCtrlHoldWarningDesc: '눌러서 말하기는 녹음 중 오른쪽 Ctrl 을 예약하므로 QQ / WeChat 등의 오른쪽 Ctrl+Enter 가 Enter 만으로 전달될 수 있습니다. 토글 방식으로 돌아가거나 메시지 전송에 쓰지 않는 단축키를 선택하세요.', migrationNoticeTitle: '기본값이 토글 방식으로 변경됨', migrationNoticeDesc: '이전에 트리거 방식을 변경했다면 여기서 다시 한 번 확인해 주세요. 이번 업데이트는 단축키 방식의 기본값과 읽기 로직을 조정했습니다. "눌러서 말하기"가 더 익숙하다면 다시 전환할 수 있습니다.', microphoneLabel: '기본 선택 마이크', @@ -597,7 +747,7 @@ export const ko: typeof zhCN = { alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', custom: '사용자 정의', - asrVolcengine: 'Volcengine bigasr', + asrVolcengine: 'Volcengine ASR', asrBailian: 'Alibaba Bailian 실시간 ASR', asrSiliconflow: 'SiliconFlow SenseVoice', asrZhipu: 'Zhipu GLM-ASR', @@ -609,7 +759,7 @@ export const ko: typeof zhCN = { volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', - volcengineMappingNote: 'Secret Key 는 현재 입력 불필요. Resource ID 기본값은 volc.bigasr.sauc.duration.', + volcengineMappingNote: 'Secret Key 는 현재 입력 불필요. Resource ID 가 volc.seedasr.sauc.duration 인지 확인하세요.', localAsrActiveNotice: '현재 "{{name}}" 사용 중. "고급" 탭에서 전환 또는 비활성화할 수 있습니다.', localAsrTakeoverHint: '"{{name}}" 활성화 시 ASR 프로바이더가 인수됩니다.', asrProviderTakenOver: 'ASR 프로바이더 인수 완료', @@ -728,7 +878,7 @@ export const ko: typeof zhCN = { enable: '활성화', alreadyActive: '활성', disableLocalLabel: '로컬 ASR 비활성화', - disableLocalDesc: '클라우드 ASR (기본 Volcengine bigasr) 로 돌아갑니다.', + disableLocalDesc: '클라우드 ASR (기본 Volcengine ASR) 로 돌아갑니다.', disable: '비활성화', platformNotSupported: '이 플랫폼에서는 로컬 ASR 모델 통합이 아직 지원되지 않습니다.', confirmEnableLocalTitle: '로컬 ASR 을 활성화할까요?', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 089cbbbe..a2d82180 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -191,20 +191,162 @@ export const zhCN = { }, onboarding: { welcome: '欢迎使用 OpenLess', - intro: '本地说出,本地落字。开始前需要两个系统权限。', + kicker: 'FIRST RUN', + intro: '先把能不能听、怎么开始录、用哪条 ASR / LLM 路线一次配清楚。多数用户建议先用火山 ASR,本地模型保留给离线或隐私敏感场景。', + refreshStatus: '刷新状态', + enterApp: '先进入 OpenLess', + enterAppRaw: '先用原文模式进入', + enterAppReady: '进入 OpenLess', + providerReady: '服务已就绪', + providerMissing: '服务未完成', + permissionReady: '已就绪', + permissionPending: '待处理', accessibilityTitle: '辅助功能', - hotkeyTitle: '全局快捷键', accessibilityDesc: '用于监听全局快捷键(默认 {{trigger}})并把识别结果写入光标位置。', - hotkeyDesc: '用于确认全局快捷键监听可用。', - micTitle: '麦克风', - micDesc: '用于捕获你的语音输入。', - actionNotApplicable: '无需授权', actionGranted: '已授权', - actionOpenSystem: '打开系统设置', actionGrant: '授权', actionRequestMic: '弹出授权', accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。', - footerHint: '授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。', + steps: { + microphone: { + title: '测试麦克风', + desc: '授权并确认有输入电平', + }, + shortcut: { + title: '配置快捷键', + desc: '录制并实际按一次', + }, + providers: { + title: '配置 ASR / LLM', + desc: '火山 ASR 优先,本地按需', + }, + }, + panels: { + microphone: { + title: '先确认 OpenLess 听得到你', + desc: '麦克风权限和输入电平是第一道门。这里会直接拉起系统授权,并用实时电平条确认设备有声音。', + }, + shortcut: { + title: '设置一个不会误触的录音快捷键', + desc: '默认快捷键不是每个人都顺手。先录制,再在这个窗口里按一次确认,后面真正听写时就少踩坑。', + }, + providers: { + title: '选择转写路线,再补齐 LLM', + desc: '日常新手默认走火山 ASR 更稳;本地 ASR 更适合离线、隐私敏感或网络受限。润色、翻译和划词问答还需要 LLM。', + }, + }, + micPanelTitle: '系统权限', + micPanelDesc: 'OpenLess 至少需要麦克风权限;部分平台还需要辅助功能来监听全局快捷键和写入文本。', + micPermissionLabel: '麦克风', + micPermissionDesc: '用于捕获你的语音输入。', + micPermissionReady: '已允许 OpenLess 使用麦克风。', + micTestTitle: '输入电平测试', + micTestStart: '开始测试', + micTestStop: '停止测试', + micHeard: '已听到声音', + micListening: '正在监听', + micWaiting: '等待测试', + micTestFailed: '测试失败', + micPermissionBlocked: '麦克风权限还未打开,请先在系统设置里允许 OpenLess 使用麦克风。', + shortcutPanelTitle: '录音快捷键', + shortcutPanelDesc: '推荐使用不参与聊天发送、IDE 命令或系统输入法切换的按键组合。', + shortcutTestTitle: '快捷键测试', + shortcutCurrent: '当前录音快捷键:{{shortcut}}', + shortcutNotSet: '还没有设置录音快捷键。', + shortcutTestStart: '测试快捷键', + shortcutListening: '按下当前快捷键…', + shortcutMatched: '已识别到这组快捷键。', + shortcutMissed: '这次按键没有匹配当前设置,请再试一次。', + shortcutTestHint: '点“测试快捷键”后,在此窗口按一次当前组合键。Esc 可取消。', + hotkeyStatusUnknown: '监听状态未知', + hotkeyInstalled: '全局监听已安装', + hotkeyStarting: '全局监听启动中', + hotkeyFailed: '全局监听异常', + cloudAsrTitle: '推荐:火山 ASR', + cloudAsrDesc: '适合绝大多数新用户,识别速度和中文准确率更稳。按文档创建应用后,把 APP ID、Access Token、Resource ID 填到下方。', + openVolcengineDoc: '打开火山文档', + localAsrTitle: '按需:本地 ASR', + localAsrDesc: '仅建议离线、隐私敏感或不想使用云端 ASR API 时开启。首次准备会下载模型,速度和准确率取决于机器。', + openLocalAsrSettings: '查看本地模型', + providerStatusTitle: '当前状态', + asrStatus: '语音转文字', + llmStatus: '智能整理', + providerSettingsTitle: '填写服务商配置', + providerSettingsDesc: '下方就是设置页的服务商区,可直接保存 API Key、模型和本地模型选项。', + providersTab: '服务商', + localModelTab: '本地模型', + progress: '第 {{current}} / {{total}} 步', + back: '上一步', + next: '下一步', + statusDone: '已完成', + statusTodo: '待完成', + slideMicKicker: '第一步', + slideMicTitle: '先试麦克风', + slideMicDesc: '确认系统授权,并看见真实输入电平。', + slideShortcutKicker: '第二步', + slideShortcutTitle: '设置录音键', + slideShortcutDesc: '录一个顺手的快捷键,再按一次确认。', + slideAsrKicker: '第三步', + slideAsrTitle: '连接语音转文字', + slideAsrDesc: '优先使用在线转写,速度和准确率更适合新用户。', + cloudAsrDescShort: '推荐新用户先用云端语音转文字。', + asrSetupTitle: '连接云端语音转文字', + asrSetupDesc: '推荐先连接 {{provider}}。也可以选择你已有密钥的其他在线转写服务。', + asrHaveKey: '我有火山账号密钥', + asrProviderLabel: '在线转写服务', + asrConnectSelected: '连接所选服务', + asrOtherOnlineKey: '在线 ASR API Key', + asrAdvancedToggle: '高级:Resource ID', + asrProviderNote: 'OpenLess 不内置公益凭据,需要使用你自己的服务商账号密钥。本地模型在设置的高级页里按需开启。', + asrDefer: '稍后在设置里连接', + asrSkipNote: '语音转文字服务没连好前,录音还不能变成文字。', + saveAsr: '保存转写服务', + recommendedBadge: '推荐', + slideLlmKicker: '可选', + slideLlmTitle: '选择输出方式', + slideLlmDesc: '先开始听写,之后再决定要不要智能整理。', + rawModeReady: '原始转写可用', + llmOptionalHint: '不连接智能整理时,会先只输出原始转写。', + llmRawTitle: '先只输出原始转写', + llmRawDesc: '不润色、不翻译,适合先跑通。', + llmSmartTitle: '连接智能整理', + llmSmartDesc: '需要润色、翻译和问答时再填 API Key。', + llmHaveKey: 'Ark / OpenAI 兼容 API Key', + saveLlm: '保存智能整理', + rawFallbackBadge: '可跳过', + slideDoneKicker: '完成', + slideDoneTitleReady: '可以开始听写了', + slideDoneTitleRaw: '先用原始转写', + slideDoneDescReady: '麦克风、快捷键和服务商已就绪。', + slideDoneDescRaw: '没有智能整理时,会自动只输出原始转写。', + doneNeedsAsrTitle: '还差语音转文字服务', + doneNeedsAsrDesc: '麦克风和快捷键可以先准备好,但要开始听写还需要连接一个在线转写服务。', + doneNeedsAsrHint: '建议先连接火山或你已有密钥的在线 ASR。', + doneEnterAnyway: '先进入 OpenLess', + llmOptionalTitle: '智能整理可以稍后开启', + llmOptionalDesc: '不连接模型时,OpenLess 会先只输出你说话的原文。', + llmPreviewRawTitle: '只把我说的话打出来', + llmPreviewRawDesc: '原始转写,最快跑通。', + llmPreviewSmartTitle: '顺手润色或翻译', + llmPreviewSmartDesc: '需要 API Key,可之后再开。', + rawMode: '原始转写', + available: '可使用', + enabled: '已启用', + primaryMicReady: '继续设置快捷键', + primaryMicGrant: '允许麦克风', + primaryMicSkip: '稍后授权,继续', + primaryShortcutReady: '继续连接转写', + primaryShortcutSkip: '稍后设置,继续', + primaryAsrReady: '继续', + primaryAsrConnect: '连接转写服务', + primaryAsrSave: '保存转写服务', + primaryAsrSkip: '稍后连接,继续', + primaryLlmReady: '继续', + primaryLlmSkip: '先用原始转写', + primaryDoneNeedsAsr: '连接转写服务', + doneAsrMissing: '稍后连接', + doneRawOutput: '只输出原始转写', + doneSmartOutput: '已开启', }, overview: { kicker: 'DASHBOARD', @@ -494,6 +636,7 @@ export const zhCN = { title: '设置', desc: '录音方式、模型与语音提供商、快捷键、权限与关于信息——全部在这里。', sections: { + setup: '引导', recording: '录音', providers: '提供商', shortcuts: '快捷键', @@ -502,6 +645,11 @@ export const zhCN = { advanced: '高级', about: '关于', }, + setup: { + title: '设置引导', + desc: '重新检查麦克风、录音键和在线 ASR。', + open: '打开引导', + }, recording: { title: '录音', desc: '全局录音的快捷键与触发方式。', @@ -512,6 +660,8 @@ export const zhCN = { modeDesc: '切换式按一次开始、再按一次结束;按住说话按下保持、松开结束。', modeToggle: '切换式', modeHold: '按住说话', + rightCtrlHoldWarningTitle: '右 Ctrl 可能与聊天发送快捷键冲突', + rightCtrlHoldWarningDesc: '按住说话会在录音期间占用右 Ctrl,QQ / 微信里的右 Ctrl+Enter 可能只收到 Enter。建议切回切换式,或改用不参与发送消息的快捷键。', migrationNoticeTitle: '默认已改为切换式说话', migrationNoticeDesc: '本次更新调整了默认值,如果习惯按住说话,请在此处切回。', microphoneLabel: '首选麦克风', @@ -593,7 +743,7 @@ export const zhCN = { alibabaCoding: '阿里云 Coding Plan', codingPlanX: 'CodingPlanX', custom: '自定义', - asrVolcengine: '火山引擎 bigasr', + asrVolcengine: '火山引擎 ASR', asrBailian: '阿里云百炼实时 ASR', asrSiliconflow: '硅基流动 SenseVoice', asrZhipu: '智谱 GLM-ASR', @@ -605,7 +755,7 @@ export const zhCN = { volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', - volcengineMappingNote: 'Secret Key 当前无需填写。Resource ID 默认使用 volc.bigasr.sauc.duration。', + volcengineMappingNote: 'Secret Key 当前无需填写。请确认 Resource ID 为 volc.seedasr.sauc.duration。', localAsrActiveNotice: '当前已启用「{{name}}」,可在「高级」中切换或禁用。', localAsrTakeoverHint: '启动「{{name}}」后,ASR 提供商将被接管。', asrProviderTakenOver: 'ASR 提供商已被接管', @@ -724,7 +874,7 @@ export const zhCN = { enable: '启用', alreadyActive: '已启用', disableLocalLabel: '禁用本地 ASR', - disableLocalDesc: '切回云端 ASR(默认火山引擎 bigasr)。', + disableLocalDesc: '切回云端 ASR(默认火山引擎 ASR)。', disable: '禁用', platformNotSupported: '该平台暂未支持本地 ASR 模型集成。', confirmEnableLocalTitle: '启用本地 ASR?', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 34a11160..9d916f31 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -193,20 +193,162 @@ export const zhTW: typeof zhCN = { }, onboarding: { welcome: '歡迎使用 OpenLess', - intro: '本地說出,本地落字。開始前需要兩個系統權限。', + kicker: 'FIRST RUN', + intro: '先把能不能聽、怎麼開始錄、用哪條 ASR / LLM 路線一次配清楚。多數用戶建議先用火山 ASR,本地模型保留給離線或隱私敏感場景。', + refreshStatus: '刷新狀態', + enterApp: '先進入 OpenLess', + enterAppRaw: '先用原文模式進入', + enterAppReady: '進入 OpenLess', + providerReady: '服務已就緒', + providerMissing: '服務未完成', + permissionReady: '已就緒', + permissionPending: '待處理', accessibilityTitle: '輔助功能', - hotkeyTitle: '全局快捷鍵', accessibilityDesc: '用於監聽全局快捷鍵(默認 {{trigger}})並把識別結果寫入光標位置。', - hotkeyDesc: '用於確認全局快捷鍵監聽可用。', - micTitle: '麥克風', - micDesc: '用於捕獲你的語音輸入。', - actionNotApplicable: '無需授權', actionGranted: '已授權', - actionOpenSystem: '打開系統設置', actionGrant: '授權', actionRequestMic: '彈出授權', accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。', - footerHint: '授權全部完成後此引導自動關閉。如果一直不消失,從菜單欄 OpenLess → 退出,重新打開 App。', + steps: { + microphone: { + title: '測試麥克風', + desc: '授權並確認有輸入電平', + }, + shortcut: { + title: '配置快捷鍵', + desc: '錄製並實際按一次', + }, + providers: { + title: '配置 ASR / LLM', + desc: '火山 ASR 優先,本地按需', + }, + }, + panels: { + microphone: { + title: '先確認 OpenLess 聽得到你', + desc: '麥克風權限和輸入電平是第一道門。這裏會直接拉起系統授權,並用實時電平條確認設備有聲音。', + }, + shortcut: { + title: '設置一個不會誤觸的錄音快捷鍵', + desc: '默認快捷鍵不是每個人都順手。先錄製,再在這個窗口裏按一次確認,後面真正聽寫時就少踩坑。', + }, + providers: { + title: '選擇轉寫路線,再補齊 LLM', + desc: '日常新手默認走火山 ASR 更穩;本地 ASR 更適合離線、隱私敏感或網絡受限。潤色、翻譯和劃詞問答還需要 LLM。', + }, + }, + micPanelTitle: '系統權限', + micPanelDesc: 'OpenLess 至少需要麥克風權限;部分平台還需要輔助功能來監聽全局快捷鍵和寫入文本。', + micPermissionLabel: '麥克風', + micPermissionDesc: '用於捕獲你的語音輸入。', + micPermissionReady: '已允許 OpenLess 使用麥克風。', + micTestTitle: '輸入電平測試', + micTestStart: '開始測試', + micTestStop: '停止測試', + micHeard: '已聽到聲音', + micListening: '正在監聽', + micWaiting: '等待測試', + micTestFailed: '測試失敗', + micPermissionBlocked: '麥克風權限還未打開,請先在系統設置裏允許 OpenLess 使用麥克風。', + shortcutPanelTitle: '錄音快捷鍵', + shortcutPanelDesc: '推薦使用不參與聊天送出、IDE 命令或系統輸入法切換的按鍵組合。', + shortcutTestTitle: '快捷鍵測試', + shortcutCurrent: '當前錄音快捷鍵:{{shortcut}}', + shortcutNotSet: '還沒有設置錄音快捷鍵。', + shortcutTestStart: '測試快捷鍵', + shortcutListening: '按下當前快捷鍵…', + shortcutMatched: '已識別到這組快捷鍵。', + shortcutMissed: '這次按鍵沒有匹配當前設置,請再試一次。', + shortcutTestHint: '點「測試快捷鍵」後,在此窗口按一次當前組合鍵。Esc 可取消。', + hotkeyStatusUnknown: '監聽狀態未知', + hotkeyInstalled: '全局監聽已安裝', + hotkeyStarting: '全局監聽啟動中', + hotkeyFailed: '全局監聽異常', + cloudAsrTitle: '推薦:火山 ASR', + cloudAsrDesc: '適合絕大多數新用戶,識別速度和中文準確率更穩。按文檔創建應用後,把 APP ID、Access Token、Resource ID 填到下方。', + openVolcengineDoc: '打開火山文檔', + localAsrTitle: '按需:本地 ASR', + localAsrDesc: '僅建議離線、隱私敏感或不想使用雲端 ASR API 時開啟。首次準備會下載模型,速度和準確率取決於機器。', + openLocalAsrSettings: '查看本地模型', + providerStatusTitle: '當前狀態', + asrStatus: '語音轉文字', + llmStatus: '智能整理', + providerSettingsTitle: '填寫服務商配置', + providerSettingsDesc: '下方就是設置頁的服務商區,可直接保存 API Key、模型和本地模型選項。', + providersTab: '服務商', + localModelTab: '本地模型', + progress: '第 {{current}} / {{total}} 步', + back: '上一步', + next: '下一步', + statusDone: '已完成', + statusTodo: '待完成', + slideMicKicker: '第一步', + slideMicTitle: '先試麥克風', + slideMicDesc: '確認系統授權,並看見真實輸入電平。', + slideShortcutKicker: '第二步', + slideShortcutTitle: '設定錄音鍵', + slideShortcutDesc: '錄一個順手的快捷鍵,再按一次確認。', + slideAsrKicker: '第三步', + slideAsrTitle: '連接語音轉文字', + slideAsrDesc: '優先使用線上轉寫,速度和準確率更適合新使用者。', + cloudAsrDescShort: '推薦新使用者先用雲端語音轉文字。', + asrSetupTitle: '連接雲端語音轉文字', + asrSetupDesc: '推薦先連接 {{provider}}。也可以選擇你已有密鑰的其他線上轉寫服務。', + asrHaveKey: '我有火山帳號密鑰', + asrProviderLabel: '線上轉寫服務', + asrConnectSelected: '連接所選服務', + asrOtherOnlineKey: '線上 ASR API Key', + asrAdvancedToggle: '進階:Resource ID', + asrProviderNote: 'OpenLess 不內建公益憑據,需要使用你自己的服務商帳號密鑰。本地模型在設定的進階頁裡按需開啟。', + asrDefer: '稍後在設定裡連接', + asrSkipNote: '語音轉文字服務沒連好前,錄音還不能變成文字。', + saveAsr: '保存轉寫服務', + recommendedBadge: '推薦', + slideLlmKicker: '可選', + slideLlmTitle: '選擇輸出方式', + slideLlmDesc: '先開始聽寫,之後再決定要不要智能整理。', + rawModeReady: '原始轉寫可用', + llmOptionalHint: '不連接智能整理時,會先只輸出原始轉寫。', + llmRawTitle: '先只輸出原始轉寫', + llmRawDesc: '不潤色、不翻譯,適合先跑通。', + llmSmartTitle: '連接智能整理', + llmSmartDesc: '需要潤色、翻譯和問答時再填 API Key。', + llmHaveKey: 'Ark / OpenAI 相容 API Key', + saveLlm: '保存智能整理', + rawFallbackBadge: '可跳過', + slideDoneKicker: '完成', + slideDoneTitleReady: '可以開始聽寫了', + slideDoneTitleRaw: '先用原始轉寫', + slideDoneDescReady: '麥克風、快捷鍵和服務商已就緒。', + slideDoneDescRaw: '沒有智能整理時,會自動只輸出原始轉寫。', + doneNeedsAsrTitle: '還差語音轉文字服務', + doneNeedsAsrDesc: '麥克風和快捷鍵可以先準備好,但要開始聽寫還需要連接一個線上轉寫服務。', + doneNeedsAsrHint: '建議先連接火山或你已有密鑰的線上 ASR。', + doneEnterAnyway: '先進入 OpenLess', + llmOptionalTitle: '智能整理可以稍後開啟', + llmOptionalDesc: '不連接模型時,OpenLess 會先只輸出你說話的原文。', + llmPreviewRawTitle: '只把我說的話打出來', + llmPreviewRawDesc: '原始轉寫,最快跑通。', + llmPreviewSmartTitle: '順手潤色或翻譯', + llmPreviewSmartDesc: '需要 API Key,可之後再開。', + rawMode: '原始轉寫', + available: '可使用', + enabled: '已啟用', + primaryMicReady: '繼續設定快捷鍵', + primaryMicGrant: '允許麥克風', + primaryMicSkip: '稍後授權,繼續', + primaryShortcutReady: '繼續連接轉寫', + primaryShortcutSkip: '稍後設定,繼續', + primaryAsrReady: '繼續', + primaryAsrConnect: '連接轉寫服務', + primaryAsrSave: '保存轉寫服務', + primaryAsrSkip: '稍後連接,繼續', + primaryLlmReady: '繼續', + primaryLlmSkip: '先用原始轉寫', + primaryDoneNeedsAsr: '連接轉寫服務', + doneAsrMissing: '稍後連接', + doneRawOutput: '只輸出原始轉寫', + doneSmartOutput: '已開啟', }, overview: { kicker: 'DASHBOARD', @@ -496,6 +638,7 @@ export const zhTW: typeof zhCN = { title: '設置', desc: '錄音方式、模型與語音提供商、快捷鍵、權限與關於信息——全部在這裏。', sections: { + setup: '引導', recording: '錄音', providers: '提供商', shortcuts: '快捷鍵', @@ -504,6 +647,11 @@ export const zhTW: typeof zhCN = { advanced: '高級', about: '關於', }, + setup: { + title: '設置引導', + desc: '重新檢查麥克風、錄音鍵和線上 ASR。', + open: '打開引導', + }, recording: { title: '錄音', desc: '定義全局錄音的快捷鍵與觸發方式。', @@ -514,6 +662,8 @@ export const zhTW: typeof zhCN = { modeDesc: '切換式 = 按一次開始、再按一次結束;按住說話 = 按住開始、鬆開結束。', modeToggle: '切換式', modeHold: '按住說話', + rightCtrlHoldWarningTitle: '右 Ctrl 可能與聊天送出快捷鍵衝突', + rightCtrlHoldWarningDesc: '按住說話會在錄音期間佔用右 Ctrl,QQ / 微信裏的右 Ctrl+Enter 可能只收到 Enter。建議切回切換式,或改用不參與送出訊息的快捷鍵。', migrationNoticeTitle: '默認已改爲切換式說話', migrationNoticeDesc: '如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。', comboRecordLabel: '錄製快捷鍵', @@ -595,7 +745,7 @@ export const zhTW: typeof zhCN = { alibabaCoding: '阿里雲 Coding Plan', codingPlanX: 'CodingPlanX', custom: '自定義', - asrVolcengine: '火山引擎 bigasr', + asrVolcengine: '火山引擎 ASR', asrBailian: '阿里雲百煉即時 ASR', asrSiliconflow: '硅基流動 SenseVoice', asrZhipu: '智譜 GLM-ASR', @@ -607,7 +757,7 @@ export const zhTW: typeof zhCN = { volcengineAppKeyLabel: 'APP ID', volcengineAccessKeyLabel: 'Access Token', volcengineResourceIdLabel: 'Resource ID', - volcengineMappingNote: 'Secret Key 當前無需填寫。Resource ID 默認使用 volc.bigasr.sauc.duration。', + volcengineMappingNote: 'Secret Key 目前無需填寫。請確認 Resource ID 為 volc.seedasr.sauc.duration。', localAsrActiveNotice: '當前已啓用「{{name}}」,可在「高級」中切換或停用。', localAsrTakeoverHint: '啓動「{{name}}」後,ASR 提供商將被接管。', asrProviderTakenOver: 'ASR 提供商已被接管', @@ -726,7 +876,7 @@ export const zhTW: typeof zhCN = { enable: '啓用', alreadyActive: '已啓用', disableLocalLabel: '停用本地 ASR', - disableLocalDesc: '切回雲端 ASR(默認火山引擎 bigasr)。', + disableLocalDesc: '切回雲端 ASR(預設火山引擎 ASR)。', disable: '停用', platformNotSupported: '該平臺暫未支持本地 ASR 模型集成。', confirmEnableLocalTitle: '啓用本地 ASR?', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index fb551d5d..ac026a2e 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -69,9 +69,10 @@ let mockSettings: UserPreferences = { customStylePrompts: { raw: '', light: '', structured: '', formal: '' }, launchAtLogin: false, showCapsule: true, + onboardingVersion: 0, muteDuringRecording: false, microphoneDeviceName: '', - activeAsrProvider: 'foundry-local-whisper', + activeAsrProvider: 'volcengine', activeLlmProvider: 'ark', llmThinkingEnabled: false, restoreClipboardAfterPaste: true, @@ -378,15 +379,44 @@ const mockHotkeyCapability: HotkeyCapability = { statusHint: '默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。', }; -const mockCredentialsStatus: CredentialsStatus = { - activeAsrProvider: 'foundry-local-whisper', +const mockCredentialValues: Record = {}; + +let mockCredentialsStatus: CredentialsStatus = { + activeAsrProvider: 'volcengine', activeLlmProvider: 'ark', - asrConfigured: true, - llmConfigured: true, - volcengineConfigured: true, - arkConfigured: true, + asrConfigured: false, + llmConfigured: false, + volcengineConfigured: false, + arkConfigured: false, }; +function syncMockCredentialsStatus() { + const volcengineConfigured = Boolean( + mockCredentialValues['volcengine.app_key']?.trim() + && mockCredentialValues['volcengine.access_key']?.trim() + ); + const genericAsrConfigured = Boolean( + mockCredentialValues['asr.api_key']?.trim() + && mockCredentialValues['asr.endpoint']?.trim() + && mockCredentialValues['asr.model']?.trim() + ); + const bailianConfigured = Boolean(mockCredentialValues['asr.api_key']?.trim()); + const arkConfigured = Boolean(mockCredentialValues['ark.api_key']?.trim()); + mockCredentialsStatus = { + ...mockCredentialsStatus, + asrConfigured: mockCredentialsStatus.activeAsrProvider === 'volcengine' + ? volcengineConfigured + : mockCredentialsStatus.activeAsrProvider === 'bailian' + ? bailianConfigured + : genericAsrConfigured, + llmConfigured: mockCredentialsStatus.activeLlmProvider === 'ark' + ? arkConfigured + : mockCredentialsStatus.llmConfigured, + volcengineConfigured, + arkConfigured, + }; +} + export interface ProviderCheckResult { ok: boolean; } @@ -529,19 +559,33 @@ export function getCredentials(): Promise { } export function setCredential(account: string, value: string): Promise { - return invokeOrMock('set_credential', { account, value }, () => undefined); + return invokeOrMock('set_credential', { account, value }, () => { + mockCredentialValues[account] = value; + syncMockCredentialsStatus(); + return undefined; + }); } export function setActiveAsrProvider(provider: string): Promise { - return invokeOrMock('set_active_asr_provider', { provider }, () => undefined); + return invokeOrMock('set_active_asr_provider', { provider }, () => { + mockCredentialsStatus = { ...mockCredentialsStatus, activeAsrProvider: provider }; + mockSettings = { ...mockSettings, activeAsrProvider: provider }; + syncMockCredentialsStatus(); + return undefined; + }); } export function setActiveLlmProvider(provider: string): Promise { - return invokeOrMock('set_active_llm_provider', { provider }, () => undefined); + return invokeOrMock('set_active_llm_provider', { provider }, () => { + mockCredentialsStatus = { ...mockCredentialsStatus, activeLlmProvider: provider }; + mockSettings = { ...mockSettings, activeLlmProvider: provider }; + syncMockCredentialsStatus(); + return undefined; + }); } export function readCredential(account: string): Promise { - return invokeOrMock('read_credential', { account }, () => null); + return invokeOrMock('read_credential', { account }, () => mockCredentialValues[account] ?? null); } export function validateProviderCredentials(kind: 'llm' | 'asr'): Promise { diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 3f93ca38..6ae804b1 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -30,6 +30,7 @@ const previousPrefs: UserPreferences = { customStylePrompts: { raw: '', light: '', structured: '', formal: '' }, launchAtLogin: false, showCapsule: true, + onboardingVersion: 0, muteDuringRecording: false, microphoneDeviceName: '', activeAsrProvider: 'volcengine', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 67ff09e5..4197b1ce 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -216,6 +216,7 @@ export interface UserPreferences { customStylePrompts: CustomStylePrompts; launchAtLogin: boolean; showCapsule: boolean; + onboardingVersion: number; /** 录音期间临时静音系统输出,停止/取消/出错后恢复原静音状态。 */ muteDuringRecording: boolean; /** 录音输入设备名称。空字符串 = 使用系统默认麦克风。 */ diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 111a40e6..b2a83c48 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; +import { ONBOARDING_COMPLETE_KEY } from '../components/Onboarding'; import { ShortcutRecorder } from '../components/ShortcutRecorder'; import { detectOS } from '../components/WindowChrome'; import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; @@ -62,14 +63,15 @@ export const NAVIGATE_LOCAL_ASR_EVENT = 'openless:navigate-local-asr'; interface SettingsProps { embedded?: boolean; initialSection?: SettingsSectionId; + onStartOnboarding?: () => void; } // "关于" tab 已移除(内容并入外层 SettingsModal 的 About 页,避免设置内外重复入口)。 -export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permissions' | 'language' | 'advanced'; +export type SettingsSectionId = 'setup' | 'recording' | 'providers' | 'shortcuts' | 'permissions' | 'language' | 'advanced'; // 「高级」放最末——本地推理 / 实验性开关都集中到这一栏,避免新手用户在主流程 // 里误开 CPU 推理(之前提案:把 local-qwen3 / foundry-local-whisper 从主 ASR // 下拉藏进高级)。位置末尾也是「实验性」语义在 macOS 系统偏好里的惯用位置。 -const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language', 'advanced']; +const SECTION_ORDER: SettingsSectionId[] = ['setup', 'recording', 'providers', 'shortcuts', 'permissions', 'language', 'advanced']; async function autostartIsEnabled(): Promise { const { invoke } = await import('@tauri-apps/api/core'); @@ -86,7 +88,7 @@ async function autostartDisable(): Promise { await invoke('plugin:autostart|disable'); } -export function Settings({ embedded = false, initialSection = 'recording' }: SettingsProps) { +export function Settings({ embedded = false, initialSection = 'recording', onStartOnboarding }: SettingsProps) { const { t } = useTranslation(); const [section, setSection] = useState(initialSection); @@ -180,6 +182,7 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set ...(embedded ? { minHeight: 0, overflow: 'auto', paddingRight: 4, paddingBottom: 16 } : {}), }} > + {section === 'setup' && } {section === 'recording' && } {section === 'providers' && } {section === 'shortcuts' && } @@ -192,6 +195,56 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set ); } +function SetupSection({ onStartOnboarding }: { onStartOnboarding?: () => void }) { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + const [busy, setBusy] = useState(false); + + const start = async () => { + if (!onStartOnboarding || busy) return; + setBusy(true); + try { + if (prefs) { + await updatePrefs(current => ({ + ...current, + onboardingVersion: 0, + })); + } + try { + window.localStorage.removeItem(ONBOARDING_COMPLETE_KEY); + } catch { + // Persisted preferences decide the gate; localStorage is only legacy cleanup. + } + onStartOnboarding(); + } finally { + setBusy(false); + } + }; + + return ( + +
+
+
{t('settings.setup.title')}
+
+ {t('settings.setup.desc')} +
+
+ void start()} + style={{ flexShrink: 0 }} + > + {busy ? t('common.loading') : t('settings.setup.open')} + +
+
+ ); +} + function RecordingSection() { const { t } = useTranslation(); const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); @@ -343,6 +396,10 @@ function RecordingSection() { const selectedMicrophoneLabel = effectiveMicrophoneDeviceName ? effectiveMicrophoneDeviceName : t('settings.recording.microphoneDefault'); + const showRightCtrlHoldWarning = detectOS() === 'win' + && prefs.hotkey.mode === 'hold' + && prefs.dictationHotkey.primary === 'RightControl' + && prefs.dictationHotkey.modifiers.length === 0; return ( <> @@ -398,6 +455,25 @@ function RecordingSection() { ))}
+ {showRightCtrlHoldWarning && ( +
+
+ {t('settings.recording.rightCtrlHoldWarningTitle')} +
+
+ {t('settings.recording.rightCtrlHoldWarningDesc')} +
+
+ )}
- + );