diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 6c1670c6..284b3342 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -21,6 +21,7 @@ use crate::asr::local::sherpa_download::{ }; use crate::asr::local::{FoundryLocalRuntime, SherpaOnnxRuntime}; use crate::coordinator::Coordinator; +use crate::net; use crate::permissions::{self, PermissionStatus}; use crate::persistence::{ sync_style_pack_preferences, CredentialAccount, CredentialsSnapshot, CredentialsVault, @@ -356,16 +357,13 @@ pub struct LatestBetaRelease { /// 返回 `Ok(None)` = 当前没发过 Beta 版;`Err(String)` = 网络/解析故障。 #[tauri::command] pub async fn fetch_latest_beta_release() -> Result, String> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION"))) - .build() - .map_err(|e| format!("build http client: {e}"))?; - let resp = client - .get("https://github.com/appergb/openless/releases.atom") - .send() - .await - .map_err(|e| format!("fetch releases.atom: {e}"))?; + let resp = net::send_with_retry(|| { + net::http() + .get("https://github.com/appergb/openless/releases.atom") + .timeout(std::time::Duration::from_secs(15)) + }) + .await + .map_err(|e| format!("fetch releases.atom: {e}"))?; if !resp.status().is_success() { return Err(format!("releases.atom status {}", resp.status())); } @@ -445,17 +443,20 @@ pub struct AppUpdateMetadata { pub raw_json: serde_json::Value, } -/// 按 prefs.update_channel 决定 manifest 来源,再走 plugin-updater 的标准 check 流程。 +/// 决定 manifest 来源后走 plugin-updater 的标准 check 流程。 +/// 渠道:显式传入 `channel` 时用它(关于页固定查 Stable、高级页 Beta 区查 Beta); +/// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。 /// 返回 None = 当前是最新;Some(metadata) = 有新版可装。 #[tauri::command] pub async fn app_check_update_with_channel( coord: CoordinatorState<'_>, webview: tauri::Webview, timeout_ms: Option, + channel: Option, ) -> Result, String> { use tauri_plugin_updater::UpdaterExt; - let channel = coord.prefs().get().update_channel; + let channel = channel.unwrap_or_else(|| coord.prefs().get().update_channel); let mut builder = webview.updater_builder(); if let Some(ms) = timeout_ms { builder = builder.timeout(std::time::Duration::from_millis(ms)); @@ -520,27 +521,29 @@ pub struct NetworkCheckResult { #[tauri::command] pub async fn check_network() -> NetworkCheckResult { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build(); - let client = match client { - Ok(c) => c, - Err(_) => return NetworkCheckResult { online: false, latency_ms: None }, - }; + // 探一个真实存在的接口。旧逻辑探 `/health` —— 实测返回 404,链路正常也永远判 + // 离线;且用 HEAD(后端只挂 GET)。改成 GET `/packs`,拿到任意 HTTP 响应即算通。 + // + // 单发、不走 send_with_retry:这是每 30s 跑一次的状态探针,要的是「快」。10 次 + // 退避重试会让被过滤 / 黑洞的网络下探测拖到近一分钟、状态灯像卡死。偶发的瞬时 + // 误判由下一个 30s 周期自动纠正。仍用 net::http() 共享连接池。 + let url = format!("{MARKETPLACE_BASE_URL}/packs?limit=1"); let start = std::time::Instant::now(); - let endpoints = [ - "https://apic.openless.top/health", - "https://github.com", - ]; - for url in &endpoints { - if let Ok(resp) = client.head(*url).send().await { - if resp.status().is_success() || resp.status().is_redirection() { - let ms = start.elapsed().as_millis() as u64; - return NetworkCheckResult { online: true, latency_ms: Some(ms) }; - } - } + match net::http() + .get(&url) + .timeout(std::time::Duration::from_secs(8)) + .send() + .await + { + Ok(_) => NetworkCheckResult { + online: true, + latency_ms: Some(start.elapsed().as_millis() as u64), + }, + Err(_) => NetworkCheckResult { + online: false, + latency_ms: None, + }, } - NetworkCheckResult { online: false, latency_ms: None } } #[tauri::command] @@ -1054,31 +1057,41 @@ fn active_sherpa_asr_is_supported(provider: &str) -> bool { async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Result<(), String> { const MAX_ASR_VALIDATE_BODY_BYTES: usize = 1024 * 1024; + const MAX_ATTEMPTS: u32 = 6; let url = asr_transcriptions_url(&config.base_url)?; let wav = encode_wav_16k_mono_silence(250); - let wav_part = reqwest::multipart::Part::bytes(wav) - .file_name("openless-asr-check.wav") - .mime_str("audio/wav") - .map_err(|e| format!("请求体构建失败: {e}"))?; - let form = reqwest::multipart::Form::new() - .part("file", wav_part) - .text("model", model.to_string()); let client = http_client_builder(&url, 20) .build() .map_err(|_| "providerClientInitFailed".to_string())?; - let response = client - .post(&url) - .header("Authorization", format!("Bearer {}", config.api_key)) - .multipart(form) - .send() - .await - .map_err(|e| { - if e.is_timeout() { - "providerRequestTimeout".to_string() - } else { - "providerNetworkError".to_string() + // 连接 / 请求未送出类失败做指数退避重试 —— 这类失败请求尚未送达服务端,重试 + // 安全。超时不重试(服务端可能已在处理)。multipart 是流式 body,每次重建。 + let mut attempt: u32 = 0; + let response = loop { + attempt += 1; + let wav_part = reqwest::multipart::Part::bytes(wav.clone()) + .file_name("openless-asr-check.wav") + .mime_str("audio/wav") + .map_err(|e| format!("请求体构建失败: {e}"))?; + let form = reqwest::multipart::Form::new() + .part("file", wav_part) + .text("model", model.to_string()); + match client + .post(&url) + .header("Authorization", format!("Bearer {}", config.api_key)) + .multipart(form) + .send() + .await + { + Ok(resp) => break resp, + Err(e) if e.is_timeout() => return Err("providerRequestTimeout".to_string()), + Err(e) if (e.is_connect() || e.is_request()) && attempt < MAX_ATTEMPTS => { + let backoff = (200u64 * 2u64.pow((attempt - 1).min(3))).min(900); + tokio::time::sleep(std::time::Duration::from_millis(backoff)).await; + continue; } - })?; + Err(_) => return Err("providerNetworkError".to_string()), + } + }; let status = response.status(); if !status.is_success() { return Err(format!("providerHttpStatus:{}", status.as_u16())); @@ -2811,13 +2824,13 @@ pub async fn marketplace_list( if let Some(n) = limit { url.query_pairs_mut().append_pair("limit", &n.to_string()); } - let client = reqwest::Client::new(); - let resp = client - .get(url) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await - .map_err(|e| format!("marketplace request failed: {e}"))?; + let resp = net::send_with_retry(|| { + net::http() + .get(url.clone()) + .timeout(std::time::Duration::from_secs(10)) + }) + .await + .map_err(|e| format!("marketplace request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); @@ -2838,13 +2851,14 @@ pub async fn marketplace_detail( } let prefs = coord.prefs().get(); let base = marketplace_url_from_prefs(&prefs); - let client = reqwest::Client::new(); - let resp = client - .get(format!("{base}/packs/{pack_id}")) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await - .map_err(|e| format!("marketplace request failed: {e}"))?; + let url = format!("{base}/packs/{pack_id}"); + let resp = net::send_with_retry(|| { + net::http() + .get(&url) + .timeout(std::time::Duration::from_secs(10)) + }) + .await + .map_err(|e| format!("marketplace request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); return Err(format!("marketplace HTTP {status}")); @@ -2867,38 +2881,40 @@ pub async fn marketplace_install( } let prefs = coord.prefs().get(); let base = marketplace_url_from_prefs(&prefs); - let client = reqwest::Client::new(); // 先拉 detail 拿 authorLogin —— 装好后本地写 originAuthorLogin, // 后续编辑+发布时 backend 据此判 supersede(原作者)vs derivative(他人 fork)。 let detail_url = format!("{base}/packs/{pack_id}"); - let detail: serde_json::Value = client - .get(&detail_url) - .timeout(std::time::Duration::from_secs(15)) - .send() - .await - .map_err(|e| format!("marketplace detail failed: {e}"))? - .error_for_status() - .map_err(|e| format!("marketplace detail HTTP error: {e}"))? - .json() - .await - .map_err(|e| format!("parse detail failed: {e}"))?; + let detail: serde_json::Value = net::send_with_retry(|| { + net::http() + .get(&detail_url) + .timeout(std::time::Duration::from_secs(15)) + }) + .await + .map_err(|e| format!("marketplace detail failed: {e}"))? + .error_for_status() + .map_err(|e| format!("marketplace detail HTTP error: {e}"))? + .json() + .await + .map_err(|e| format!("parse detail failed: {e}"))?; let origin_author_login = detail .get("authorLogin") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let bytes = client - .get(format!("{base}/packs/{pack_id}/download")) - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - .map_err(|e| format!("marketplace download failed: {e}"))? - .error_for_status() - .map_err(|e| format!("marketplace HTTP error: {e}"))? - .bytes() - .await - .map_err(|e| format!("read body failed: {e}"))?; + let download_url = format!("{base}/packs/{pack_id}/download"); + let bytes = net::send_with_retry(|| { + net::http() + .get(&download_url) + .timeout(std::time::Duration::from_secs(30)) + }) + .await + .map_err(|e| format!("marketplace download failed: {e}"))? + .error_for_status() + .map_err(|e| format!("marketplace HTTP error: {e}"))? + .bytes() + .await + .map_err(|e| format!("read body failed: {e}"))?; // pack_id 已经过 UUID 白名单,拼临时文件路径安全。 let tmp = std::env::temp_dir().join(format!("openless-marketplace-{pack_id}.zip")); @@ -2953,7 +2969,8 @@ pub async fn marketplace_upload( let bytes = std::fs::read(&tmp).map_err(|e| format!("read exported zip: {e}"))?; let _ = std::fs::remove_file(&tmp); - let client = reqwest::Client::new(); + // multipart 上传:表单是流式 body,不走 send_with_retry 的闭包重试;改用共享 + // 客户端 —— 之前 list/detail 命令若已打开过连接,这里直接复用连接池里的连接。 let part = reqwest::multipart::Part::bytes(bytes) .file_name(format!("{pack_id}.zip")) .mime_str("application/zip") @@ -2962,7 +2979,7 @@ pub async fn marketplace_upload( if let Some(ref oid) = origin_pack_id { form = form.text("origin_pack_id", oid.clone()); } - let resp = client + let resp = net::http() .post(format!("{base}/packs")) .header("X-Dev-User", dev_user) .timeout(std::time::Duration::from_secs(30)) @@ -3013,14 +3030,15 @@ pub async fn marketplace_like( if dev_user.is_empty() { return Err("未登录:先在 Settings 填发布者名字".into()); } - let client = reqwest::Client::new(); - let resp = client - .post(format!("{base}/packs/{pack_id}/like")) - .header("X-Dev-User", dev_user) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await - .map_err(|e| format!("like request failed: {e}"))?; + let like_url = format!("{base}/packs/{pack_id}/like"); + let resp = net::send_with_retry(|| { + net::http() + .post(&like_url) + .header("X-Dev-User", dev_user.as_str()) + .timeout(std::time::Duration::from_secs(10)) + }) + .await + .map_err(|e| format!("like request failed: {e}"))?; if !resp.status().is_success() { return Err(format!("like HTTP {}", resp.status())); } @@ -3045,14 +3063,15 @@ pub async fn marketplace_delete( if dev_user.is_empty() { return Err("未登录:先在 Settings 填发布者名字".into()); } - let client = reqwest::Client::new(); - let resp = client - .delete(format!("{base}/packs/{pack_id}")) - .header("X-Dev-User", dev_user) - .timeout(std::time::Duration::from_secs(15)) - .send() - .await - .map_err(|e| format!("delete request failed: {e}"))?; + let delete_url = format!("{base}/packs/{pack_id}"); + let resp = net::send_with_retry(|| { + net::http() + .delete(&delete_url) + .header("X-Dev-User", dev_user.as_str()) + .timeout(std::time::Duration::from_secs(15)) + }) + .await + .map_err(|e| format!("delete request failed: {e}"))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); @@ -3070,14 +3089,15 @@ pub async fn marketplace_my_likes(coord: CoordinatorState<'_>) -> Result Result { let client_id = get_github_oauth_client_id()?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build() - .map_err(|e| format!("build http client: {e}"))?; - let resp = client - .post("https://github.com/login/device/code") - .header("Accept", "application/json") - .header("User-Agent", "OpenLess") - .form(&[("client_id", client_id.as_str()), ("scope", "read:user")]) - .send() - .await - .map_err(|e| format!("调用 GitHub /login/device/code 失败:{e}"))?; + let resp = net::send_with_retry(|| { + net::http() + .post("https://github.com/login/device/code") + .header("Accept", "application/json") + .header("User-Agent", "OpenLess") + .timeout(std::time::Duration::from_secs(15)) + .form(&[("client_id", client_id.as_str()), ("scope", "read:user")]) + }) + .await + .map_err(|e| format!("调用 GitHub /login/device/code 失败:{e}"))?; let status = resp.status(); let body: serde_json::Value = resp .json() @@ -3211,36 +3230,36 @@ pub async fn github_device_flow_poll( device_code: String, ) -> Result { let client_id = get_github_oauth_client_id()?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build() - .map_err(|e| format!("build http client: {e}"))?; - let token_resp = client - .post("https://github.com/login/oauth/access_token") - .header("Accept", "application/json") - .header("User-Agent", "OpenLess") - .form(&[ - ("client_id", client_id.as_str()), - ("device_code", device_code.as_str()), - ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), - ]) - .send() - .await - .map_err(|e| format!("调用 GitHub /login/oauth/access_token 失败:{e}"))?; + let token_resp = net::send_with_retry(|| { + net::http() + .post("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .header("User-Agent", "OpenLess") + .timeout(std::time::Duration::from_secs(15)) + .form(&[ + ("client_id", client_id.as_str()), + ("device_code", device_code.as_str()), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + }) + .await + .map_err(|e| format!("调用 GitHub /login/oauth/access_token 失败:{e}"))?; let body: serde_json::Value = token_resp .json() .await .map_err(|e| format!("解析 access_token 响应失败:{e}"))?; if let Some(token) = body["access_token"].as_str() { - let user_resp = client - .get("https://api.github.com/user") - .header("User-Agent", "OpenLess") - .header("Accept", "application/vnd.github+json") - .bearer_auth(token) - .send() - .await - .map_err(|e| format!("调用 GitHub /user 失败:{e}"))?; + let user_resp = net::send_with_retry(|| { + net::http() + .get("https://api.github.com/user") + .header("User-Agent", "OpenLess") + .header("Accept", "application/vnd.github+json") + .timeout(std::time::Duration::from_secs(15)) + .bearer_auth(token) + }) + .await + .map_err(|e| format!("调用 GitHub /user 失败:{e}"))?; let user_body: serde_json::Value = user_resp .json() .await diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1b2c98ec..e44cf0cc 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -24,6 +24,7 @@ mod insertion; #[cfg(target_os = "linux")] mod linux_fcitx; mod llm_gemini; +mod net; mod permissions; mod persistence; mod polish; diff --git a/openless-all/app/src-tauri/src/net.rs b/openless-all/app/src-tauri/src/net.rs new file mode 100644 index 00000000..2bd32b13 --- /dev/null +++ b/openless-all/app/src-tauri/src/net.rs @@ -0,0 +1,72 @@ +//! 共享 HTTP 客户端 + 带重试的请求发送。 +//! +//! 背景:原先每个网络命令各自 `reqwest::Client::new()`,连接池互不复用 —— 一次 +//! 成功的 TLS 连接用完即弃,下一个命令又得重新握手。在握手不稳定的网络下(代理 +//! 分流等)首次握手经常被重置,用户得反复重试才能用。 +//! +//! 这里提供两件东西: +//! - `http()`:进程级共享客户端。一次握手成功后的连接进连接池,后续命令直接复用, +//! 不再付握手成本。 +//! - `send_with_retry`:只对**连接层失败**(`is_connect()` —— 握手重置 / 连接被拒 +//! 等)做指数退避重试。这类失败发生在请求送达服务端之前、且通常是瞬时的(代理 +//! 分流抖动等),重试既幂等安全又有意义。**不重试超时与其他请求层错误**:超时 +//! 可能发生在服务端已收到之后(重试 POST / DELETE 会重复执行);`is_request()` +//! 类错误多为确定性失败(如 endpoint 配置错误),重试只是徒增数秒延迟。HTTP +//! 4xx/5xx 同样不重试 —— 服务端已应答,状态码交给调用方判断。 + +use std::time::Duration; + +use once_cell::sync::Lazy; + +static HTTP: Lazy = Lazy::new(|| { + reqwest::Client::builder() + // 握手单独限时:卡在握手上要尽快失败,好让 send_with_retry 立即重试。 + .connect_timeout(Duration::from_secs(8)) + // 连接池:一条握手成功的连接保留 90s 供后续命令复用。 + .pool_idle_timeout(Duration::from_secs(90)) + .pool_max_idle_per_host(8) + .tcp_keepalive(Duration::from_secs(30)) + .user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION"))) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +}); + +/// 进程级共享 HTTP 客户端。带连接池 —— 一次握手成功后的连接被后续请求复用。 +pub fn http() -> &'static reqwest::Client { + &HTTP +} + +/// 单次请求最多尝试的次数。失败本身很快(握手重置 ~0.5s),10 次总耗时仍可控。 +const MAX_ATTEMPTS: u32 = 10; + +/// 发送请求,只对连接层失败(`is_connect()`:握手重置 / 连接被拒等)做指数退避重试。 +/// +/// `make` 每次尝试都重新构造 `RequestBuilder`(`send()` 会消耗它)。只重试 +/// `is_connect()` —— 连接尚未建立、请求未送达服务端,且这类失败通常是瞬时的, +/// 重试幂等安全且有价值。超时(可能服务端已在处理)与其他 `is_request()` 类错误 +/// (多为 endpoint 配置错误等确定性失败)都不重试。拿到任意 HTTP 响应(含 +/// 4xx/5xx)即返回,状态码由调用方自行判断。 +pub async fn send_with_retry(make: F) -> reqwest::Result +where + F: Fn() -> reqwest::RequestBuilder, +{ + let mut attempt: u32 = 0; + loop { + attempt += 1; + match make().send().await { + Ok(resp) => return Ok(resp), + Err(err) => { + let retryable = err.is_connect(); + if !retryable || attempt >= MAX_ATTEMPTS { + return Err(err); + } + // 150 / 300 / 600 / 900 / 900 … ms 退避。 + let backoff = (150u64 * 2u64.pow((attempt - 1).min(3))).min(900); + log::warn!( + "[net] transient failure (attempt {attempt}/{MAX_ATTEMPTS}), retry in {backoff}ms: {err}" + ); + tokio::time::sleep(Duration::from_millis(backoff)).await; + } + } + } +} diff --git a/openless-all/app/src-tauri/src/polish.rs b/openless-all/app/src-tauri/src/polish.rs index ac29cd60..4374d17e 100644 --- a/openless-all/app/src-tauri/src/polish.rs +++ b/openless-all/app/src-tauri/src/polish.rs @@ -2782,7 +2782,8 @@ mod tests { assert!(prompt.contains("# 三、双层格式")); assert!(prompt.contains("第一层(主题)")); assert!(prompt.contains("第二层(子项)")); - assert!(prompt.contains("事项 ≤ 2 条")); + assert!(prompt.contains("事项仅 1 条")); + assert!(prompt.contains("事项 = 2 条")); assert!(prompt.contains("事项 ≥ 3 条")); // 防回归:模型名、字段名、布尔值和版本号必须被显式保护。 @@ -2808,14 +2809,14 @@ mod tests { fn structured_prompt_keeps_regrouping_and_no_loss_guards() { let prompt = prompts::system_prompt(PolishMode::Structured); - // v1.3.0 回归的关键规则:已编号 ≠ 不用改、≥3 必须重组、≤2 不硬塞层级。 + // v1.3.0 回归的关键规则:已编号 ≠ 不用改、≥3 必须重组、仅 1 条事项输出连贯段落。 assert!( prompt.contains("照抄原结构 = 失败"), "Structured prompt 必须把照抄原结构判为失败" ); assert!( - prompt.contains("不硬塞层级"), - "Structured prompt 必须避免短输入过度结构化" + prompt.contains("输出连贯段落"), + "Structured prompt 必须避免短输入过度结构化(仅 1 条事项 → 连贯段落)" ); assert!( prompt.contains("不丢失任何一件事"), diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 3cd5c3c5..fe7137e6 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1107,11 +1107,14 @@ const STRUCTURED_BUILTIN_PROMPT: &str = r#"# 角色 按可识别的事项数决定输出形态: -- **事项 ≤ 2 条** → 输出连贯段落,不硬塞层级。 +- **事项仅 1 条** → 输出连贯段落。 +- **事项 = 2 条** → **必须**用 1./2. 编号平列输出,每条一句完整陈述。不强制分主题子项,但仍需整理表达。 - **事项 ≥ 3 条** → **必须**按语义归类为 2–4 个主题,使用下文双层格式。**照抄原结构 = 失败。** 即使原文已经写成「1. 做 X 2. 做 Y 3. 做 Z」,也要按主题重新归类,把同主题事项收到同一组下做 (a)(b) 子项。 +**重要:只要存在 2 条及以上可区分事项,就必须编号。不编号 = 失败。** + 常见主题组合(按内容自动选取): - 工程类:「代码与功能 / 文档与配置 / 界面与交互 / 项目清理」「后端 / 前端 / 部署 / 提示词」 diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 08ce2665..c2b5402b 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -3,7 +3,7 @@ import { AutoUpdateGate } from './components/AutoUpdateGate'; import { Capsule } from './components/Capsule'; import { FloatingShell } from './components/FloatingShell'; import { Onboarding } from './components/Onboarding'; -import { detectOS } from './components/WindowChrome'; +import { detectOS, type OS } from './components/WindowChrome'; import { checkAccessibilityPermission, checkMicrophonePermission, @@ -22,11 +22,12 @@ import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; interface AppProps { isCapsule: boolean; isQa: boolean; + forcedOs?: OS | null; } type Gate = 'checking' | 'onboarding' | 'ready'; -export function App({ isCapsule, isQa }: AppProps) { +export function App({ isCapsule, isQa, forcedOs }: AppProps) { if (isCapsule) { return ; } @@ -34,7 +35,7 @@ export function App({ isCapsule, isQa }: AppProps) { return ; } - const os = detectOS(); + const os = forcedOs ?? detectOS(); // Windows 启动不应被权限探测阻塞首屏。 const [gate, setGate] = useState(isTauri ? 'checking' : 'ready'); @@ -173,7 +174,7 @@ export function App({ isCapsule, isQa }: AppProps) { } return ( - {gate === 'onboarding' ? setGate('ready')} /> : } + {gate === 'onboarding' ? setGate('ready')} /> : } {gate === 'ready' && } ); diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index 2a38be7b..c6a5f096 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -12,7 +12,7 @@ import { invoke } from '@tauri-apps/api/core'; import type { DownloadEvent } from '@tauri-apps/plugin-updater'; import { Update } from '@tauri-apps/plugin-updater'; import { useTranslation } from 'react-i18next'; -import { isTauri, restartApp } from '../lib/ipc'; +import { isTauri, restartApp, type UpdateChannel } from '../lib/ipc'; import { Btn } from '../pages/_atoms'; const UPDATE_CHECK_TIMEOUT_MS = 15_000; @@ -45,8 +45,9 @@ export interface UseAutoUpdate { checking: boolean; busy: boolean; errorMessage: string | null; - /** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 */ - checkForUpdates: () => Promise; + /** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 + * `channel` 显式指定查哪个渠道;省略时由 Rust 端回落到 prefs.update_channel。 */ + checkForUpdates: (channel?: UpdateChannel) => Promise; /** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */ installUpdate: () => Promise; /** 关闭对话框(仅在非 busy 状态可用)。 */ @@ -88,7 +89,7 @@ export function useAutoUpdate(): UseAutoUpdate { setContentLength(null); }; - const checkForUpdates = async () => { + const checkForUpdates = async (channel?: UpdateChannel) => { setStatus('checking'); setVersion(''); setErrorMessage(null); @@ -103,6 +104,7 @@ export function useAutoUpdate(): UseAutoUpdate { // Beta → fetch_latest_beta_release 拼出 -beta manifest URL 后再 check。 const metadata = await invoke('app_check_update_with_channel', { timeoutMs: UPDATE_CHECK_TIMEOUT_MS, + channel: channel ?? null, }); if (!metadata) { setStatus('none'); diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 7abce175..532b91c0 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -30,7 +30,7 @@ import { PROVIDER_SETUP_PROMPT_DEFERRED_KEY, shouldShowProviderSetupPrompt, } from '../lib/providerSetup'; -import { type SettingsSectionId } from '../pages/Settings'; +import { type SettingsSectionId } from './SettingsModal'; import { useAppState, type AppTab } from '../state/useAppState'; interface NavItem { @@ -170,13 +170,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const openProviderSettings = () => { rememberProviderPrompt(); - openSettings('providers'); + openSettings('services'); }; const openHotkeyRecordingSettings = () => { window.localStorage.setItem(HOTKEY_MODE_MIGRATION_ACK_KEY, '1'); setHotkeyModePromptOpen(false); - openSettings('recording'); + openSettings('general'); }; return ( diff --git a/openless-all/app/src/components/GithubLoginModal.tsx b/openless-all/app/src/components/GithubLoginModal.tsx new file mode 100644 index 00000000..8dc4c5c3 --- /dev/null +++ b/openless-all/app/src/components/GithubLoginModal.tsx @@ -0,0 +1,218 @@ +// GitHub 登录弹窗 —— 风格市场与扩展市场共用同一套登录界面。 +// GitHub OAuth Device Flow:打开即 start → 展示 user code 等浏览器授权 → +// 轮询直到 authorized。各阶段内容套同一 minHeight 容器,窗口尺寸恒定, +// 不再出现「先弹小窗、过会儿变大窗」的跳动。 + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + githubDeviceFlowPoll, + githubDeviceFlowStart, + openExternal, +} from '../lib/ipc'; +import { Btn } from '../pages/_atoms'; +import { Modal } from './ui/Modal'; + +type Phase = + | { kind: 'starting' } + | { kind: 'pending'; userCode: string; verificationUri: string; deviceCode: string } + | { kind: 'success'; login: string } + | { kind: 'error'; message: string }; + +interface GithubLoginModalProps { + onClose: () => void; + /** 授权成功回调(拿到 GitHub login)。 */ + onSuccess: (login: string) => void; +} + +export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) { + const { t } = useTranslation(); + const [phase, setPhase] = useState({ kind: 'starting' }); + const [copied, setCopied] = useState(false); + const cancelledRef = useRef(false); + // 用 ref 持有回调,poll 副作用只依赖 phase,不因父组件重渲染而重启。 + const onSuccessRef = useRef(onSuccess); + const onCloseRef = useRef(onClose); + useEffect(() => { + onSuccessRef.current = onSuccess; + onCloseRef.current = onClose; + }); + + const begin = useCallback(async () => { + cancelledRef.current = false; + setPhase({ kind: 'starting' }); + try { + const start = await githubDeviceFlowStart(); + if (cancelledRef.current) return; + setPhase({ + kind: 'pending', + userCode: start.userCode, + verificationUri: start.verificationUri, + deviceCode: start.deviceCode, + }); + // 自动拉起浏览器;失败不致命,用户可手动复制。 + try { await openExternal(start.verificationUri); } catch { /* manual fallback */ } + } catch (err) { + if (cancelledRef.current) return; + setPhase({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + }, []); + + // 打开即发起登录。 + useEffect(() => { + void begin(); + return () => { cancelledRef.current = true; }; + }, [begin]); + + // pending 阶段轮询 backend。 + useEffect(() => { + if (phase.kind !== 'pending') return; + let cancelled = false; + let timer: number | null = null; + let interval = 5_000; + const deviceCode = phase.deviceCode; + const tick = async () => { + if (cancelled) return; + try { + const res = await githubDeviceFlowPoll(deviceCode); + if (cancelled) return; + if (res.kind === 'authorized') { + setPhase({ kind: 'success', login: res.login }); + onSuccessRef.current(res.login); + window.setTimeout(() => { if (!cancelled) onCloseRef.current(); }, 1200); + } else if (res.kind === 'slowDown') { + interval = Math.min(interval + 5_000, 30_000); + timer = window.setTimeout(tick, interval); + } else if (res.kind === 'pending') { + timer = window.setTimeout(tick, interval); + } else { + setPhase({ kind: 'error', message: res.message }); + } + } catch (err) { + if (cancelled) return; + setPhase({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + }; + timer = window.setTimeout(tick, interval); + return () => { + cancelled = true; + if (timer != null) window.clearTimeout(timer); + }; + }, [phase]); + + const close = () => { + cancelledRef.current = true; + onClose(); + }; + + const copyCode = async () => { + if (phase.kind !== 'pending') return; + try { + await navigator.clipboard.writeText(phase.userCode); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { /* clipboard unavailable */ } + }; + + return ( + +
+

{t('marketplace.oauth.title')}

+ +
+ + {/* 固定最小高度 —— 各阶段共用,窗口尺寸恒定。 */} +
+ {phase.kind === 'starting' && ( +
+ {t('marketplace.oauth.generating')} +
+ )} + + {phase.kind === 'pending' && ( +
+
+ {t('marketplace.oauth.browserHint', { uri: phase.verificationUri })} +
+
+ {phase.userCode} + void copyCode()}> + {copied ? t('marketplace.oauth.copied') : t('marketplace.oauth.copyBtn')} + +
+
+ void openExternal(phase.verificationUri)}> + {t('marketplace.oauth.openBrowserBtn')} + + + {t('marketplace.oauth.cancelBtn')} + +
+
+ + {t('marketplace.oauth.waiting')} +
+ +
+ )} + + {phase.kind === 'success' && ( +
+
+
+
+ {t('marketplace.oauth.successAs', { login: phase.login })} +
+
+
+ )} + + {phase.kind === 'error' && ( +
+
+ {phase.message} +
+
+ {t('marketplace.oauth.closeBtn')} + void begin()}>{t('marketplace.oauth.retryBtn')} +
+
+ )} +
+
+ ); +} diff --git a/openless-all/app/src/components/Icon.tsx b/openless-all/app/src/components/Icon.tsx index 9e238bb2..cb0705f4 100644 --- a/openless-all/app/src/components/Icon.tsx +++ b/openless-all/app/src/components/Icon.tsx @@ -48,6 +48,7 @@ export const ICONS: Record = { user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', // User mail: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zM22 6l-10 7L2 6', // Mail info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 16v-4M12 8h.01', // Info + shield: 'M12 22s8-3 8-9V5l-8-3-8 3v8c0 6 8 9 8 9z', // Shield (privacy) external: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3', // External link close: 'M18 6L6 18M6 6l12 12', // Close / X play: 'M5 3l14 9-14 9V3z', // Play diff --git a/openless-all/app/src/components/MarketplaceModal.tsx b/openless-all/app/src/components/MarketplaceModal.tsx index 506d1783..e977dc7b 100644 --- a/openless-all/app/src/components/MarketplaceModal.tsx +++ b/openless-all/app/src/components/MarketplaceModal.tsx @@ -41,7 +41,7 @@ export function MarketplaceModal({ onClose }: MarketplaceModalProps) { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 28, zIndex: 50, - animation: 'ol-modal-fade .2s var(--ol-motion-soft)', + animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)', }} >
b。按 SemVer 规则:major→minor→patch→prerelease(none > some)。 -function semverGreater(a: string, b: string): boolean { - const parse = (v: string) => { - const [main, pre] = v.split('-'); - const [major, minor, patch] = main.split('.').map(n => Number.parseInt(n, 10)); - const preNum = pre === undefined ? null : Number.parseInt(pre, 10); - return { major, minor, patch, preNum }; - }; - const A = parse(a); - const B = parse(b); - if (A.major !== B.major) return A.major > B.major; - if (A.minor !== B.minor) return A.minor > B.minor; - if (A.patch !== B.patch) return A.patch > B.patch; - // pre: null(无 prerelease)> 任何 prerelease - if (A.preNum === B.preNum) return false; - if (A.preNum === null) return true; - if (B.preNum === null) return false; - return A.preNum > B.preNum; -} +// 稳定 tab ID(与 i18n key `modal.sections.*` 一致)。 +export type SettingsSectionId = + | 'general' + | 'services' + | 'privacy' + | 'advanced' + | 'about'; interface SettingsModalProps { os: OS; @@ -59,9 +31,6 @@ interface SettingsModalProps { initialSettingsSection?: SettingsSectionId; } -// 稳定 ID(与 i18n key 一致,方便 modal.sections.* 渲染)。 -type ModalSectionId = 'settings' | 'personalize' | 'about'; - interface ModalNavItem { id: string; icon: string; @@ -69,39 +38,33 @@ interface ModalNavItem { href?: string; } -interface ModalGroup { - items: ModalNavItem[]; -} - const HELP_URL = 'https://github.com/appergb/openless#readme'; const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases'; +// 第一组:可选中的 tab;第二组:外部链接(永远不 active)。 +const TAB_ITEMS: ModalNavItem[] = [ + { id: 'general', icon: 'settings' }, + { id: 'services', icon: 'cloud' }, + { id: 'privacy', icon: 'shield' }, + { id: 'advanced', icon: 'bolt' }, + { id: 'about', icon: 'info' }, +]; +const LINK_ITEMS: ModalNavItem[] = [ + { id: 'helpCenter', icon: 'help', external: true, href: HELP_URL }, + { id: 'releaseNotes', icon: 'doc', external: true, href: RELEASE_NOTES_URL }, +]; + export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) { const { t } = useTranslation(); - const [section, setSection] = useState('settings'); + const [section, setSection] = useState(initialSettingsSection ?? 'general'); const savedToast = useSavedToastListener(); - const groups: ModalGroup[] = [ - { - items: [ - { id: 'settings', icon: 'settings' }, - { id: 'personalize', icon: 'sparkle' }, - { id: 'about', icon: 'info' }, - ], - }, - { - items: [ - { id: 'helpCenter', icon: 'help', external: true, href: HELP_URL }, - { id: 'releaseNotes', icon: 'doc', external: true, href: RELEASE_NOTES_URL }, - ], - }, - ]; - // 与 sidebar nav 一致的滑动指示器:仅第一组(可选中)有 pill;外链组永远不 active 不画 pill。 - const firstGroupRefs = useRef>([]); + // 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill。 + const tabRefs = useRef>([]); const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null); useLayoutEffect(() => { - const idx = groups[0].items.findIndex(it => it.id === section); - const el = firstGroupRefs.current[idx]; + const idx = TAB_ITEMS.findIndex(it => it.id === section); + const el = tabRefs.current[idx]; if (!el) return; setPillRect({ top: el.offsetTop, height: el.offsetHeight }); }, [section]); @@ -117,7 +80,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 28, zIndex: 50, - animation: 'ol-modal-fade .2s var(--ol-motion-soft)', + animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)', }}>
- {/* sub-sidebar */} + {/* ─── 单层侧栏 ────────────────────────────────────────────── */} - {/* content — 父容器 overflow:hidden + 列向 flex;X 和 h2 固定在头部, - 只有最里层的 scroll wrapper 真正滚动。这样模态左 sidebar、关闭按钮、 - section 标题都不会跟着内容一起飘。 */} + {/* ─── 内容区 ────────────────────────────────────────────── + 父容器 overflow:hidden + 列向 flex;关闭按钮、section 标题固定在头部, + 只有最里层的 scroll wrapper 真正滚动。 */}
- {/* "已保存"toast 在内容区右上角;right:54 避开 28×28 关闭按钮 + 12px gap。 - 弹窗内用 absolute 锚内容区、从上方滑入 / 滑回 —— 外层 overflow:hidden - 会把它裁在面板顶边,读感即"从屏幕外上方来、回上方去"。 - CredentialField 等通过 emitSaved 发事件,useSavedToastListener 接收。 */} + {/* "已保存" toast:right:54 避开 28×28 关闭按钮 + 12px gap。 */} (e.currentTarget.style.background = 'rgba(0,0,0,0.05)')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} title={t('common.close')}> - -

{t(`modal.sections.${section}`)}

- - {section === 'settings' ? ( - // SettingsContent 自己接管 flex:1 + 内部右栏 scroll,外层不能再加 overflow:auto。 -
- +

+ {t(`modal.sections.${section}`)} +

+ +
+ {/* key=section 让切 tab 时整块重挂载,ol-tab-fade 轻微淡入。 */} +
+ {section === 'general' && } + {section === 'services' && } + {section === 'privacy' && } + {section === 'advanced' && } + {section === 'about' && }
- ) : ( - // personalize / about 短内容:单一 scroll wrapper,超出时本块滚动。 -
- {section === 'personalize' && } - {section === 'about' && } -
- )} +
- -
); } -function PersonalizeSection() { - const { t } = useTranslation(); - // 玻璃强度持久化到 localStorage,并实时写入 CSS var --ol-glass-blur。 - // 这是 CSS-only 的层(影响 backdrop-filter 的内层强度);macOS NSVisualEffectView - // 是另一层,由 Tauri 在窗口创建时一次性配置,运行时改动需要重启 App。 - const [blur, setBlur] = useState(() => { - const saved = window.localStorage.getItem('ol.glassBlur'); - return saved ? Number(saved) : 22; - }); - - useEffect(() => { - document.documentElement.style.setProperty('--ol-glass-blur', `${blur}px`); - window.localStorage.setItem('ol.glassBlur', String(blur)); - }, [blur]); - - const [fontScale, setFontScaleState] = useState(() => readFontScale()); - const applyFontScaleChoice = (next: FontScaleId) => { - setFontScaleState(next); - setFontScale(next); - }; - const fontOptions: Array<[FontScaleId, string]> = [ - ['small', t('modal.personalize.fontSmall')], - ['medium', t('modal.personalize.fontMedium')], - ['large', t('modal.personalize.fontLarge')], - ]; - - return ( -
- -
- {fontOptions.map(([id, label]) => { - const selected = fontScale === id; - return ( - - ); - })} -
-
- -
- setBlur(Number(e.target.value))} - style={{ width: 200, accentColor: 'var(--ol-blue)' }} - /> - - {blur}px - -
-
-
- ); -} - -function AboutMini() { - const { t } = useTranslation(); - const [qqCopied, setQqCopied] = useState(false); - const qqCopiedRef = useRef(null); - const [exportStatus, setExportStatus] = useState<'idle' | 'busy' | 'ok' | 'err'>('idle'); - const [exportMessage, setExportMessage] = useState(''); - - useEffect(() => () => { - if (qqCopiedRef.current) clearTimeout(qqCopiedRef.current); - }, []); - - const copyQq = () => { - navigator.clipboard?.writeText('1078960553'); - setQqCopied(true); - if (qqCopiedRef.current) clearTimeout(qqCopiedRef.current); - qqCopiedRef.current = window.setTimeout(() => setQqCopied(false), 1500); - }; - - const onExportLog = async () => { - setExportStatus('busy'); - setExportMessage(''); - try { - const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const target = await exportErrorLog(`openless-${ts}.log`); - if (target == null) { - setExportStatus('idle'); - return; - } - setExportStatus('ok'); - setExportMessage(target); - window.setTimeout(() => setExportStatus('idle'), 4000); - } catch (err) { - setExportStatus('err'); - setExportMessage(err instanceof Error ? err.message : String(err)); - } - }; - - return ( -
-
- -
-
OpenLess
- -
-
- - - - - - - - - - -
- 1078960553 - - {qqCopied && {t('common.copied')}} -
-
- -
- - {exportStatus === 'ok' && ( - - {t('modal.about.exportSuccess')} - - )} - {exportStatus === 'err' && ( - - {t('modal.about.exportFailed')} - - )} -
-
- - {t('modal.about.localFirst')} - - -
- ); -} - -// Beta 渠道开关:物理隔离的 opt-in,**已接 auto-update**(PR feat/beta-auto-update)。 -// - 关闭 = Stable 渠道,「检查更新」走 tauri.conf 默认 endpoints -// - 打开 = 写 prefs.update_channel = 'beta';Rust 端 app_check_update_with_channel -// 命令会自动拉最新 prerelease tag 拼成 -beta manifest URL,再走 plugin-updater -// 的 check/download/install 标准流程 -// - 这里拉一次 fetch_latest_beta_release 仅用于「告诉用户最新 Beta 是哪个版本」做 -// 信息透明;不再渲染手动下载按钮(auto-update 接管了那条路)。 -// 不在 Beta 渠道时不发起 GitHub API 请求,避免空切换浪费配额。 -function BetaChannelControl() { - const { t } = useTranslation(); - const [channel, setChannel] = useState('stable'); - const [latest, setLatest] = useState(null); - const [status, setStatus] = useState<'idle' | 'fetching' | 'empty' | 'error'>('idle'); - const [errorMessage, setErrorMessage] = useState(''); - const updater = useAutoUpdate(); - - // 本机 vs 最新 Beta tag 比对: - // - 'unknown' → 还没拿到 latest 或解析失败,按"未知"渲染 - // - 'up-to-date' → 本机版本 >= 远端最新 Beta,按"已是最新"渲染、隐藏更新按钮 - // - 'newer' → 远端有更新版本,渲染「立即更新」按钮触发 auto-update 流程 - const remoteVersion = latest ? parseVersionFromBetaTag(latest.tagName) : null; - const comparison: 'unknown' | 'up-to-date' | 'newer' = !remoteVersion - ? 'unknown' - : semverGreater(remoteVersion, APP_VERSION) - ? 'newer' - : 'up-to-date'; - - useEffect(() => { - let cancelled = false; - void getUpdateChannel() - .then(c => { if (!cancelled) setChannel(c); }) - .catch(() => { /* fall back to stable already in initial state */ }); - return () => { cancelled = true; }; - }, []); - - const fetchBeta = async () => { - setStatus('fetching'); - setErrorMessage(''); - try { - const info = await fetchLatestBetaRelease(); - if (info == null) { - setLatest(null); - setStatus('empty'); - } else { - setLatest(info); - setStatus('idle'); - } - } catch (err) { - setStatus('error'); - setErrorMessage(err instanceof Error ? err.message : String(err)); - } - }; - - const onToggle = async (next: boolean) => { - const target: UpdateChannel = next ? 'beta' : 'stable'; - setChannel(target); - try { - await setUpdateChannel(target); - } catch (err) { - setStatus('error'); - setErrorMessage(err instanceof Error ? err.message : String(err)); - // 写入失败时回滚 UI,免得用户以为切成功了。 - setChannel(target === 'beta' ? 'stable' : 'beta'); - return; - } - if (target === 'beta') { - void fetchBeta(); - } else { - setLatest(null); - setStatus('idle'); - setErrorMessage(''); - } - }; - - return ( - <> - - - - {channel === 'beta' && ( -
- {status === 'fetching' && {t('settings.about.betaChannelFetching')}} - {status === 'empty' && {t('settings.about.betaChannelNoBeta')}} - {status === 'error' && ( - - {t('settings.about.betaChannelFetchError')} - - )} - {status === 'idle' && latest && ( -
- - {t('settings.about.betaChannelLatestPrefix')} {latest.tagName} - - {comparison === 'up-to-date' && ( - {t('settings.about.betaChannelUpToDate')} - )} - {comparison === 'newer' && ( - - )} - -
- )} - {status === 'idle' && !latest && ( - - )} -
- )} - {isDialogStatus(updater.status) && ( - void updater.installUpdate()} - onClose={() => void updater.dismissDialog()} - /> - )} - - ); -} - -const btnGhost: CSSProperties = { - padding: '5px 10px', fontSize: 12, borderRadius: 6, - border: '0.5px solid var(--ol-line-strong)', - background: '#fff', color: 'var(--ol-ink-2)', - cursor: 'default', fontFamily: 'inherit', - transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)', +const navBtnStyle = { + display: 'flex', alignItems: 'center', gap: 10, + padding: '7px 10px', + borderRadius: 8, border: 0, + background: 'transparent', + fontFamily: 'inherit', fontSize: 13, + cursor: 'default', textAlign: 'left' as const, + position: 'relative' as const, + zIndex: 1, + transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)', }; - diff --git a/openless-all/app/src/components/ui/Modal.tsx b/openless-all/app/src/components/ui/Modal.tsx new file mode 100644 index 00000000..e84d376a --- /dev/null +++ b/openless-all/app/src/components/ui/Modal.tsx @@ -0,0 +1,51 @@ +// Modal — 居中弹窗:backdrop + 卡片。风格市场详情 / 上传 / 我的发布 / GitHub 登录 +// 等共用同一套弹出逻辑,避免每处各写一个。 +// +// 动画沿用 global.css 的 ol-modal-backdrop-in / ol-modal-card-in(纯 opacity + +// transform,不碰 blur),与设置弹窗、各市场弹窗保持一致。 + +import type { ReactNode } from 'react'; + +interface ModalProps { + children: ReactNode; + onClose: () => void; + /** 默认 50;多层叠加时(如登录弹窗叠在「我的发布」之上)传更大的值。 */ + zIndex?: number; + /** 卡片宽度,默认 'min(560px, 100%)'。 */ + width?: string; +} + +export function Modal({ children, onClose, zIndex = 50, width = 'min(560px, 100%)' }: ModalProps) { + return ( +
+
e.stopPropagation()} + style={{ + width, + maxHeight: '85vh', + overflow: 'auto', + borderRadius: 16, + background: 'var(--ol-surface)', + border: '0.5px solid var(--ol-line-strong)', + boxShadow: '0 18px 42px rgba(0,0,0,0.18)', + padding: 22, + animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)', + }} + > + {children} +
+
+ ); +} diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index eb1ef684..4822e50a 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -25,6 +25,7 @@ import { useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; import { Icon } from '../Icon'; @@ -33,6 +34,8 @@ export interface SelectOption { value: string; label: string; disabled?: boolean; + /** 可选:渲染在选项标签右侧、勾选标记左侧(如麦克风音量条)。 */ + trailing?: ReactNode; } interface SelectLiteProps { @@ -43,6 +46,8 @@ interface SelectLiteProps { disabled?: boolean; style?: CSSProperties; ariaLabel?: string; + /** 下拉打开 / 关闭时回调 —— 让调用方按开合状态启停副作用(如电平监听)。 */ + onOpenChange?: (open: boolean) => void; } const DEFAULT_TRIGGER_STYLE: CSSProperties = { @@ -74,6 +79,7 @@ export function SelectLite({ disabled = false, style, ariaLabel, + onOpenChange, }: SelectLiteProps) { const [open, setOpen] = useState(false); // leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount) @@ -194,10 +200,12 @@ export function SelectLite({ setHighlight(initial >= 0 ? initial : options.findIndex(opt => !opt.disabled)); setLeaving(false); setOpen(true); + onOpenChange?.(true); }; const closeMenu = () => { if (!open) return; + onOpenChange?.(false); setLeaving(true); window.setTimeout(() => { setOpen(false); @@ -356,6 +364,7 @@ export function SelectLite({ {option.label} + {option.trailing} {isSelected && }
); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 70cad95b..fa189d50 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -495,17 +495,31 @@ export const en: typeof zhCN = { kicker: 'SETTINGS', title: 'Settings', desc: 'Recording, providers, shortcuts, and permissions.', - sections: { - recording: 'Recording', - providers: 'Providers', - shortcuts: 'Shortcuts', - permissions: 'Permissions', - language: 'Language', - advanced: 'Advanced', - about: 'About', + dataStorage: { + title: 'Data storage', + desc: 'Conversation history and context kept on this device.', + }, + debug: { + title: 'Debug tools', + desc: 'For troubleshooting recognition issues; off by default.', + }, + marketplace: { + title: 'Marketplace', + desc: 'Upload identity for the style marketplace. Browse and install styles on the Styles page.', + github: { + signIn: 'Sign in with GitHub', + signedIn: 'Signed in with GitHub', + signedOut: 'Sign in to upload styles and like packs.', + signOut: 'Sign out', + starting: 'Starting sign-in…', + codeHint: 'Enter this code on the GitHub page that just opened:', + openGithub: 'Open GitHub', + waiting: 'GitHub opened — you’ll be signed in once you authorize…', + failed: 'Sign-in failed, please retry', + }, }, recording: { - title: 'Recording', + title: 'Recording & input', desc: 'Global recording hotkey and trigger mode.', hotkeyLabel: 'Recording hotkey', hotkeyDescAcc: 'Press to capture voice globally (requires Accessibility permission).', @@ -647,6 +661,7 @@ export const en: typeof zhCN = { selectModel: 'Select a model to fill the field above', modelSaved: 'Saved model {{model}}.', validateSuccess: 'Connection check passed.', + validateFailed: 'Connection check failed.', providerHttpStatus: 'Provider returned HTTP {{status}}. Check the API key permissions or endpoint.', endpointMustUseHttps: 'Endpoint must use HTTPS (localhost/127.0.0.1 are allowed for local testing).', endpointInvalid: 'Endpoint format is invalid.', @@ -767,8 +782,9 @@ export const en: typeof zhCN = { privacy: 'Privacy', privacyDesc: 'All data stays on this device. Cloud APIs do not retain recordings.', localFirst: 'Local-first', + linksTitle: 'Documentation', betaChannelLabel: 'Join Beta channel', - betaChannelDesc: 'Receive Beta updates when enabled. May be unstable — recommended only for early adopters.', + betaChannelDesc: 'Early access to new features', betaChannelFetching: 'Fetching the latest Beta…', betaChannelFetchBtn: 'Look up latest Beta', betaChannelLatestPrefix: 'Latest Beta:', @@ -809,25 +825,16 @@ export const en: typeof zhCN = { }, modal: { sections: { - account: 'Account', - settings: 'Settings', + general: 'General', + services: 'Services', + privacy: 'Privacy', + advanced: 'Advanced', personalize: 'Personalize', about: 'About', helpCenter: 'Help center', releaseNotes: 'Release notes', }, - account: { - localUser: 'Local user', - localUserDesc: 'Not signed in · all data stays local', - loginSync: 'Sign in / Sync', - footer: 'Runs fully locally by default. Sign in to sync vocabulary and style presets across devices.', - }, personalize: { - appearance: 'Appearance', - appearanceDesc: 'Follow system / Light / Dark', - appearanceSystem: 'Follow system', - appearanceLight: 'Light', - appearanceDark: 'Dark', font: 'Font size', fontDesc: 'Scale the entire UI font size — applies instantly.', fontSmall: 'Small', @@ -835,10 +842,6 @@ export const en: typeof zhCN = { fontLarge: 'Large', blur: 'Glass blur intensity', blurDesc: 'Affects the inner backdrop-filter strength (the macOS system frosted layer can not be tuned at runtime).', - startupOpen: 'On launch', - startupOverview: 'Overview', - startupLast: 'Last position', - startupAtBoot: 'Launch at login', }, about: { tagline: 'Speak naturally, write perfectly', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 2d357eb1..fde07e6e 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -497,17 +497,31 @@ export const ja: typeof zhCN = { kicker: 'SETTINGS', title: '設定', desc: '録音、プロバイダー、ショートカット、権限の設定。', - sections: { - recording: '録音', - providers: 'プロバイダー', - shortcuts: 'ショートカット', - permissions: '権限', - language: '言語', - advanced: '詳細設定', - about: '情報', + dataStorage: { + title: 'データ保存', + desc: 'この端末に保存される会話履歴とコンテキスト。', + }, + debug: { + title: 'デバッグツール', + desc: '認識の問題を調査するときに使用。通常はオフのままで構いません。', + }, + marketplace: { + title: '拡張マーケット', + desc: 'スタイルマーケットの投稿者 ID。スタイルの閲覧とインストールは「スタイル」ページで行います。', + github: { + signIn: 'GitHub でログイン', + signedIn: 'GitHub でログイン済み', + signedOut: 'ログインするとスタイルの投稿・いいねができます。', + signOut: 'ログアウト', + starting: 'ログインを開始しています…', + codeHint: '開いた GitHub ページでこのコードを入力してください:', + openGithub: 'GitHub を開く', + waiting: 'GitHub を開きました。承認するとログインします…', + failed: 'ログインに失敗しました。再試行してください', + }, }, recording: { - title: '録音', + title: '録音と入力', desc: 'グローバル録音のショートカットとトリガー方式を定義します。', hotkeyLabel: '録音ショートカット', hotkeyDescAcc: '押すと音声キャプチャを開始(グローバル)。アクセシビリティ権限が必要です。', @@ -649,6 +663,7 @@ export const ja: typeof zhCN = { selectModel: 'モデルを選んで上記欄に入力', modelSaved: 'モデル {{model}} を保存しました。', validateSuccess: '接続チェックに合格しました。', + validateFailed: '接続チェックに失敗しました。', providerHttpStatus: 'サプライヤーが {{status}} を返しました。API Key 権限またはエンドポイントを確認してください。', endpointMustUseHttps: 'Endpoint は HTTPS を使用する必要があります(localhost/127.0.0.1 を除く)。', endpointInvalid: 'Endpoint の形式が無効です。', @@ -769,8 +784,9 @@ export const ja: typeof zhCN = { privacy: 'プライバシー', privacyDesc: 'すべてのデータはローカルに保存。クラウド API は録音を保持しません。', localFirst: 'ローカル優先', + linksTitle: 'ドキュメント', betaChannelLabel: 'Beta チャンネルに参加', - betaChannelDesc: '有効にすると Beta 更新を受信。不安定な場合があります。', + betaChannelDesc: '新機能をいち早く体験', betaChannelFetching: '最新 Beta 版を取得中…', betaChannelFetchBtn: '最新 Beta を確認', betaChannelLatestPrefix: '最新 Beta:', @@ -811,25 +827,16 @@ export const ja: typeof zhCN = { }, modal: { sections: { - account: 'アカウント', - settings: '設定', + general: '一般', + services: 'サービス', + privacy: 'プライバシー', + advanced: '詳細設定', personalize: 'パーソナライズ', about: '情報', helpCenter: 'ヘルプセンター', releaseNotes: 'リリースノート', }, - account: { - localUser: 'ローカルユーザー', - localUserDesc: '未ログイン · すべてのデータはローカルに保存', - loginSync: 'ログイン / 同期', - footer: 'デフォルトで完全ローカル動作。ログインすると語彙とスタイルプリセットをデバイス間で同期。', - }, personalize: { - appearance: '外観', - appearanceDesc: 'システムに従う / ライト / ダーク', - appearanceSystem: 'システムに従う', - appearanceLight: 'ライト', - appearanceDark: 'ダーク', font: 'フォントサイズ', fontDesc: 'UI のフォントサイズを全体的にスケール。即時反映。', fontSmall: '小', @@ -837,10 +844,6 @@ export const ja: typeof zhCN = { fontLarge: '大', blur: 'すりガラス強度', blurDesc: 'ウィンドウ内側の backdrop-filter 強度に影響(macOS のシステムフロスト層が動かない場合に調整)。', - startupOpen: '起動時に開く', - startupOverview: '概要', - startupLast: '前回の位置', - startupAtBoot: '起動時に自動起動', }, about: { tagline: '自然に話し、きれいに書く', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 95a8b637..96106ccc 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -497,17 +497,31 @@ export const ko: typeof zhCN = { kicker: 'SETTINGS', title: '설정', desc: '녹음, 공급자, 단축키, 권한 설정.', - sections: { - recording: '녹음', - providers: '공급자', - shortcuts: '단축키', - permissions: '권한', - language: '언어', - advanced: '고급', - about: '정보', + dataStorage: { + title: '데이터 저장', + desc: '이 기기에 보관되는 대화 기록과 컨텍스트.', + }, + debug: { + title: '디버그 도구', + desc: '인식 문제를 진단할 때 사용합니다. 평소에는 꺼두어도 됩니다.', + }, + marketplace: { + title: '확장 마켓', + desc: '스타일 마켓 업로드 신원. 스타일 둘러보기와 설치는 「스타일」 페이지에서 합니다.', + github: { + signIn: 'GitHub로 로그인', + signedIn: 'GitHub로 로그인됨', + signedOut: '로그인하면 스타일 업로드와 좋아요를 할 수 있습니다.', + signOut: '로그아웃', + starting: '로그인을 시작하는 중…', + codeHint: '열린 GitHub 페이지에서 이 코드를 입력하세요:', + openGithub: 'GitHub 열기', + waiting: 'GitHub를 열었습니다. 승인하면 로그인됩니다…', + failed: '로그인 실패, 다시 시도하세요', + }, }, recording: { - title: '녹음', + title: '녹음 및 입력', desc: '전역 녹음의 단축키와 트리거 방식을 정의합니다.', hotkeyLabel: '녹음 단축키', hotkeyDescAcc: '누르면 음성 캡처 시작(전역). 접근성 권한이 필요합니다.', @@ -649,6 +663,7 @@ export const ko: typeof zhCN = { selectModel: '모델을 선택해 위 필드에 입력', modelSaved: '모델 {{model}} 을(를) 저장했습니다.', validateSuccess: '연결 확인을 통과했습니다.', + validateFailed: '연결 확인에 실패했습니다.', providerHttpStatus: '공급자가 {{status}} 를 반환했습니다. API Key 권한 또는 Endpoint 를 확인해 주세요.', endpointMustUseHttps: 'Endpoint 는 HTTPS 를 사용해야 합니다(localhost/127.0.0.1 제외).', endpointInvalid: 'Endpoint 형식이 올바르지 않습니다.', @@ -769,8 +784,9 @@ export const ko: typeof zhCN = { privacy: '프라이버시', privacyDesc: '모든 데이터는 로컬에 저장됩니다. 클라우드 API 는 녹음을 보관하지 않습니다.', localFirst: '로컬 우선', + linksTitle: '문서 링크', betaChannelLabel: 'Beta 채널 참여', - betaChannelDesc: '활성화 시 Beta 업데이트 수신. 불안정할 수 있으며 얼리 어답터만 권장.', + betaChannelDesc: '새 기능 미리 체험', betaChannelFetching: '최신 Beta 버전을 가져오는 중…', betaChannelFetchBtn: '최신 Beta 확인', betaChannelLatestPrefix: '최신 Beta:', @@ -811,25 +827,16 @@ export const ko: typeof zhCN = { }, modal: { sections: { - account: '계정', - settings: '설정', + general: '일반', + services: '서비스', + privacy: '프라이버시', + advanced: '고급', personalize: '개인 설정', about: '정보', helpCenter: '도움말 센터', releaseNotes: '릴리스 노트', }, - account: { - localUser: '로컬 사용자', - localUserDesc: '로그인하지 않음 · 모든 데이터는 로컬에 저장', - loginSync: '로그인 / 동기화', - footer: '기본적으로 완전 로컬 실행. 로그인하면 어휘와 스타일 프리셋을 기기 간 동기화.', - }, personalize: { - appearance: '모양', - appearanceDesc: '시스템 따라가기 / 라이트 / 다크', - appearanceSystem: '시스템 따라가기', - appearanceLight: '라이트', - appearanceDark: '다크', font: '글꼴 크기', fontDesc: 'UI 글꼴 크기를 전체 스케일. 즉시 반영.', fontSmall: '소', @@ -837,10 +844,6 @@ export const ko: typeof zhCN = { fontLarge: '대', blur: '서리유리 강도', blurDesc: '창 내부 backdrop-filter 강도에 영향(macOS 시스템 서리 레이어가 작동하지 않을 때 조정).', - startupOpen: '시작 시 열기', - startupOverview: '개요', - startupLast: '마지막 위치', - startupAtBoot: '부팅 시 자동 시작', }, about: { tagline: '자연스럽게 말하고, 정확하게 작성하세요', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 9bbfc394..7a1d8cd5 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -493,17 +493,31 @@ export const zhCN = { kicker: 'SETTINGS', title: '设置', desc: '录音、提供商、快捷键与权限配置。', - sections: { - recording: '录音', - providers: '提供商', - shortcuts: '快捷键', - permissions: '权限', - language: '语言', - advanced: '高级', - about: '关于', + dataStorage: { + title: '数据存储', + desc: '本机保留的历史会话与对话上下文。', + }, + debug: { + title: '调试工具', + desc: '排查识别问题时使用,平时无需开启。', + }, + marketplace: { + title: '扩展市场', + desc: '风格市场的上传身份。浏览与安装风格在「风格」页内完成。', + github: { + signIn: '用 GitHub 账号登录', + signedIn: '已通过 GitHub 登录', + signedOut: '登录后即可上传风格、给风格点赞。', + signOut: '退出登录', + starting: '正在发起登录…', + codeHint: '在打开的 GitHub 页面输入这个验证码:', + openGithub: '打开 GitHub', + waiting: '已打开 GitHub,完成授权后会自动登录…', + failed: '登录失败,请重试', + }, }, recording: { - title: '录音', + title: '录音与输入', desc: '全局录音的快捷键与触发方式。', hotkeyLabel: '录音快捷键', hotkeyDescAcc: '按下开始捕获语音,全局生效(需辅助功能权限)。', @@ -645,6 +659,7 @@ export const zhCN = { selectModel: '选择一个模型写入上方字段', modelSaved: '已保存模型 {{model}}。', validateSuccess: '连接检查通过。', + validateFailed: '连接检查未通过。', providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。', endpointMustUseHttps: 'Endpoint 必须使用 HTTPS(本地 localhost/127.0.0.1 测试除外)。', endpointInvalid: 'Endpoint 格式不合法。', @@ -765,8 +780,9 @@ export const zhCN = { privacy: '隐私', privacyDesc: '所有数据仅保存在本机,云端 API 不保留录音。', localFirst: '本地优先', + linksTitle: '文档链接', betaChannelLabel: '加入 Beta 渠道', - betaChannelDesc: '开启后自动接收 Beta 版本。可能不稳定,仅推荐愿意尝鲜的用户。', + betaChannelDesc: '抢先体验新功能', betaChannelFetching: '正在获取最新 Beta 版本…', betaChannelFetchBtn: '查询最新 Beta', betaChannelLatestPrefix: '最新 Beta:', @@ -807,25 +823,16 @@ export const zhCN = { }, modal: { sections: { - account: '账户', - settings: '设置', + general: '通用', + services: '服务', + privacy: '隐私', + advanced: '高级', personalize: '个性化', about: '关于', helpCenter: '帮助中心', - releaseNotes: '版本说明', - }, - account: { - localUser: '本地用户', - localUserDesc: '未登录 · 所有数据保存在本机', - loginSync: '登录 / 同步', - footer: '默认完全本地运行。登录后可跨设备同步词汇表与风格预设。', + releaseNotes: '发布日志', }, personalize: { - appearance: '外观', - appearanceDesc: '跟随系统 / 浅色 / 深色', - appearanceSystem: '跟随系统', - appearanceLight: '浅色', - appearanceDark: '深色', font: '字体大小', fontDesc: '整体缩放界面字号,立即生效。', fontSmall: '小', @@ -833,10 +840,6 @@ export const zhCN = { fontLarge: '大', blur: '毛玻璃强度', blurDesc: '影响窗口内层 backdrop-filter 强度(macOS 系统磨砂层无法运行时调)。', - startupOpen: '启动时打开', - startupOverview: '概览', - startupLast: '上次位置', - startupAtBoot: '开机自启', }, about: { tagline: '自然说话,完美书写', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index c2be5bad..e987706a 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -495,17 +495,31 @@ export const zhTW: typeof zhCN = { kicker: 'SETTINGS', title: '設置', desc: '錄音、提供商、快捷鍵與權限配置。', - sections: { - recording: '錄音', - providers: '提供商', - shortcuts: '快捷鍵', - permissions: '權限', - language: '語言', - advanced: '高級', - about: '關於', + dataStorage: { + title: '資料儲存', + desc: '本機保留的歷史會話與對話上下文。', + }, + debug: { + title: '除錯工具', + desc: '排查辨識問題時使用,平時無需開啟。', + }, + marketplace: { + title: '擴充市集', + desc: '風格市集的上傳身份。瀏覽與安裝風格在「風格」頁內完成。', + github: { + signIn: '用 GitHub 帳號登入', + signedIn: '已透過 GitHub 登入', + signedOut: '登入後即可上傳風格、為風格按讚。', + signOut: '登出', + starting: '正在發起登入…', + codeHint: '在開啟的 GitHub 頁面輸入這個驗證碼:', + openGithub: '開啟 GitHub', + waiting: '已開啟 GitHub,完成授權後會自動登入…', + failed: '登入失敗,請重試', + }, }, recording: { - title: '錄音', + title: '錄音與輸入', desc: '定義全局錄音的快捷鍵與觸發方式。', hotkeyLabel: '錄音快捷鍵', hotkeyDescAcc: '按下即開始捕獲語音,全局生效。需要授予輔助功能權限。', @@ -647,6 +661,7 @@ export const zhTW: typeof zhCN = { selectModel: '選擇一個模型寫入上方字段', modelSaved: '已保存模型 {{model}}。', validateSuccess: '連接檢查通過。', + validateFailed: '連接檢查未通過。', providerHttpStatus: '供應商接口返回 {{status}},請檢查 API Key 權限或 Endpoint。', endpointMustUseHttps: 'Endpoint 必須使用 HTTPS(本地 localhost/127.0.0.1 測試除外)。', endpointInvalid: 'Endpoint 格式不合法。', @@ -767,8 +782,9 @@ export const zhTW: typeof zhCN = { privacy: '隱私', privacyDesc: '所有資料僅保存在本機,雲端 API 不保留錄音。', localFirst: '本地優先', + linksTitle: '文件連結', betaChannelLabel: '加入 Beta 渠道', - betaChannelDesc: '開啟後自動接收 Beta 版本。可能不穩定,僅推薦願意嘗鮮的用戶。', + betaChannelDesc: '搶先體驗新功能', betaChannelFetching: '正在獲取最新 Beta 版本…', betaChannelFetchBtn: '查詢最新 Beta', betaChannelLatestPrefix: '最新 Beta:', @@ -809,25 +825,16 @@ export const zhTW: typeof zhCN = { }, modal: { sections: { - account: '賬戶', - settings: '設置', + general: '通用', + services: '服務', + privacy: '隱私', + advanced: '高級', personalize: '個性化', about: '關於', helpCenter: '幫助中心', - releaseNotes: '版本說明', - }, - account: { - localUser: '本地用戶', - localUserDesc: '未登錄 · 所有數據保存在本機', - loginSync: '登錄 / 同步', - footer: '預設完全本地運行。登入後可跨裝置同步詞彙表與風格預設。', + releaseNotes: '發佈日誌', }, personalize: { - appearance: '外觀', - appearanceDesc: '跟隨系統 / 淺色 / 深色', - appearanceSystem: '跟隨系統', - appearanceLight: '淺色', - appearanceDark: '深色', font: '字體大小', fontDesc: '整體縮放界面字號,立即生效。', fontSmall: '小', @@ -835,10 +842,6 @@ export const zhTW: typeof zhCN = { fontLarge: '大', blur: '毛玻璃強度', blurDesc: '影響窗口內層 backdrop-filter 強度(macOS 系統磨砂層無法運行時調)。', - startupOpen: '啓動時打開', - startupOverview: '概覽', - startupLast: '上次位置', - startupAtBoot: '開機自啓', }, about: { tagline: '自然說話,完美書寫', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index eb28575a..7491d6ff 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -156,7 +156,7 @@ const mockFullStylePrompts: StyleSystemPrompts = { 语音输入整理器。把 AI 编程协作、技术排障和模型资讯口述整理成结构清楚、术语准确的文本。 # 任务(清晰结构 · AI 编程协作) -优先修正 ASR 造成的技术词、模型名、字段名错误;多事项按主题输出双层 list,操作指引输出连续步骤。 +优先修正 ASR 造成的技术词、模型名、字段名错误;两个事项以上必须编号(1./2./3.),三事项以上按主题分组输出双层 list。 # 术语 Token、Secret Key、Access Token、API、App ID、Claude、Gemini、Cappuccino、Coder、LongCat、Codex、MCP、SSE、PR、CI、ASR、LLM、SOTA、FP8。保留命令、路径、环境变量、URL、true / false / null 和模型版本号。 diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 0c592ec5..9ce653e1 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -5,17 +5,20 @@ import i18n from "./i18n"; // 副作用:触发 i18next init import "./styles/tokens.css"; import "./styles/global.css"; +import type { OS } from "./components/WindowChrome"; + const params = new URLSearchParams(window.location.search); const windowKind = params.get("window"); const isCapsule = windowKind === "capsule"; const isQa = windowKind === "qa"; +const osQuery = params.get("os") as OS | null; const root = ReactDOM.createRoot(document.getElementById("root")!); const renderApp = () => { root.render( - + , ); }; diff --git a/openless-all/app/src/pages/Marketplace.tsx b/openless-all/app/src/pages/Marketplace.tsx index ddcabaaa..398e2b64 100644 --- a/openless-all/app/src/pages/Marketplace.tsx +++ b/openless-all/app/src/pages/Marketplace.tsx @@ -17,10 +17,10 @@ import { AnimatePresence, motion } from 'framer-motion'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { SavedToast } from '../components/SavedToast'; +import { GithubLoginModal } from '../components/GithubLoginModal'; +import { Modal } from '../components/ui/Modal'; import { fetchMarketplaceDetail, - githubDeviceFlowPoll, - githubDeviceFlowStart, installMarketplacePack, likeMarketplacePack, listMarketplace, @@ -28,7 +28,6 @@ import { marketplaceDelete, marketplaceMyLikes, marketplaceMyPacks, - openExternal, readMarketplaceDetailCache, readMarketplaceListCache, uploadMarketplacePack, @@ -75,15 +74,8 @@ export function Marketplace() { // 失败后只弹 toast,没有 inline 重试入口。 const [myPacksLoading, setMyPacksLoading] = useState(false); const [myPacksError, setMyPacksError] = useState(null); - // GitHub OAuth Device Flow 状态。点登录 chip → 'starting' → 'pending'(展示 user_code 等待 - // 用户在浏览器授权)→ 'success'(自动保存 marketplaceDevLogin)/ 'error'。 - type OAuthPhase = - | { phase: 'idle' } - | { phase: 'starting' } - | { phase: 'pending'; userCode: string; verificationUri: string; deviceCode: string } - | { phase: 'success'; login: string } - | { phase: 'error'; message: string }; - const [oauth, setOauth] = useState({ phase: 'idle' }); + // GitHub 登录弹窗开关 —— 登录流程交给共用的 。 + const [showLogin, setShowLogin] = useState(false); // 当前用户赞过的 pack id 集合 —— 用于红心渲染 + 「我赞过的」过滤。 // 进入 marketplace 时拉一次;点星后本地 mutate。 const [likedIds, setLikedIds] = useState>(new Set()); @@ -402,67 +394,14 @@ export function Marketplace() { } }; - // GitHub OAuth Device Flow 入口:点登录 chip 触发。 - const beginGithubLogin = useCallback(async () => { - setOauth({ phase: 'starting' }); - try { - const start = await githubDeviceFlowStart(); - setOauth({ - phase: 'pending', - userCode: start.userCode, - verificationUri: start.verificationUri, - deviceCode: start.deviceCode, - }); - // 自动拉起浏览器到 verification_uri;失败不致命,用户可以手动复制点击 - try { await openExternal(start.verificationUri); } catch { /* user can copy manually */ } - } catch (error) { - setOauth({ phase: 'error', message: errorMessage(error) }); - } - }, []); - - // OAuth 轮询:phase==='pending' 时每 interval 秒打 backend → GitHub 一次。 - useEffect(() => { - if (oauth.phase !== 'pending') return; - let cancelled = false; - let timer: number | null = null; - let interval = 5_000; - const pendingDeviceCode = oauth.deviceCode; - const tick = async () => { - if (cancelled) return; - try { - const res = await githubDeviceFlowPoll(pendingDeviceCode); - if (cancelled) return; - if (res.kind === 'authorized') { - setOauth({ phase: 'success', login: res.login }); - // 写入 prefs.marketplaceDevLogin,让后续 X-Dev-User 走真实 GitHub login。 - try { - await updatePrefs(current => ({ ...current, marketplaceDevLogin: res.login })); - } catch (e) { - console.warn('[oauth] save login to prefs failed', e); - } - setActionMsg({ kind: 'ok', text: t('marketplace.oauth.successAs', { login: res.login }) }); - window.setTimeout(() => { - if (!cancelled) setOauth({ phase: 'idle' }); - }, 1500); - } else if (res.kind === 'slowDown') { - interval = Math.min(interval + 5_000, 30_000); - timer = window.setTimeout(tick, interval); - } else if (res.kind === 'pending') { - timer = window.setTimeout(tick, interval); - } else { - setOauth({ phase: 'error', message: res.message }); - } - } catch (error) { - if (cancelled) return; - setOauth({ phase: 'error', message: errorMessage(error) }); - } - }; - timer = window.setTimeout(tick, interval); - return () => { - cancelled = true; - if (timer != null) window.clearTimeout(timer); - }; - }, [oauth, updatePrefs]); + // GitHub 登录成功 → 写回 prefs.marketplaceDevLogin,让后续 X-Dev-User 走真实身份。 + const onLoginSuccess = useCallback((nextLogin: string) => { + // prefs 写入失败只 console 记一笔(与重构前的 OAuth 轮询一致)—— 不能裸 void, + // 否则 reject 会冒成未处理的 promise rejection。 + void updatePrefs(current => ({ ...current, marketplaceDevLogin: nextLogin })) + .catch(e => console.warn('[marketplace] save login to prefs failed', e)); + setActionMsg({ kind: 'ok', text: t('marketplace.oauth.successAs', { login: nextLogin }) }); + }, [updatePrefs, t]); const sortPills = useMemo>( () => [ @@ -901,8 +840,7 @@ export function Marketplace() { -
- - {oauth.phase === 'starting' && ( -
- {t('marketplace.oauth.generating')} -
- )} - - {oauth.phase === 'pending' && ( -
-
- {(() => { - const parts = t('marketplace.oauth.browserHint', { uri: ' URI ' }).split(' URI '); - return ( - <> - {parts[0]} - {oauth.verificationUri} - {parts[1] ?? ''} - - ); - })()} -
-
- {oauth.userCode} - { - try { - await navigator.clipboard.writeText(oauth.userCode); - setActionMsg({ kind: 'ok', text: t('marketplace.oauth.copied') }); - } catch (e) { - setActionMsg({ kind: 'err', text: t('marketplace.oauth.copyFailed', { err: errorMessage(e) }) }); - } - }} - > - {t('marketplace.oauth.copyBtn')} - -
-
- void openExternal(oauth.verificationUri)}> - {t('marketplace.oauth.openBrowserBtn')} - - setOauth({ phase: 'idle' })}> - {t('marketplace.oauth.cancelBtn')} - -
-
- - {t('marketplace.oauth.waiting')} -
- -
- )} - - {oauth.phase === 'success' && ( -
-
-
{t('marketplace.oauth.successAs', { login: oauth.login })}
-
- )} - - {oauth.phase === 'error' && ( -
-
- {oauth.message} -
-
- setOauth({ phase: 'idle' })}>{t('marketplace.oauth.closeBtn')} - void beginGithubLogin()}>{t('marketplace.oauth.retryBtn')} -
-
- )} - + {/* GitHub 登录弹窗 */} + {showLogin && ( + setShowLogin(false)} + onSuccess={onLoginSuccess} + /> )} ); } -function Modal({ - children, - onClose, - zIndex = 50, -}: { - children: React.ReactNode; - onClose: () => void; - /** 默认 50;多层叠加时(如上传 picker 在「我的发布」之上)传更大的值。*/ - zIndex?: number; -}) { - return ( -
-
e.stopPropagation()} - style={{ - width: 'min(560px, 100%)', - maxHeight: '85vh', - overflow: 'auto', - borderRadius: 16, - background: 'var(--ol-surface)', - border: '0.5px solid var(--ol-line-strong)', - boxShadow: '0 18px 42px rgba(0,0,0,0.18)', - padding: 22, - }} - > - {children} -
- -
- ); -} - function statusLabel(state: string, t: (key: string) => string): string { switch (state) { case 'pending': return t('marketplace.state.pending'); diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 428f18f3..20b1367f 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -655,7 +655,7 @@ const contentStyle: CSSProperties = { flex: 1, minHeight: 0, overflow: 'auto', - padding: '14px 16px', + padding: 16, display: 'flex', flexDirection: 'column', gap: 12, @@ -754,7 +754,7 @@ const statusBarStyle: CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, - padding: '0 14px', + padding: '0 16px', borderTop: '0.5px solid rgba(0, 0, 0, 0.06)', background: 'rgba(255,255,255,0.4)', }; diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx deleted file mode 100644 index 793d26c5..00000000 --- a/openless-all/app/src/pages/Settings.tsx +++ /dev/null @@ -1,2213 +0,0 @@ -// Settings.tsx — ported verbatim from design_handoff_openless/pages.jsx::Settings. -// Section 拆分见 settings/ 子目录;本文件保留 dispatcher + RecordingSection + ProvidersSection(含其内嵌助手), -// 其他 section 已挪出。原导出 Toggle / AboutUpdateControl / SettingsSectionId 通过 re-export 维持向后兼容。 - -import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Icon } from '../components/Icon'; -import { ShortcutRecorder } from '../components/ShortcutRecorder'; -import { detectOS } from '../components/WindowChrome'; -import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; -import { - getHotkeyBindingCodes, - getHotkeyBindingLabel, - getHotkeyCodeLabel, -} from '../lib/hotkey'; -import { createHotkeyRecorderState, orderHotkeyCodes, updateHotkeyRecorderState } from '../lib/hotkeyRecorder'; -import { - isTauri, - listMicrophoneDevices, - openExternal, - listProviderModels, - readCredential, - setActiveAsrProvider, - setActiveLlmProvider, - setCredential, - setDictationHotkey, - startMicrophoneLevelMonitor, - stopMicrophoneLevelMonitor, - validateProviderCredentials, -} from '../lib/ipc'; -import type { - HotkeyBinding, - HotkeyMode, - HotkeyTrigger, - MicrophoneDevice, - PasteShortcut, -} from '../lib/types'; -import { emitSaved } from '../lib/savedEvent'; -import { useHotkeySettings } from '../state/HotkeySettingsContext'; -import { SelectLite } from '../components/ui/SelectLite'; -import { Btn, Card, Collapsible, PageHeader, Pill } from './_atoms'; -import { - deleteLocalAsrModel, - getLocalAsrSettings, - listLocalAsrModels, - type LocalAsrModelStatus, - type LocalAsrSettings, -} from '../lib/localAsr'; -import { SettingRow, Toggle, inputStyle, SectionTitle, SectionDesc, type AsrPresetId } from './settings/shared'; -import { AdvancedSection } from './settings/AdvancedSection'; -import { ShortcutsSection } from './settings/ShortcutsSection'; -import { PermissionsSection } from './settings/PermissionsSection'; -import { LanguageSection } from './settings/LanguageSection'; - -export { Toggle } from './settings/shared'; -export { AboutUpdateControl } from './settings/AboutUpdateControl'; - -/// Settings → ASR 选了 local-qwen3 时触发跳到「模型设置」页 + 关 Settings modal。 -/// FloatingShell 监听同名事件做 setCurrentTab('localAsr') + setSettingsOpen(false)。 -export const NAVIGATE_LOCAL_ASR_EVENT = 'openless:navigate-local-asr'; - -interface SettingsProps { - embedded?: boolean; - initialSection?: SettingsSectionId; -} -// "关于" tab 已移除(内容并入外层 SettingsModal 的 About 页,避免设置内外重复入口)。 -export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permissions' | 'language' | 'advanced'; - -// 「高级」放最末——本地推理 / 实验性开关都集中到这一栏,避免新手用户在主流程 -// 里误开 CPU 推理(之前提案:把 local-qwen3 / foundry-local-whisper 从主 ASR -// 下拉藏进高级)。位置末尾也是「实验性」语义在 macOS 系统偏好里的惯用位置。 -const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language', 'advanced']; - -async function autostartIsEnabled(): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - return invoke('plugin:autostart|is_enabled'); -} - -async function autostartEnable(): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('plugin:autostart|enable'); -} - -async function autostartDisable(): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('plugin:autostart|disable'); -} - -export function Settings({ embedded = false, initialSection = 'recording' }: SettingsProps) { - const { t } = useTranslation(); - const [section, setSection] = useState(initialSection); - - useEffect(() => { - setSection(initialSection); - }, [initialSection]); - - // 跟 sidebar / SettingsModal 同款滑动 pill:测当前 active section 的 offsetTop/height - // → 用 absolute pill 平滑滑过去;--ol-motion-spring 是项目里的 Apple 风格 ease-out-quint。 - const sectionRefs = useRef>([]); - const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null); - useLayoutEffect(() => { - const idx = SECTION_ORDER.indexOf(section); - const el = sectionRefs.current[idx]; - if (!el) return; - setPillRect({ top: el.offsetTop, height: el.offsetHeight }); - }, [section]); - - return ( - <> - {!embedded && ( - - )} - {/* embedded(在 SettingsModal 里)模式下:mini-sidebar 固定,仅右栏 scroll。 - 外层 flex:1 minHeight:0 让 grid 拿到确定高度;gridTemplateRows: minmax(0, 1fr) - 强制行高等于容器高度,否则 grid 默认 auto rows 会跟内容长,右栏 overflow:auto - 就退化成"没东西需要 scroll",于是大家照旧一起飘。 */} -
-
- {pillRect && ( -
- )} - {SECTION_ORDER.map((s, i) => { - const active = section === s; - return ( - - ); - })} -
-
- {section === 'recording' && } - {section === 'providers' && } - {section === 'shortcuts' && } - {section === 'permissions' && } - {section === 'language' && } - {section === 'advanced' && } -
-
- - ); -} - -function RecordingSection() { - const { t } = useTranslation(); - const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); - const [microphoneDevices, setMicrophoneDevices] = useState([]); - const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false); - const [microphoneDevicesError, setMicrophoneDevicesError] = useState(null); - const [microphonePickerOpen, setMicrophonePickerOpen] = useState(false); - - const loadMicrophoneDevices = useCallback(async ( - signal?: { cancelled: boolean }, - options: { showLoading?: boolean } = {}, - ) => { - if (options.showLoading ?? true) { - setMicrophoneDevicesLoaded(false); - } - setMicrophoneDevicesError(null); - try { - const devices = await listMicrophoneDevices(); - if (signal?.cancelled) return; - setMicrophoneDevices(devices); - setMicrophoneDevicesLoaded(true); - } catch (err) { - console.error('[settings] list microphone devices failed', err); - if (signal?.cancelled) return; - setMicrophoneDevices([]); - setMicrophoneDevicesError(err instanceof Error ? err.message : String(err)); - setMicrophoneDevicesLoaded(true); - } - }, []); - - useEffect(() => { - const signal = { cancelled: false }; - void loadMicrophoneDevices(signal); - return () => { - signal.cancelled = true; - }; - }, [loadMicrophoneDevices]); - - useEffect(() => { - if (!isTauri) return; - let cancelled = false; - let unlisten: (() => void) | undefined; - async function listenForDeviceChanges() { - const { listen } = await import('@tauri-apps/api/event'); - if (cancelled) return; - const stopListening = await listen('microphone:devices-changed', () => { - void loadMicrophoneDevices(undefined, { showLoading: false }); - }); - if (cancelled) { - stopListening(); - return; - } - unlisten = stopListening; - } - void listenForDeviceChanges(); - return () => { - cancelled = true; - unlisten?.(); - }; - }, [loadMicrophoneDevices]); - - useEffect(() => { - if (microphonePickerOpen) { - void loadMicrophoneDevices(undefined, { showLoading: false }); - } - }, [loadMicrophoneDevices, microphonePickerOpen]); - - if (!prefs || !capability) { - return ( - -
{t('common.loading')}
-
- ); - } - - const onModeChange = (mode: HotkeyMode) => - savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); - const onShowCapsuleChange = (showCapsule: boolean) => - savePrefs({ ...prefs, showCapsule }); - const onMuteDuringRecordingChange = (muteDuringRecording: boolean) => - savePrefs({ ...prefs, muteDuringRecording }); - const onMicrophoneDeviceChange = (microphoneDeviceName: string) => - savePrefs({ ...prefs, microphoneDeviceName }); - const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => - savePrefs({ ...prefs, restoreClipboardAfterPaste }); - const onPasteShortcutChange = (pasteShortcut: PasteShortcut) => - savePrefs({ ...prefs, pasteShortcut }); - const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => - savePrefs({ ...prefs, allowNonTsfInsertionFallback }); - // 历史保留 / 对话感知 polish 上下文窗口都用裸 number input;空字符串时回滚到默认值。 - // 范围限制:retention 0-365 天,context window 0-60 分钟(再大的值对实际对话场景没意义且白烧 token)。 - const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); - const onHistoryRetentionChange = (raw: string) => { - const parsed = raw === '' ? 0 : Number.parseInt(raw, 10); - if (Number.isNaN(parsed)) return; - void savePrefs({ ...prefs, historyRetentionDays: clamp(parsed, 0, 365) }); - }; - const onPolishContextWindowChange = (raw: string) => { - const parsed = raw === '' ? 0 : Number.parseInt(raw, 10); - if (Number.isNaN(parsed)) return; - void savePrefs({ ...prefs, polishContextWindowMinutes: clamp(parsed, 0, 60) }); - }; - const onStartMinimizedChange = (startMinimized: boolean) => - savePrefs({ ...prefs, startMinimized }); - const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) => - savePrefs({ ...prefs, autoUpdateCheck }); - const onMarketplaceDevLoginChange = (marketplaceDevLogin: string) => - savePrefs({ ...prefs, marketplaceDevLogin }); - const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) => - savePrefs({ ...prefs, recordAudioForDebug }); - // 历史条数 200 是当前 HISTORY_CAP(persistence.rs:32),下限 5 是避免用户填 0 导致 - // 写一条就立刻被清光;空字符串视为不限制,落回 null → 后端走 200 默认。 - const onHistoryMaxEntriesChange = (raw: string) => { - const trimmed = raw.trim(); - if (trimmed === '') { - void savePrefs({ ...prefs, historyMaxEntries: null }); - return; - } - const parsed = Number.parseInt(trimmed, 10); - if (Number.isNaN(parsed)) return; - void savePrefs({ ...prefs, historyMaxEntries: clamp(parsed, 5, 200) }); - }; - const onAudioRecordingMaxEntriesChange = (raw: string) => { - const trimmed = raw.trim(); - if (trimmed === '') { - void savePrefs({ ...prefs, audioRecordingMaxEntries: null }); - return; - } - const parsed = Number.parseInt(trimmed, 10); - if (Number.isNaN(parsed)) return; - void savePrefs({ ...prefs, audioRecordingMaxEntries: clamp(parsed, 1, 200) }); - }; - - const choices: Array<[HotkeyMode, string]> = [ - ['toggle', t('settings.recording.modeToggle')], - ['hold', t('settings.recording.modeHold')], - ]; - const hotkeyDesc = capability.requiresAccessibilityPermission - ? t('settings.recording.hotkeyDescAcc') - : t('settings.recording.hotkeyDescNoAcc'); - const preferredMicrophoneAvailable = Boolean( - prefs.microphoneDeviceName - && microphoneDevices.some(device => device.name === prefs.microphoneDeviceName), - ); - const effectiveMicrophoneDeviceName = prefs.microphoneDeviceName - && (!microphoneDevicesLoaded || preferredMicrophoneAvailable) - ? prefs.microphoneDeviceName - : ''; - const selectedMicrophoneLabel = effectiveMicrophoneDeviceName - ? effectiveMicrophoneDeviceName - : t('settings.recording.microphoneDefault'); - - return ( - <> - - {t('settings.recording.title')} - {isHotkeyModeMigrationNoticeActive() && ( -
-
- {t('settings.recording.migrationNoticeTitle')} -
-
- {t('settings.recording.migrationNoticeDesc')} -
-
- )} - - { - await setDictationHotkey(binding); - await savePrefs({ ...prefs, dictationHotkey: binding }); - }} - /> - - -
- {choices.map(([v, l]) => ( - - ))} -
-
- -
- - {!microphoneDevicesLoaded && ( -
{t('common.loading')}
- )} - {microphoneDevicesError && ( -
- {t('settings.recording.microphoneLoadError', { message: microphoneDevicesError })} -
- )} -
-
- {microphonePickerOpen && ( - setMicrophonePickerOpen(false)} - onRefresh={() => { - void loadMicrophoneDevices(); - }} - loading={!microphoneDevicesLoaded} - onSelect={(name) => { - onMicrophoneDeviceChange(name); - }} - /> - )} - - - - - - -
- - {/* ─── 插入与剪贴板(折叠) ──────────────────────────────────── */} - - - - - {capability.adapter !== 'macEventTap' && ( - - onPasteShortcutChange(next as PasteShortcut)} - options={[ - { value: 'ctrlV', label: t('settings.recording.pasteShortcutCtrlV') }, - { value: 'ctrlShiftV', label: t('settings.recording.pasteShortcutCtrlShiftV') }, - { value: 'shiftInsert', label: t('settings.recording.pasteShortcutShiftInsert') }, - ]} - ariaLabel={t('settings.recording.pasteShortcutLabel')} - style={{ ...inputStyle, maxWidth: 220 }} - /> - - )} - {capability.adapter === 'windowsLowLevel' && ( - - - - )} - - - {/* ─── 历史与上下文(折叠) ────────────────────────────────── */} - - - onHistoryRetentionChange(e.target.value)} - style={{ ...inputStyle, width: 80, textAlign: 'right' }} - /> - - - onHistoryMaxEntriesChange(e.target.value)} - style={{ ...inputStyle, width: 80, textAlign: 'right' }} - /> - - - onPolishContextWindowChange(e.target.value)} - style={{ ...inputStyle, width: 80, textAlign: 'right' }} - /> - - - - - - onAudioRecordingMaxEntriesChange(e.target.value)} - style={{ ...inputStyle, width: 80, textAlign: 'right' }} - disabled={!prefs.recordAudioForDebug} - /> - - - - {/* ─── 启动(折叠) ──────────────────────────────────────────── */} - - - - - - - - - {capability.statusHint && ( -
- {capability.statusHint} -
- )} -
- - {/* ─── 风格市场(折叠) ────────────────────────────────────────── */} - {/* URL 已硬编码到云端 apic.openless.top(commands.rs:MARKETPLACE_BASE_URL), - Settings 不再提供输入,避免用户改错导致连不上。GitHub OAuth 上线后这里会接登录按钮。 */} - - - onMarketplaceDevLoginChange(e.target.value)} - style={{ ...inputStyle, width: 180 }} - /> - - - - ); -} - -function HotkeyRecorder({ - binding, - onCommit, -}: { - binding: HotkeyBinding; - onCommit: (codes: string[]) => void; -}) { - const { t } = useTranslation(); - const [recording, setRecording] = useState(false); - const [draftCodes, setDraftCodes] = useState([]); - const recorderStateRef = useRef(createHotkeyRecorderState()); - const recordingRef = useRef(false); - - const resetRecording = () => { - recordingRef.current = false; - recorderStateRef.current = createHotkeyRecorderState(); - setDraftCodes([]); - setRecording(false); - }; - - const commitCodes = (codes: string[]) => { - const ordered = orderHotkeyCodes(codes); - resetRecording(); - onCommit(ordered); - }; - - const startRecording = () => { - recordingRef.current = true; - recorderStateRef.current = createHotkeyRecorderState(); - setDraftCodes([]); - setRecording(true); - }; - - useEffect(() => { - if (!recording) return undefined; - - const stopEvent = (event: Event) => { - event.preventDefault(); - event.stopPropagation(); - }; - - const applyHotkeyCode = (code: string, pressed: boolean) => { - if (!recordingRef.current) return; - const next = updateHotkeyRecorderState(recorderStateRef.current, code, pressed); - recorderStateRef.current = next.state; - setDraftCodes(next.state.draftCodes); - if (next.commitCodes) commitCodes(next.commitCodes); - }; - - const onKeyDown = (event: KeyboardEvent) => { - stopEvent(event); - if (event.key === 'Escape' || event.code === 'Escape') { - resetRecording(); - return; - } - const code = normalizeKeyboardHotkeyCode(event); - if (!code) return; - applyHotkeyCode(code, true); - }; - - const onKeyUp = (event: KeyboardEvent) => { - stopEvent(event); - if (!recordingRef.current) return; - if (event.key === 'Escape' || event.code === 'Escape') { - resetRecording(); - return; - } - const code = normalizeKeyboardHotkeyCode(event); - if (!code) return; - applyHotkeyCode(code, false); - }; - - const onMouseDown = (event: MouseEvent) => { - const code = mouseButtonToHotkeyCode(event.button); - if (!code) return; - stopEvent(event); - applyHotkeyCode(code, true); - }; - - const onMouseUp = (event: MouseEvent) => { - const code = mouseButtonToHotkeyCode(event.button); - if (!code) return; - stopEvent(event); - applyHotkeyCode(code, false); - }; - - window.addEventListener('keydown', onKeyDown, true); - window.addEventListener('keyup', onKeyUp, true); - window.addEventListener('mousedown', onMouseDown, true); - window.addEventListener('mouseup', onMouseUp, true); - return () => { - window.removeEventListener('keydown', onKeyDown, true); - window.removeEventListener('keyup', onKeyUp, true); - window.removeEventListener('mousedown', onMouseDown, true); - window.removeEventListener('mouseup', onMouseUp, true); - }; - }, [recording]); - - const label = recording - ? draftCodes.length > 0 - ? draftCodes.map(getHotkeyCodeLabel).join('+') - : t('settings.recording.hotkeyRecording') - : getHotkeyBindingLabel(binding); - const hasKeys = getHotkeyBindingCodes(binding).length > 0; - - return ( -
- -
- ); -} - -function MicrophonePickerDialog({ - devices, - selectedName, - onClose, - onRefresh, - loading, - onSelect, -}: { - devices: MicrophoneDevice[]; - selectedName: string; - onClose: () => void; - onRefresh: () => void; - loading: boolean; - onSelect: (name: string) => void; -}) { - const { t } = useTranslation(); - const [pickedName, setPickedName] = useState(selectedName); - const [previewName, setPreviewName] = useState(selectedName); - const [level, setLevel] = useState(0); - const [hoveredName, setHoveredName] = useState(null); - const [pressedName, setPressedName] = useState(null); - const [monitorError, setMonitorError] = useState(null); - const monitorQueueRef = useRef>(Promise.resolve()); - - const enqueueMonitorTask = useCallback((task: () => Promise) => { - const next = monitorQueueRef.current.catch(() => undefined).then(task); - monitorQueueRef.current = next.catch(() => undefined); - return next; - }, []); - - useEffect(() => { - setPickedName(selectedName); - setPreviewName(selectedName); - }, [selectedName]); - - useEffect(() => { - let unlisten: (() => void) | undefined; - let cancelled = false; - let timer: number | undefined; - setLevel(0); - setMonitorError(null); - - async function start() { - await enqueueMonitorTask(async () => { - try { - if (isTauri) { - const { listen } = await import('@tauri-apps/api/event'); - if (cancelled) return; - const stopListening = await listen<{ level: number }>('microphone:level', event => { - setLevel(Math.max(0, Math.min(1, event.payload.level ?? 0))); - }); - if (cancelled) { - stopListening(); - return; - } - unlisten = stopListening; - await startMicrophoneLevelMonitor(previewName); - if (cancelled) { - unlisten?.(); - unlisten = undefined; - await stopMicrophoneLevelMonitor(); - } - } else { - const tick = window.setInterval(() => { - setLevel(0.25 + Math.random() * 0.55); - }, 120); - if (cancelled) { - window.clearInterval(tick); - return; - } - unlisten = () => window.clearInterval(tick); - } - } catch (err) { - console.warn('[settings] microphone level monitor failed', err); - if (!cancelled) { - setMonitorError(err instanceof Error ? err.message : String(err)); - } - } - }); - } - - timer = window.setTimeout(() => { - void start(); - }, 140); - return () => { - cancelled = true; - if (timer !== undefined) { - window.clearTimeout(timer); - } - void enqueueMonitorTask(async () => { - unlisten?.(); - unlisten = undefined; - await stopMicrophoneLevelMonitor(); - }); - }; - }, [enqueueMonitorTask, previewName]); - - const rows = [ - { - id: 'default', - name: '', - label: t('settings.recording.microphoneDefault'), - desc: t('settings.recording.microphoneDefaultDesc'), - isDefault: false, - }, - ...devices.map((device, index) => ({ - id: `${device.name}-${index}`, - name: device.name, - label: device.name, - desc: device.isDefault ? t('settings.recording.microphoneSystemDefault') : '', - isDefault: device.isDefault, - })), - ]; - - return ( -
-
e.stopPropagation()} - style={{ - width: 450, - maxWidth: 'calc(100vw - 48px)', - borderRadius: 16, - background: 'rgba(255,255,255,0.96)', - border: '0.5px solid rgba(0,0,0,0.12)', - boxShadow: '0 24px 70px rgba(0,0,0,0.28)', - padding: 24, - animation: 'olMicPickerPopIn 160ms cubic-bezier(.2,.8,.2,1)', - }} - > -
-
{t('settings.recording.microphoneDialogTitle')}
-
- - -
-
-
- {t('settings.recording.microphoneDialogDesc')} -
- {monitorError && ( -
- {t('settings.recording.microphoneMonitorError', { message: monitorError })} -
- )} -
- {rows.map(row => { - const active = pickedName === row.name; - const previewing = previewName === row.name; - const hovered = hoveredName === row.name; - const pressed = pressedName === row.name; - return ( - - ); - })} -
- -
-
- ); -} - -function inferLegacyTrigger(codes: string[], fallback: HotkeyTrigger): HotkeyTrigger { - if (codes.includes('ControlRight')) return 'rightControl'; - if (codes.includes('ControlLeft')) return 'leftControl'; - if (codes.includes('AltRight')) return 'rightAlt'; - if (codes.includes('AltLeft')) return 'leftOption'; - if (codes.includes('MetaRight')) return 'rightCommand'; - if (codes.includes('Fn')) return 'fn'; - return fallback; -} - -function normalizeKeyboardHotkeyCode(event: KeyboardEvent): string | null { - if (event.key === 'Fn' || event.code === 'Fn') return 'Fn'; - if (event.key === 'FnLock' || event.code === 'FnLock') return 'FnLock'; - const code = event.code === 'OSLeft' ? 'MetaLeft' : event.code === 'OSRight' ? 'MetaRight' : event.code; - if (SUPPORTED_HOTKEY_CODES.has(code)) return code; - if (/^Key[A-Z]$/.test(code)) return code; - if (/^Digit[0-9]$/.test(code)) return code; - if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return code; - if (/^Numpad[0-9]$/.test(code)) return code; - return null; -} - -function mouseButtonToHotkeyCode(button: number): string | null { - if (button === 3) return 'Mouse4'; - if (button === 4) return 'Mouse5'; - return null; -} - -const SUPPORTED_HOTKEY_CODES = new Set([ - 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', - 'MetaLeft', 'MetaRight', 'CapsLock', 'ScrollLock', 'Pause', 'PrintScreen', - 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', 'End', - 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', - 'ContextMenu', 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', - 'NumpadDecimal', 'NumpadEnter', 'Backquote', 'Minus', 'Equal', 'BracketLeft', - 'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', - 'Fn', 'FnLock', -]); - -function LevelMeter({ level }: { level: number }) { - const amplified = Math.min(1, Math.max(0, level * 4.5)); - const bars = [0.25, 0.5, 0.75, 1, 0.75, 0.5]; - return ( - - {bars.map((weight, index) => { - const intensity = Math.min(1, amplified * (0.85 + weight * 0.35)); - const height = 6 + intensity * (20 * weight); - return ( - 0.08 ? 'var(--ol-blue)' : 'rgba(0,0,0,0.10)', - opacity: 0.35 + intensity * 0.65, - transition: 'height 70ms linear, opacity 90ms ease, background 120ms ease', - }} - /> - ); - })} - - ); -} - -// 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop / -// windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。 -function AutostartRow() { - const { t } = useTranslation(); - const [enabled, setEnabled] = useState(false); - const [loaded, setLoaded] = useState(false); - // 切 plist / 注册表失败时给用户看的错误。null = 没有失败/上次操作已成功。 - // 不渲染等于把失败吞掉 —— Windows 写 HKCU\Run 被组策略拦、macOS 写 - // LaunchAgent plist 权限不够 都是真实可能。issue #194。 - const [error, setError] = useState(null); - - useEffect(() => { - if (!isTauri) { - setLoaded(true); - return; - } - let cancelled = false; - autostartIsEnabled() - .then((v: boolean) => { - if (!cancelled) { - setEnabled(v); - setLoaded(true); - } - }) - .catch((err: unknown) => { - console.error('[autostart] isEnabled failed', err); - if (!cancelled) setLoaded(true); - }); - return () => { - cancelled = true; - }; - }, []); - - const onToggle = async (next: boolean) => { - setEnabled(next); - setError(null); - try { - if (!isTauri) return; - if (next) await autostartEnable(); - else await autostartDisable(); - } catch (err) { - console.error('[autostart] toggle failed', err); - setEnabled(!next); - setError(err instanceof Error ? err.message : String(err)); - } - }; - - return ( - -
- {loaded ? : null} - {error && ( -
- {t('settings.recording.startupAtBootError', { message: error })} -
- )} -
-
- ); -} - -function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) { - const { t } = useTranslation(); - return ( -
- - {t('settings.providers.thinkingModeLabel')} - - - - {enabled ? t('settings.providers.thinkingModeOn') : t('settings.providers.thinkingModeOff')} - -
- ); -} - -const LLM_PRESETS = [ - { - id: 'ark', - nameKey: 'ark', - baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', - modelPlaceholder: 'deepseek-v3-2', - }, - { - id: 'deepseek', - nameKey: 'deepseek', - baseUrl: 'https://api.deepseek.com/v1', - modelPlaceholder: 'deepseek-v4-flash', - }, - { - id: 'siliconflow', - nameKey: 'siliconflow', - baseUrl: 'https://api.siliconflow.cn/v1', - modelPlaceholder: 'Qwen/Qwen2.5-7B-Instruct', - }, - { - id: 'openai', - nameKey: 'openai', - baseUrl: 'https://api.openai.com/v1', - modelPlaceholder: 'gpt-4o', - }, - { - // 谷歌官方 Gemini API(原生 generateContent,不走 OpenAI 兼容 shim)。 - // baseUrl 末尾 /v1beta 是当前 Generally Available 的 path(ai.google.dev/api)。 - // 后端 llm_gemini.rs 会拼成 `{baseUrl}/models/{model}:generateContent`, - // 并按 Gemini 原生通道级 thinkingConfig 关闭或压低思考,不在前端维护模型适配表。 - // 模型列表用 ProviderTools「拉取模型」按钮取, - // 由 commands.rs::fetch_provider_models 识别 generativelanguage 域名后按 Gemini shape 解析。 - id: 'gemini', - nameKey: 'gemini', - baseUrl: 'https://generativelanguage.googleapis.com/v1beta', - modelPlaceholder: 'gemini-2.5-flash', - }, - { - id: 'codex_oauth', - nameKey: 'codexOAuth', - baseUrl: '', - modelPlaceholder: 'gpt-5.3-codex-spark', - }, - { - id: 'mimo', - nameKey: 'mimo', - baseUrl: 'https://api.xiaomimimo.com/v1', - modelPlaceholder: 'xiaomi/mimo-v2-flash', - }, - { - id: 'cometapi', - nameKey: 'cometapi', - baseUrl: 'https://api.cometapi.com/v1', - modelPlaceholder: 'gpt-4o', - }, - { - id: 'openrouterFree', - nameKey: 'openrouterFree', - baseUrl: 'https://openrouter.ai/api/v1', - modelPlaceholder: 'qwen/qwen3-coder:free', - }, - { - id: 'alibabaCoding', - nameKey: 'alibabaCoding', - baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', - modelPlaceholder: 'qwen3-coder-plus', - }, - { - id: 'codingPlanX', - nameKey: 'codingPlanX', - baseUrl: 'https://api.codingplanx.ai/v1', - modelPlaceholder: 'gpt-5-mini', - }, - { - id: 'custom', - nameKey: 'custom', - baseUrl: '', - modelPlaceholder: '', - }, -] as const; - -type LlmPresetId = typeof LLM_PRESETS[number]['id']; - -const ASR_DEFAULT_RESOURCE_ID = 'volc.bigasr.sauc.duration'; - -// `volcengine` / `bailian` 走自建流式客户端;其余走 OpenAI 兼容 -// `/audio/transcriptions`(`coordinator.rs::is_whisper_compatible_provider`)。 -// 新增兼容厂商: -// 1. 在这里加一项 `{ id, nameKey, baseUrl, model }`; -// 2. `coordinator.rs::is_whisper_compatible_provider` 加同名 id; -// 3. 在 i18n 的 `settings.providers.presets.` 加文案。 -// `AsrPresetId` 定义在 settings/shared.ts,AdvancedSection / ProvidersSection 共用同一份。 -const ASR_PRESETS: ReadonlyArray<{ id: AsrPresetId; nameKey: string; baseUrl: string; model: string }> = [ - { id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' }, - { id: 'bailian', nameKey: 'asrBailian', baseUrl: 'wss://dashscope.aliyuncs.com/api-ws/v1/inference/', model: 'fun-asr-realtime' }, - { id: 'siliconflow', nameKey: 'asrSiliconflow', baseUrl: 'https://api.siliconflow.cn/v1', model: 'FunAudioLLM/SenseVoiceSmall' }, - { id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' }, - { id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' }, - { id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' }, - { id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' }, - // 本地 Qwen3-ASR:无 baseUrl/model 配置,模型在「模型设置」页下载与切换。 - { id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' }, -]; - -function ProvidersSection() { - const { t } = useTranslation(); - const { prefs, updatePrefs } = useHotkeySettings(); - // `*Provider` 立即跟随 立刻显示用户的选择(issue #220 P2:codex 指出受控选不应等 await) - // - CredentialField 不要在后端 active 切完前 remount(issue #219:避免读到旧 entry) - // `*SwitchSeq` 是 stale-write 守卫:用户 100ms 内连点两次时,先发的请求晚到不 - // 会覆盖后发的 commit。 - const [llmProvider, setLlmProvider] = useState('ark'); - const [asrProvider, setAsrProvider] = useState('volcengine'); - const [committedLlmProvider, setCommittedLlmProvider] = useState('ark'); - const [committedAsrProvider, setCommittedAsrProvider] = useState('volcengine'); - const llmSwitchSeqRef = useRef(0); - const asrSwitchSeqRef = useRef(0); - const [llmModelRevision, setLlmModelRevision] = useState(0); - const [asrModelRevision, setAsrModelRevision] = useState(0); - const os = detectOS(); - // 主 ASR 下拉只列云端选项;本地推理(local-qwen3 / foundry-local-whisper) - // 移到「高级」标签页,防止新手误开 CPU 推理。详见 AdvancedSection。 - const visibleAsrPresets = ASR_PRESETS.filter( - p => p.id !== 'foundry-local-whisper' && p.id !== 'local-qwen3', - ); - - useEffect(() => { - if (!prefs) return; - const knownLlm = LLM_PRESETS.find(x => x.id === prefs.activeLlmProvider); - const llmId = knownLlm ? knownLlm.id : 'custom'; - setLlmProvider(llmId); - setCommittedLlmProvider(llmId); - // ASR 在 ALL ASR_PRESETS 里查(不是 visibleAsrPresets)——本地选项虽然 - // 从下拉里藏起来了,但若用户曾在「高级」里启用过 local-qwen3,主 Card - // 仍要识别出 active 是本地,并切到「正在使用本地 ASR」的 notice 渲染。 - const knownAsr = ASR_PRESETS.find(x => x.id === prefs.activeAsrProvider); - const asrId = knownAsr ? knownAsr.id : 'volcengine'; - setAsrProvider(asrId); - setCommittedAsrProvider(asrId); - }, [prefs, os]); - - // issue #219 / #220 P2: - // 1. 立刻 setLlmProvider —— 受控 立刻切到新厂商,但凭据字段还在显示旧 entry,placeholder - // 会先于实际数据切换、视觉上对不上。 - const preset = LLM_PRESETS.find(p => p.id === committedLlmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1]; - const codexOAuthSelected = committedLlmProvider === 'codex_oauth'; - const asrPreset = visibleAsrPresets.find(p => p.id === committedAsrProvider); - return ( - <> -
- {t('settings.providers.credentialStorageNotice')} -
- -
- {t('settings.providers.llmTitle')} -
- {/* desc 已去掉——'选择后将自动填入 Base URL 默认值' 在 180px label 列必换行成两行, - 视觉上 label 区出现"字体单独占一行"。下拉自身已经表达了"切换"含义,desc 冗余。 */} - - onLlmProviderChange(next as LlmPresetId)} - options={LLM_PRESETS.map(p => ({ - value: p.id, - label: t(`settings.providers.presets.${p.nameKey}`), - }))} - ariaLabel={t('settings.providers.providerLabel')} - style={{ ...inputStyle, width: '100%', maxWidth: 200 }} - /> - - {codexOAuthSelected ? ( -
- {t('settings.providers.codexOAuthNotice')} -
- ) : ( - <> - - - - )} - - )} - /> - setLlmModelRevision(v => v + 1)} /> -
- - -
- {t('settings.providers.asrTitle')} -
- {/* 下拉只放云端选项;本地引擎激活时锁住 + 在下方放一行"ASR 提供商已被接管"提示, - 未激活时不显示提示。 */} - - {(() => { - const isLocked = - committedAsrProvider === 'local-qwen3' || - committedAsrProvider === 'foundry-local-whisper'; - const selectedValue: AsrPresetId = isLocked ? committedAsrProvider : asrProvider; - // 跨机器同步异常兜底:committed 是本地但不在 visibleAsrPresets 里时,受控 - // select 会回退到首项造成假象 —— 补一个 disabled option 让 select 找到当前值。 - const anomalousLocal: AsrPresetId | null = - isLocked && !visibleAsrPresets.some(p => p.id === committedAsrProvider) - ? committedAsrProvider - : null; - const anomalousNameKey = anomalousLocal === 'local-qwen3' - ? 'asrLocalQwen3' - : anomalousLocal === 'foundry-local-whisper' - ? 'asrFoundryLocalWhisper' - : null; - return ( -
- onAsrProviderChange(next as AsrPresetId)} - options={[ - ...visibleAsrPresets.map(p => ({ - value: p.id, - label: t(`settings.providers.presets.${p.nameKey}`), - })), - ...(anomalousLocal && anomalousNameKey - ? [{ - value: anomalousLocal, - label: t(`settings.providers.presets.${anomalousNameKey}`), - disabled: true, - }] - : []), - ]} - ariaLabel={t('settings.providers.providerLabel')} - style={{ ...inputStyle, width: '100%', maxWidth: 200 }} - /> - {isLocked && ( -
- {t('settings.providers.asrProviderTakenOver')} -
- )} -
- ); - })()} -
- {committedAsrProvider === 'volcengine' ? ( - <> - - - -
- {t('settings.providers.volcengineMappingNote')} -
- - ) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' ? ( - // 用户已经在用本地 ASR——dropdown 行的 localAsrActiveNotice 已经把 - // "在高级中切换或禁用"讲清楚了,body 不再重复 LocalAsrProviderHint。 - // 模型管理 UI 唯一入口在 AdvancedSection 里的 。 - null - ) : ( - <> - - - - {committedAsrProvider === 'bailian' && ( - <> - -
- {t('settings.providers.bailianVocabularyIdNote')} -
- - )} - setAsrModelRevision(v => v + 1)} /> - - )} -
- - ); -} - - -type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; - -function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | 'asr'; modelAccount: string; onModelSelected: () => void }) { - const { t } = useTranslation(); - const [models, setModels] = useState([]); - const [selectedModel, setSelectedModel] = useState(''); - const [status, setStatus] = useState('idle'); - const [message, setMessage] = useState(''); - - const setResult = (next: ProviderToolStatus, nextMessage: string) => { - setStatus(next); - setMessage(nextMessage); - }; - - const validate = async () => { - setModels([]); - setSelectedModel(''); - setResult('loading', t('settings.providers.validating')); - try { - const result = await validateProviderCredentials(kind); - setResult(result.ok ? 'success' : 'error', t('settings.providers.validateSuccess')); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if ((kind === 'llm' && message === 'llmModelMissing') || (kind === 'asr' && message === 'asrModelMissing')) { - setResult('empty', t('settings.providers.modelMissing')); - return; - } - if (message === 'modelsEmpty') { - setResult('empty', t('settings.providers.modelsEmpty')); - return; - } - setResult('error', providerErrorMessage(error, t)); - } - }; - - const loadModels = async () => { - setResult('loading', t('settings.providers.loadingModels')); - try { - const result = await listProviderModels(kind); - setModels(result.models); - if (result.models.length === 0) { - setResult('empty', t('settings.providers.modelsEmpty')); - } else { - setSelectedModel(''); - setResult('success', t('settings.providers.modelsLoaded', { count: result.models.length })); - } - } catch (error) { - setModels([]); - setResult('error', providerErrorMessage(error, t)); - } - }; - - const applyModel = async (model: string) => { - setResult('loading', t('common.saving')); - try { - await setCredential(modelAccount, model); - setSelectedModel(model); - onModelSelected(); - setResult('success', t('settings.providers.modelSaved', { model })); - } catch (error) { - setResult('error', providerErrorMessage(error, t)); - } - }; - - return ( - -
-
- - - {models.length > 0 && ( - ({ value: model, label: model }))} - placeholder={t('settings.providers.selectModel')} - ariaLabel={t('settings.providers.selectModel')} - style={{ ...inputStyle, maxWidth: 220 }} - /> - )} -
- {message && ( - - {message} - - )} -
-
- ); -} - - -function providerErrorMessage(error: unknown, t: ReturnType['t']): string { - const message = error instanceof Error ? error.message : String(error); - if (message.startsWith('providerHttpStatus:')) { - return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' }); - } - if (message === 'endpointMustUseHttps') return t('settings.providers.endpointMustUseHttps'); - if (message === 'endpointInvalid') return t('settings.providers.endpointInvalid'); - if (message === 'providerResponseTooLarge') return t('settings.providers.responseTooLarge'); - if (message === 'asrInvalidJson') return t('settings.providers.asrInvalidJson'); - if (message === 'asrMissingTextField') return t('settings.providers.asrMissingTextField'); - if (message === 'providerNetworkError') return t('common.networkError'); - if (message === 'providerReadResponseFailed' || message === 'providerClientInitFailed') return t('common.operationFailed'); - if (message === 'providerRequestTimeout') return t('settings.providers.requestTimeout'); - if (message.includes('API Key')) return t('settings.providers.apiKeyMissing'); - if (message.includes('Endpoint')) return t('settings.providers.endpointMissing'); - if (message.includes('timeout') || message.includes('超时')) return t('settings.providers.requestTimeout'); - return t('common.operationFailed'); -} - -type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError'; - -interface CredentialFieldProps { - label: string; - account: string; - placeholder?: string; - mono?: boolean; - mask?: boolean; - defaultValue?: string; - trailing?: ReactNode; -} - -function CredentialField({ label, account, placeholder, mono, mask, defaultValue, trailing }: CredentialFieldProps) { - const { t } = useTranslation(); - const [value, setValue] = useState(''); - const [revealed, setRevealed] = useState(false); - const [loaded, setLoaded] = useState(false); - const [dirty, setDirty] = useState(false); - const [status, setStatus] = useState('idle'); - const debounceRef = useRef(null); - const statusRef = useRef(null); - - useEffect(() => { - let cancelled = false; - setLoaded(false); - setDirty(false); - setStatus('idle'); - setValue(''); - if (debounceRef.current) { - clearTimeout(debounceRef.current); - debounceRef.current = null; - } - readCredential(account) - .then(v => { - if (cancelled) return; - setValue(v ?? ''); - setLoaded(true); - }) - .catch(error => { - if (cancelled) return; - console.error('[settings] failed to read credential', account, error); - setLoaded(true); - setStatus('readError'); - }); - return () => { - cancelled = true; - }; - }, [account]); - - useEffect(() => { - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - if (statusRef.current) clearTimeout(statusRef.current); - }; - }, []); - - // 改造:除 readError(持续错误,留在输入旁标识字段不可用)外,所有 saving / saved / - // saveError / copied / copyError 一律发到右上角 SavedToast。原内联文案太挤、跟其它 - // 页面 toast 风格不统一。 - const showTemporaryStatus = (next: CredentialFieldStatus) => { - if (next === 'saving') { - emitSaved('saving', t('common.saving')); - } else if (next === 'saved') { - emitSaved('saved', t('common.saved')); - } else if (next === 'saveError') { - emitSaved('failed', t('common.operationFailed')); - } else if (next === 'copied') { - emitSaved('saved', t('common.copied')); - } else if (next === 'copyError') { - emitSaved('failed', t('common.operationFailed')); - } - setStatus(next); - if (statusRef.current) clearTimeout(statusRef.current); - statusRef.current = window.setTimeout(() => setStatus('idle'), 1600); - }; - - const save = async (v: string, force = false) => { - if (!loaded || (!dirty && !force)) return; - setStatus('saving'); - emitSaved('saving', t('common.saving')); - try { - await setCredential(account, v); - setDirty(false); - showTemporaryStatus('saved'); - } catch (error) { - console.error('[settings] failed to save credential', account, error); - showTemporaryStatus('saveError'); - } - }; - - const handleChange = (e: React.ChangeEvent) => { - const v = e.target.value; - setValue(v); - if (!loaded) return; - setDirty(true); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = window.setTimeout(() => save(v, true), 300); - }; - - const onBlur = () => { - if (!loaded || !dirty) return; - if (debounceRef.current) { - clearTimeout(debounceRef.current); - debounceRef.current = null; - } - save(value, true); - }; - - const fillDefault = async () => { - if (!loaded || !defaultValue) return; - setValue(defaultValue); - setDirty(true); - await save(defaultValue, true); - }; - - const onCopy = async () => { - if (!value || !loaded) return; - try { - if (!navigator.clipboard?.writeText) { - throw new Error('Clipboard API unavailable'); - } - await navigator.clipboard.writeText(value); - showTemporaryStatus('copied'); - } catch (error) { - console.error('[settings] failed to copy credential', account, error); - showTemporaryStatus('copyError'); - } - }; - - const inputType = mask && !revealed ? 'password' : 'text'; - const disabled = !loaded; - - return ( - -
- - {defaultValue && !value && loaded && ( - - )} - {trailing} - {mask && ( - - )} - - {/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用; - 其它瞬态状态(saving / saved / saveError / copied / copyError)都通过 - emitSaved 发到右上角统一 toast,不再内联占位。 */} - {status === 'readError' && ( - - {t('settings.providers.readFailed')} - - )} -
-
- ); -} - -const miniBtnStyle: CSSProperties = { - height: 32, padding: '0 12px', - border: '0.5px solid var(--ol-line-strong)', - borderRadius: 8, background: 'var(--ol-surface)', - boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset', - color: 'var(--ol-ink-2)', cursor: 'default', flexShrink: 0, - fontSize: 12.5, fontWeight: 500, letterSpacing: '0.01em', - transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.16s var(--ol-motion-quick)', -}; - -const recordingHotkeyControlWidth = 178; - -const hotkeyRecorderButtonStyle: CSSProperties = { - width: recordingHotkeyControlWidth, - height: 32, - padding: '0 8px 0 12px', - border: '0.5px solid var(--ol-line-strong)', - borderRadius: 8, - background: 'var(--ol-surface)', - boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset', - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: 8, - fontFamily: 'var(--ol-font-mono)', - fontSize: 12.5, - cursor: 'default', - transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', -}; - -const recordingHotkeySegmentedStyle: CSSProperties = { - width: recordingHotkeyControlWidth, - display: 'inline-flex', - padding: 3, - borderRadius: 8, - background: 'rgba(0,0,0,0.06)', - boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.04)', -}; - -const recordingHotkeyGroupStyle: CSSProperties = { - display: 'grid', - gridTemplateColumns: 'auto', - rowGap: 12, - justifyItems: 'start', -}; - -const recordingHotkeyLineStyle: CSSProperties = { - display: 'grid', - gridTemplateColumns: '64px auto', - alignItems: 'center', - columnGap: 12, -}; - -const recordingHotkeyFieldLabelStyle: CSSProperties = { - fontSize: 12.5, - color: 'var(--ol-ink-4)', - textAlign: 'right', - whiteSpace: 'nowrap', -}; - -const recordingHotkeyStatusStyle: CSSProperties = { - marginLeft: 76, - fontSize: 12, - lineHeight: 1.4, - color: 'var(--ol-ink-3)', -}; - -const hotkeyRecorderLabelStyle: CSSProperties = { - minWidth: 0, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}; - -const hotkeyClearButtonStyle: CSSProperties = { - width: 18, - height: 18, - borderRadius: 999, - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - background: 'rgba(0,0,0,0.15)', - color: '#fff', - transition: 'background 0.16s ease', -}; - -const iconBtnStyle: CSSProperties = { - width: 32, height: 32, - border: '0.5px solid var(--ol-line-strong)', - borderRadius: 8, background: 'var(--ol-surface)', - boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset', - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - color: 'var(--ol-ink-3)', cursor: 'default', flexShrink: 0, - transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)', -}; - - - - - - -/// 本地 Qwen3-ASR 在 Settings → 服务商区里**不**让用户填空——展示当前激活模型 -/// 是否已下载、列出所有已下载模型 + 删除按钮,并提示性能/质量预期,引导跳到 -/// 「模型设置」页做下载。 -function LocalAsrProviderHint({ - provider, - selectedProvider, -}: { - provider: 'local-qwen3' | 'foundry-local-whisper'; - selectedProvider: AsrPresetId; -}) { - const { t } = useTranslation(); - const [settings, setSettings] = useState(null); - const [models, setModels] = useState([]); - const [loading, setLoading] = useState(true); - const [deletingId, setDeletingId] = useState(null); - const refreshSeqRef = useRef(0); - const providerStateRef = useRef({ provider, selectedProvider }); - providerStateRef.current = { provider, selectedProvider }; - - const qwenReadyForFetch = () => { - const state = providerStateRef.current; - return state.provider === 'local-qwen3' && state.selectedProvider === 'local-qwen3'; - }; - - const refresh = async (seq: number) => { - try { - const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]); - if (seq !== refreshSeqRef.current) { - return; - } - setSettings(s); - setModels(list); - } catch (err) { - if (seq !== refreshSeqRef.current) { - return; - } - console.warn('[settings] load local asr status failed', err); - } finally { - if (seq === refreshSeqRef.current) { - setLoading(false); - } - } - }; - - const beginRefresh = () => { - const seq = ++refreshSeqRef.current; - setSettings(null); - setModels([]); - setDeletingId(null); - if (provider !== selectedProvider) { - setLoading(true); - return; - } - if (provider === 'foundry-local-whisper') { - setLoading(false); - return; - } - setLoading(true); - void refresh(seq); - }; - - useEffect(() => { - beginRefresh(); - return () => { - refreshSeqRef.current += 1; - }; - }, [provider, selectedProvider]); - - const goToLocalAsr = () => { - window.dispatchEvent(new CustomEvent(NAVIGATE_LOCAL_ASR_EVENT)); - }; - - const handleDelete = async (modelId: string) => { - const seq = refreshSeqRef.current; - if (!qwenReadyForFetch()) { - return; - } - setDeletingId(modelId); - try { - await deleteLocalAsrModel(modelId); - if (seq !== refreshSeqRef.current || !qwenReadyForFetch()) { - return; - } - beginRefresh(); - } catch (err) { - console.warn('[settings] delete local model failed', err); - } finally { - if (seq === refreshSeqRef.current && provider === 'local-qwen3') { - setDeletingId(null); - } - } - }; - - const hintKey = provider === 'foundry-local-whisper' - ? 'settings.providers.foundryLocalAsrHint' - : 'settings.providers.localAsrHint'; - - if (loading) { - return ( -
- {t('common.loading')} -
- ); - } - - const active = models.find(m => m.id === settings?.activeModel); - const isReady = active?.isDownloaded ?? false; - const downloaded = models.filter(m => m.isDownloaded); - - if (provider === 'foundry-local-whisper') { - return ( -
-
- {t(hintKey)} -
-
- - {t('settings.providers.localAsrManage')} - -
-
- ); - } - - return ( -
- {/* 性能/质量预期警告 —— 用户硬要求要写清楚 */} -
- ⚠️ {t('settings.providers.localAsrPerformanceWarning')} -
- -
- {t(hintKey)} -
- - {/* 当前激活模型状态 + 跳转按钮 */} -
- - {isReady - ? t('settings.providers.localAsrReady', { model: active?.id ?? '' }) - : t('settings.providers.localAsrNotReady', { model: settings?.activeModel ?? '' })} - - - {isReady - ? t('settings.providers.localAsrManage') - : t('settings.providers.localAsrGoDownload')} - -
- - {/* 已下载模型列表 + 删除按钮(用户:已下载的项目要在旁边显示 + 提供删除) */} - {downloaded.length > 0 && ( -
-
- {t('settings.providers.localAsrDownloadedTitle')} -
- {downloaded.map(m => ( -
-
- {m.id} - - {formatBytes(m.downloadedBytes)} - -
- void handleDelete(m.id)}> - {t('settings.providers.localAsrDelete')} - -
- ))} -
- )} -
- ); -} - -function formatBytes(n: number): string { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB`; - return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; -} diff --git a/openless-all/app/src/pages/settings/AboutSection.tsx b/openless-all/app/src/pages/settings/AboutSection.tsx new file mode 100644 index 00000000..2ff35479 --- /dev/null +++ b/openless-all/app/src/pages/settings/AboutSection.tsx @@ -0,0 +1,151 @@ +// 关于 → 版本信息 / 检查更新 / 字体大小 / 文档链接。 +// 「个性化」原本是独立 tab,但只剩字体大小一项、整页太空,遂并入「关于」。 +// 「加入 Beta 渠道」已挪到「高级」页底部(见 BetaChannelSection),这里图标旁 +// 只保留查正式版的「检查更新」按钮。 + +import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from '../../components/Icon'; +import { Row } from '../../components/ui/Row'; +import { openExternal } from '../../lib/ipc'; +import { APP_VERSION_LABEL } from '../../lib/appVersion'; +import { readFontScale, setFontScale, type FontScaleId } from '../../lib/fontScale'; +import { Card } from '../_atoms'; +import { SectionTitle } from './shared'; +import { CheckUpdateButton } from './CheckUpdateButton'; + +export function AboutSection() { + const { t } = useTranslation(); + const [qqCopied, setQqCopied] = useState(false); + const qqCopiedRef = useRef(null); + + useEffect(() => () => { + if (qqCopiedRef.current) clearTimeout(qqCopiedRef.current); + }, []); + + const copyQq = () => { + navigator.clipboard?.writeText('1078960553'); + setQqCopied(true); + if (qqCopiedRef.current) clearTimeout(qqCopiedRef.current); + qqCopiedRef.current = window.setTimeout(() => setQqCopied(false), 1500); + }; + + return ( + <> + {/* ─── 版本信息 + 检查更新(正式版)─────────────────────────────── */} + +
+ +
+
OpenLess
+
+ {t('modal.about.tagline')} · {APP_VERSION_LABEL} +
+
+ {/* 图标右上方:查正式版的检查更新按钮。Beta 渠道在「高级」页。 */} + +
+
+ + {/* ─── 个性化(字体大小)—— 原 personalize tab 并入此处 ──────────── */} + + {t('modal.sections.personalize')} + + + + {/* ─── 文档链接 ─────────────────────────────────────────────── */} + + {t('settings.about.linksTitle')} + + + + + + + + + + +
+ 1078960553 + + {qqCopied && {t('common.copied')}} +
+
+
+ + ); +} + +// 字体大小 —— 整体缩放界面字号,立即生效(fontScale.ts 走 html.style.zoom)。 +function FontSizeRow() { + const { t } = useTranslation(); + const [fontScale, setFontScaleState] = useState(() => readFontScale()); + const applyFontScaleChoice = (next: FontScaleId) => { + setFontScaleState(next); + setFontScale(next); + }; + const fontOptions: Array<[FontScaleId, string]> = [ + ['small', t('modal.personalize.fontSmall')], + ['medium', t('modal.personalize.fontMedium')], + ['large', t('modal.personalize.fontLarge')], + ]; + return ( + +
+ {fontOptions.map(([id, label]) => { + const selected = fontScale === id; + return ( + + ); + })} +
+
+ ); +} + +const btnGhost: CSSProperties = { + padding: '5px 10px', fontSize: 12, borderRadius: 6, + border: '0.5px solid var(--ol-line-strong)', + background: '#fff', color: 'var(--ol-ink-2)', + cursor: 'default', fontFamily: 'inherit', + transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)', +}; diff --git a/openless-all/app/src/pages/settings/AboutUpdateControl.tsx b/openless-all/app/src/pages/settings/AboutUpdateControl.tsx deleted file mode 100644 index e2fe75d5..00000000 --- a/openless-all/app/src/pages/settings/AboutUpdateControl.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// 关于面板里嵌入的"检查更新"控件。 -// 注:原 Settings 内的 About tab 已并入 SettingsModal 的 AboutMini;这里只是版本号 + 检查按钮。 - -import { useTranslation } from 'react-i18next'; -import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../../components/AutoUpdate'; -import { APP_VERSION_LABEL } from '../../lib/appVersion'; -import { Btn } from '../_atoms'; - -export function AboutUpdateControl({ tagline }: { tagline: string }) { - const { t } = useTranslation(); - const u = useAutoUpdate(); - return ( - <> -
- {tagline} 路 {APP_VERSION_LABEL} - - {u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')} - -
- {u.status === 'none' && ( -
- {t('settings.about.upToDate')} -
- )} - {u.status === 'error' && ( -
-
- {t('settings.about.updateError')} -
- {u.errorMessage && ( -
- {u.errorMessage} -
- )} - - {t('settings.about.retryBtn') ?? t('common.retry') ?? '重试'} - -
- )} - {isDialogStatus(u.status) && ( - - )} - - ); -} diff --git a/openless-all/app/src/pages/settings/AdvancedSection.tsx b/openless-all/app/src/pages/settings/AdvancedSection.tsx deleted file mode 100644 index 0ffeb6ae..00000000 --- a/openless-all/app/src/pages/settings/AdvancedSection.tsx +++ /dev/null @@ -1,441 +0,0 @@ -// 高级设置:流式输入开关 / 同步剪贴板 / 本地 ASR 模型启用与禁用。 -// 拆出自 Settings.tsx,逻辑零改动;i18n key 全部保持 `settings.advanced.*`。 - -import { useRef, useState } from "react" -import { useTranslation } from "react-i18next" -import { LocalAsr } from "../LocalAsr" -import { detectOS } from "../../components/WindowChrome" -import { setActiveAsrProvider } from "../../lib/ipc" -import { useHotkeySettings } from "../../state/HotkeySettingsContext" -import { Btn, Card } from "../_atoms" -import { SettingRow, Toggle, type AsrPresetId } from "./shared" - -export function AdvancedSection() { - const { t } = useTranslation() - const { prefs, updatePrefs } = useHotkeySettings() - const os = detectOS() - const isMac = os === "mac" - const isWin = os === "win" - const isLinux = os === "linux" - const platformSupported = isMac || isWin - const switchSeqRef = useRef(0) - const [busy, setBusy] = useState(false) - // 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切; - // 点取消 → 回到 null。一次只允许一个 modal。 - const [pendingTarget, setPendingTarget] = useState(null) - - const activeAsrProvider = (prefs?.activeAsrProvider ?? - "volcengine") as AsrPresetId - const isOnLocalQwen3 = activeAsrProvider === "local-qwen3" - const isOnFoundry = activeAsrProvider === "foundry-local-whisper" - const isOnSherpaOnnx = activeAsrProvider === "sherpa-onnx-local" - const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry || isOnSherpaOnnx - - const requestEnable = (target: AsrPresetId) => { - setPendingTarget(target) - } - - const performSwitch = async (target: AsrPresetId) => { - setBusy(true) - const seq = ++switchSeqRef.current - try { - await setActiveAsrProvider(target) - if (seq !== switchSeqRef.current) return - if (prefs) { - await updatePrefs({ ...prefs, activeAsrProvider: target }) - } - } finally { - if (seq === switchSeqRef.current) { - setBusy(false) - setPendingTarget(null) - } - } - } - - const pendingNameKey = - pendingTarget === "local-qwen3" - ? "asrLocalQwen3" - : pendingTarget === "foundry-local-whisper" - ? "asrFoundryLocalWhisper" - : pendingTarget === "sherpa-onnx-local" - ? "asrSherpaOnnxLocal" - : null - - return ( - <> - {/* ─── 屏幕中央确认 modal(背景模糊) ───────────────────────────── - 点击遮罩或取消按钮关闭;切换中(busy)禁止任何关闭路径以免半切失败。 */} - {pendingTarget && pendingNameKey && ( -
{ - if (e.target === e.currentTarget && !busy) - setPendingTarget(null) - }} - > - -
- ⚠️ {t("settings.advanced.confirmEnableLocalTitle")} -
-
- {t("settings.advanced.confirmEnableLocalBody", { - target: t( - `settings.providers.presets.${pendingNameKey}`, - ), - })} -
-
- setPendingTarget(null)} - > - {t("common.cancel")} - - - void performSwitch(pendingTarget) - } - > - {t("settings.advanced.confirm")} - -
-
-
- )} - - {/* ─── 流式输入(全平台 opt-in) ─────────────────────────────────── - 润色 SSE 一边到达一边逐字模拟键盘事件落到光标。开启后用户感知到的处理 - 时延显著降低,但有几个限制(不满足时自动回落原一次性插入路径): - - macOS:CGEvent Unicode + 临时切到 ABC 输入源(CJK / 日文 IME 拦截兜底) - - Windows:SendInput Unicode,绕过 TSF / IME,不需要切输入法 - - Linux:通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键 - - 仅 OpenAI-compatible provider 实装;Gemini / Codex 透明降级 - - 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落 - 每个平台用各自的 hint key,互相不显示对方平台的细节。 */} - -
- {t( - isLinux - ? "settings.advanced.streamingInsertTitleLinux" - : "settings.advanced.streamingInsertTitle", - )} -
-
- {t("settings.advanced.streamingInsertDesc")} -
- - { - if (prefs) - void updatePrefs({ - ...prefs, - streamingInsert: next, - }) - }} - /> - - - { - if (prefs) - void updatePrefs({ - ...prefs, - streamingInsertSaveClipboard: next, - }) - }} - /> - -
- - - {/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。 - Windows:标题区整体灰显 —— "本地 ASR 模型(实验性)" 在 Win 上几乎只有 - Qwen3 占位、本平台暂不支持;Foundry 走的是另一条独立路径,不属于"实验性" - 框架。灰显视觉让用户知道这条"实验性"主线在 Win 不可用,关注点转到下方 - Foundry 行。 */} -
-
-
- {t("settings.advanced.localAsrTitle")} -
-
- {t("settings.advanced.localAsrDesc")} -
-
-
- ⚠️ {t("settings.advanced.localAsrWarningShort")} -
-
- - {!platformSupported ? ( -
- {t("settings.advanced.platformNotSupported")} -
- ) : ( - <> - {/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off - + 不可点 + desc=notSupportedHere,跟"本平台不可用"视觉一致。跨平台 - 异常(Windows profile 同步到 local-qwen3)时 active 状态靠下方独立 - "禁用本地 ASR" 行兜底,避免 Toggle ON + desc 说不支持的自相矛盾感 - (pr_agent #403 'Stale Windows state' 修法)。 - Windows 整行灰显,跟"本地 ASR 实验性"标题区视觉对齐 —— 用户一眼看出 - 这条线在 Win 上不能用,关注点落到下方 Foundry 行。 */} -
- -
- { - if (next) - requestEnable( - "local-qwen3", - ) - else - void performSwitch( - "volcengine", - ) - } - : undefined - } - /> -
-
-
- - {/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */} - {isWin && ( - <> - -
- { - if (next) - requestEnable( - "foundry-local-whisper", - ) - else - void performSwitch( - "volcengine", - ) - } - : undefined - } - /> -
-
- -
- { - if (next) - requestEnable( - "sherpa-onnx-local", - ) - else - void performSwitch( - "volcengine", - ) - } - : undefined - } - /> -
-
- - )} - - )} - - {/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出: - - Linux / 不支持平台:根本没有任何引擎行 - - 跨平台异常(macOS profile 同步到 foundry / Windows profile 同步到 qwen3): - 本机引擎 Toggle 是 off,关不动异常 active 的对方引擎 - 否则平台本机 Toggle 自身就能 off → 关停,重复 disable 行徒增视觉。 */} - {isOnAnyLocal && - !( - (isMac && isOnLocalQwen3) || - (isWin && (isOnFoundry || isOnSherpaOnnx)) - ) && ( - -
- - void performSwitch("volcengine") - } - > - {t("settings.advanced.disable")} - -
-
- )} -
- - {/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local) - inline 渲染——「模型设置」独立页已删,这里是唯一入口。 */} - {platformSupported && } - - ) -} diff --git a/openless-all/app/src/pages/settings/BetaChannelSection.tsx b/openless-all/app/src/pages/settings/BetaChannelSection.tsx new file mode 100644 index 00000000..cbf40c83 --- /dev/null +++ b/openless-all/app/src/pages/settings/BetaChannelSection.tsx @@ -0,0 +1,51 @@ +// 高级 → 加入 Beta 渠道。单独成一节,固定放在「高级」页最下面。 +// +// 打开后写 prefs.update_channel='beta':后台 AutoUpdateGate 自动更新随之走 Beta, +// 同时本节出现「检查更新」按钮 —— 手动查测试版更新(CheckUpdateButton channel='beta')。 +// 关于页的检查更新按钮固定查正式版(channel='stable'),两者互不影响。 + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getUpdateChannel, setUpdateChannel, type UpdateChannel } from '../../lib/ipc'; +import { Card } from '../_atoms'; +import { SectionTitle, Toggle } from './shared'; +import { CheckUpdateButton } from './CheckUpdateButton'; + +export function BetaChannelSection() { + const { t } = useTranslation(); + const [channel, setChannel] = useState('stable'); + + useEffect(() => { + let cancelled = false; + void getUpdateChannel() + .then(c => { if (!cancelled) setChannel(c); }) + .catch(() => { /* fall back to stable already in initial state */ }); + return () => { cancelled = true; }; + }, []); + + const onToggle = async (next: boolean) => { + const target: UpdateChannel = next ? 'beta' : 'stable'; + setChannel(target); + try { + await setUpdateChannel(target); + } catch { + // 写入失败时回滚 UI,免得用户以为切成功了。 + setChannel(target === 'beta' ? 'stable' : 'beta'); + } + }; + + return ( + + {t('settings.about.betaChannelLabel')} +
+ + {t('settings.about.betaChannelDesc')} + +
+ {channel === 'beta' && } + +
+
+
+ ); +} diff --git a/openless-all/app/src/pages/settings/CheckUpdateButton.tsx b/openless-all/app/src/pages/settings/CheckUpdateButton.tsx new file mode 100644 index 00000000..23ab8ec5 --- /dev/null +++ b/openless-all/app/src/pages/settings/CheckUpdateButton.tsx @@ -0,0 +1,80 @@ +// 检查更新按钮 —— 关于页查正式版(channel='stable')、高级页 Beta 区查测试版 +// (channel='beta'),共用此组件。 +// +// 检查中:按钮内图标转圈。结果(已是最新 / 失败)只在按钮内以图标 + 颜色短暂 +// 呈现 2.5s 后自动回到 idle,绝不另起文字块、不改变所在卡片高度 —— 杜绝 +// 「渲染框突然变大 / 抽搐」。发现新版则弹出固定定位的 UpdateDialog。 + +import { useEffect, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from '../../components/Icon'; +import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../../components/AutoUpdate'; +import type { UpdateChannel } from '../../lib/ipc'; + +export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) { + const { t } = useTranslation(); + const updater = useAutoUpdate(); + const { status, checking, busy } = updater; + + useEffect(() => { + if (status === 'none' || status === 'error') { + const id = window.setTimeout(() => { void updater.dismissDialog(); }, 2500); + return () => window.clearTimeout(id); + } + return undefined; + // 只按 status 触发:useAutoUpdate 每次渲染都返回新 updater 对象,把它放进 + // 依赖会让父组件每次重渲染都把 2.5s 自动收起计时器清掉重置。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status]); + + const upToDate = status === 'none'; + const failed = status === 'error'; + const iconName = upToDate ? 'check' : 'refresh'; + const color = upToDate ? 'var(--ol-ok)' : failed ? 'var(--ol-err)' : 'var(--ol-ink-2)'; + const label = checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn'); + + return ( + <> + + {isDialogStatus(status) && ( + void updater.installUpdate()} + onClose={() => void updater.dismissDialog()} + /> + )} + + ); +} + +const checkBtnStyle: CSSProperties = { + padding: '5px 10px', fontSize: 12, borderRadius: 6, + border: '0.5px solid var(--ol-line-strong)', + background: '#fff', + cursor: 'default', fontFamily: 'inherit', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, + minWidth: 84, + transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', +}; diff --git a/openless-all/app/src/pages/settings/DataStorageSection.tsx b/openless-all/app/src/pages/settings/DataStorageSection.tsx new file mode 100644 index 00000000..5727a747 --- /dev/null +++ b/openless-all/app/src/pages/settings/DataStorageSection.tsx @@ -0,0 +1,84 @@ +// 隐私 → 数据存储:本地保留的历史会话与对话上下文窗口。 +// 自 Settings.tsx 的 RecordingSection「历史与上下文」折叠组拆出,逻辑零改动。 + +import { useTranslation } from 'react-i18next'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Card } from '../_atoms'; +import { SettingRow, SectionTitle, inputStyle } from './shared'; + +// 范围限制:retention 0-365 天,context window 0-60 分钟(再大对实际对话场景没意义且白烧 token)。 +const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); + +export function DataStorageSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); + + if (!prefs) { + return ( + +
{t('common.loading')}
+
+ ); + } + + // 空字符串时回滚到默认值。 + const onHistoryRetentionChange = (raw: string) => { + const parsed = raw === '' ? 0 : Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) return; + void savePrefs({ ...prefs, historyRetentionDays: clamp(parsed, 0, 365) }); + }; + const onPolishContextWindowChange = (raw: string) => { + const parsed = raw === '' ? 0 : Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) return; + void savePrefs({ ...prefs, polishContextWindowMinutes: clamp(parsed, 0, 60) }); + }; + // 历史条数 200 是当前 HISTORY_CAP(persistence.rs:32),下限 5 是避免用户填 0 导致 + // 写一条就立刻被清光;空字符串视为不限制,落回 null → 后端走 200 默认。 + const onHistoryMaxEntriesChange = (raw: string) => { + const trimmed = raw.trim(); + if (trimmed === '') { + void savePrefs({ ...prefs, historyMaxEntries: null }); + return; + } + const parsed = Number.parseInt(trimmed, 10); + if (Number.isNaN(parsed)) return; + void savePrefs({ ...prefs, historyMaxEntries: clamp(parsed, 5, 200) }); + }; + + return ( + + {t('settings.dataStorage.title')} + + onHistoryRetentionChange(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: 'right' }} + /> + + + onHistoryMaxEntriesChange(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: 'right' }} + /> + + + onPolishContextWindowChange(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: 'right' }} + /> + + + ); +} diff --git a/openless-all/app/src/pages/settings/DebugToolsSection.tsx b/openless-all/app/src/pages/settings/DebugToolsSection.tsx new file mode 100644 index 00000000..c7bbc420 --- /dev/null +++ b/openless-all/app/src/pages/settings/DebugToolsSection.tsx @@ -0,0 +1,110 @@ +// 高级 → 调试工具:保留原始录音、导出错误日志等排障入口。 +// recordAudioForDebug 行自 Settings.tsx 的 RecordingSection 拆出; +// 导出错误日志自 SettingsModal 的 AboutMini 迁入 —— 调试相关集中到此处。 + +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { exportErrorLog } from '../../lib/ipc'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Btn, Card } from '../_atoms'; +import { SettingRow, Toggle, SectionTitle, inputStyle } from './shared'; + +const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); + +export function DebugToolsSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); + const [exportStatus, setExportStatus] = useState<'idle' | 'busy' | 'ok' | 'err'>('idle'); + const [exportMessage, setExportMessage] = useState(''); + const exportTimerRef = useRef(null); + + useEffect(() => () => { + if (exportTimerRef.current) clearTimeout(exportTimerRef.current); + }, []); + + const onExportLog = async () => { + setExportStatus('busy'); + setExportMessage(''); + try { + const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const target = await exportErrorLog(`openless-${ts}.log`); + if (target == null) { + setExportStatus('idle'); + return; + } + setExportStatus('ok'); + setExportMessage(target); + if (exportTimerRef.current) clearTimeout(exportTimerRef.current); + exportTimerRef.current = window.setTimeout(() => setExportStatus('idle'), 4000); + } catch (err) { + setExportStatus('err'); + setExportMessage(err instanceof Error ? err.message : String(err)); + } + }; + + if (!prefs) { + return ( + +
{t('common.loading')}
+
+ ); + } + + const onRecordAudioForDebugChange = (recordAudioForDebug: boolean) => + savePrefs({ ...prefs, recordAudioForDebug }); + // 留空视为不限制,落回 null → 后端走 200 默认。 + const onAudioRecordingMaxEntriesChange = (raw: string) => { + const trimmed = raw.trim(); + if (trimmed === '') { + void savePrefs({ ...prefs, audioRecordingMaxEntries: null }); + return; + } + const parsed = Number.parseInt(trimmed, 10); + if (Number.isNaN(parsed)) return; + void savePrefs({ ...prefs, audioRecordingMaxEntries: clamp(parsed, 1, 200) }); + }; + + return ( + + {t('settings.debug.title')} + + + + + onAudioRecordingMaxEntriesChange(e.target.value)} + style={{ ...inputStyle, width: 80, textAlign: 'right' }} + disabled={!prefs.recordAudioForDebug} + /> + + +
+ + {exportStatus === 'busy' ? t('modal.about.exporting') : t('modal.about.exportErrorLogBtn')} + + {exportStatus === 'ok' && ( + + {t('modal.about.exportSuccess')} + + )} + {exportStatus === 'err' && ( + + {t('modal.about.exportFailed')} + + )} +
+
+
+ ); +} diff --git a/openless-all/app/src/pages/settings/LanguageSection.tsx b/openless-all/app/src/pages/settings/LanguageSection.tsx index 89e91bf5..4de9a880 100644 --- a/openless-all/app/src/pages/settings/LanguageSection.tsx +++ b/openless-all/app/src/pages/settings/LanguageSection.tsx @@ -46,9 +46,8 @@ export function LanguageSection() { return ( -
{t('settings.language.title')}
-
{t('settings.language.desc')}
- +
{t('settings.language.title')}
+ apply(next as SupportedLocale | typeof FOLLOW_SYSTEM)} @@ -57,9 +56,6 @@ export function LanguageSection() { style={{ maxWidth: 220, minWidth: 200 }} /> -
- {t('settings.language.restartHint')} -
); } diff --git a/openless-all/app/src/pages/settings/LocalModelSection.tsx b/openless-all/app/src/pages/settings/LocalModelSection.tsx new file mode 100644 index 00000000..222cfbde --- /dev/null +++ b/openless-all/app/src/pages/settings/LocalModelSection.tsx @@ -0,0 +1,219 @@ +// 高级 → 本地模型:本地 ASR 推理引擎的启用 / 禁用 + 模型下载管理。 +// 自 Settings.tsx 的 AdvancedSection 拆出(流式输入已挪到「录音与输入」)。 +// 含 Qwen3(macOS)/ Foundry Local + sherpa-onnx(Windows)三条本地引擎。 + +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LocalAsr } from '../LocalAsr'; +import { detectOS } from '../../components/WindowChrome'; +import { setActiveAsrProvider } from '../../lib/ipc'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Btn, Card } from '../_atoms'; +import { SettingRow, Toggle, type AsrPresetId } from './shared'; + +export function LocalModelSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + const os = detectOS(); + const isMac = os === 'mac'; + const isWin = os === 'win'; + const platformSupported = isMac || isWin; + const switchSeqRef = useRef(0); + const [busy, setBusy] = useState(false); + // 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切; + // 点取消 → 回到 null。一次只允许一个 modal。 + const [pendingTarget, setPendingTarget] = useState(null); + + const activeAsrProvider = (prefs?.activeAsrProvider ?? 'volcengine') as AsrPresetId; + const isOnLocalQwen3 = activeAsrProvider === 'local-qwen3'; + const isOnFoundry = activeAsrProvider === 'foundry-local-whisper'; + const isOnSherpaOnnx = activeAsrProvider === 'sherpa-onnx-local'; + const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry || isOnSherpaOnnx; + + const requestEnable = (target: AsrPresetId) => { + setPendingTarget(target); + }; + + const performSwitch = async (target: AsrPresetId) => { + setBusy(true); + const seq = ++switchSeqRef.current; + try { + await setActiveAsrProvider(target); + if (seq !== switchSeqRef.current) return; + if (prefs) { + await updatePrefs({ ...prefs, activeAsrProvider: target }); + } + } catch (err) { + // 调用方是 void performSwitch(...) 即发即忘 —— 这里吞掉并记日志,否则 IPC + // 失败会冒成未处理的 promise rejection。 + console.error('[settings] switch local ASR provider failed', err); + } finally { + if (seq === switchSeqRef.current) { + setBusy(false); + setPendingTarget(null); + } + } + }; + + const pendingNameKey = + pendingTarget === 'local-qwen3' ? 'asrLocalQwen3' + : pendingTarget === 'foundry-local-whisper' ? 'asrFoundryLocalWhisper' + : pendingTarget === 'sherpa-onnx-local' ? 'asrSherpaOnnxLocal' + : null; + + return ( + <> + {/* ─── 屏幕中央确认 modal(背景模糊) ───────────────────────────── + 点击遮罩或取消按钮关闭;切换中(busy)禁止任何关闭路径以免半切失败。 */} + {pendingTarget && pendingNameKey && ( +
{ + if (e.target === e.currentTarget && !busy) setPendingTarget(null); + }}> + +
+ ⚠️ {t('settings.advanced.confirmEnableLocalTitle')} +
+
+ {t('settings.advanced.confirmEnableLocalBody', { + target: t(`settings.providers.presets.${pendingNameKey}`), + })} +
+
+ setPendingTarget(null)}> + {t('common.cancel')} + + void performSwitch(pendingTarget)}> + {t('settings.advanced.confirm')} + +
+
+
+ )} + + + {/* 标题 + 右上角 inline 警告小字。 + Windows:标题区整体灰显 —— 「本地 ASR 模型(实验性)」在 Win 上几乎只有 + Qwen3 占位、本平台暂不支持;Foundry / sherpa-onnx 走的是另一条独立路径。 */} +
+
+
{t('settings.advanced.localAsrTitle')}
+
+
+ ⚠️ {t('settings.advanced.localAsrWarningShort')} +
+
+ + {!platformSupported ? ( +
+ {t('settings.advanced.platformNotSupported')} +
+ ) : ( + <> + {/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off + + 不可点。整行灰显,跟「实验性」标题区对齐。 */} +
+ +
+ { + if (next) requestEnable('local-qwen3'); + else void performSwitch('volcengine'); + } : undefined} + /> +
+
+
+ + {/* Foundry Local + sherpa-onnx 行 —— 仅 Windows 露出。 */} + {isWin && ( + <> + +
+ { + if (next) requestEnable('foundry-local-whisper'); + else void performSwitch('volcengine'); + } : undefined} + /> +
+
+ +
+ { + if (next) requestEnable('sherpa-onnx-local'); + else void performSwitch('volcengine'); + } : undefined} + /> +
+
+ + )} + + )} + + {/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出(Linux / 跨平台 + 异常 profile 同步)。否则平台本机 Toggle 自身就能 off。 */} + {isOnAnyLocal && !((isMac && isOnLocalQwen3) || (isWin && (isOnFoundry || isOnSherpaOnnx))) && ( + +
+ void performSwitch('volcengine')}> + {t('settings.advanced.disable')} + +
+
+ )} + + {/* 模型下载 / 加载(镜像源 · 模型列表 · 下载 · 删除 · 设为默认 · Foundry / sherpa) + —— 跟上面的启动开关收进同一个框:「本地 ASR」是一个整体。 */} + {platformSupported && ( +
+ +
+ )} +
+ + ); +} diff --git a/openless-all/app/src/pages/settings/MarketplaceSection.tsx b/openless-all/app/src/pages/settings/MarketplaceSection.tsx new file mode 100644 index 00000000..bd21a932 --- /dev/null +++ b/openless-all/app/src/pages/settings/MarketplaceSection.tsx @@ -0,0 +1,108 @@ +// 服务 → 扩展市场:通过 GitHub 登录获取上传 / 点赞身份。 +// 浏览与安装风格在「风格」页内完成,设置页只管登录身份。 +// +// 登录走共用的 (GitHub OAuth Device Flow),与风格市场 +// 完全一致 —— 点登录弹出统一登录窗口,授权成功写回 prefs.marketplaceDevLogin。 + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Icon } from '../../components/Icon'; +import { GithubLoginModal } from '../../components/GithubLoginModal'; +import { Btn, Card } from '../_atoms'; +import { SectionTitle } from './shared'; + +export function MarketplaceSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); + const [showLogin, setShowLogin] = useState(false); + + if (!prefs) { + return ( + +
{t('common.loading')}
+
+ ); + } + + const login = prefs.marketplaceDevLogin.trim(); + const signedIn = login.length > 0; + + const signOut = () => { + void savePrefs(current => ({ ...current, marketplaceDevLogin: '' })); + }; + + return ( + + {t('settings.marketplace.title')} + + {signedIn ? ( + /* ── 已登录 ──────────────────────────────────────────────── */ +
+
+ +
+
+
+ {t('settings.marketplace.github.signedIn')} +
+
+ @{login} +
+
+ + {t('settings.marketplace.github.signOut')} + +
+ ) : ( + /* ── 未登录 ──────────────────────────────────────────────── */ +
+ setShowLogin(true)}> + {t('settings.marketplace.github.signIn')} + +
+ )} + + {showLogin && ( + setShowLogin(false)} + onSuccess={nextLogin => { + void savePrefs(current => ({ ...current, marketplaceDevLogin: nextLogin })) + .catch(e => console.warn('[marketplace] save login to prefs failed', e)); + }} + /> + )} +
+ ); +} diff --git a/openless-all/app/src/pages/settings/MicrophoneSelect.tsx b/openless-all/app/src/pages/settings/MicrophoneSelect.tsx new file mode 100644 index 00000000..015b0485 --- /dev/null +++ b/openless-all/app/src/pages/settings/MicrophoneSelect.tsx @@ -0,0 +1,152 @@ +// 麦克风选择 —— 复用 SelectLite「官方框」下拉:点开后弹出麦克风列表, +// 选中项最右侧打勾、勾左侧显示实时音量条。下拉打开时监听选中设备电平。 + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + isTauri, + startMicrophoneLevelMonitor, + stopMicrophoneLevelMonitor, +} from '../../lib/ipc'; +import type { MicrophoneDevice } from '../../lib/types'; +import { SelectLite, type SelectOption } from '../../components/ui/SelectLite'; + +interface MicrophoneSelectProps { + devices: MicrophoneDevice[]; + /** 当前生效的设备名;'' = 系统默认。 */ + selectedName: string; + onSelect: (name: string) => void; + /** 下拉打开时回调 —— 用于刷新设备列表。 */ + onOpen?: () => void; +} + +export function MicrophoneSelect({ devices, selectedName, onSelect, onOpen }: MicrophoneSelectProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [level, setLevel] = useState(0); + // 串行化 start/stop —— 避免快速开合下监听器与 Rust 端状态错位。 + const monitorQueueRef = useRef>(Promise.resolve()); + + const enqueueMonitorTask = useCallback((task: () => Promise) => { + const next = monitorQueueRef.current.catch(() => undefined).then(task); + monitorQueueRef.current = next.catch(() => undefined); + return next; + }, []); + + // 下拉打开时监听选中设备电平;关闭即停止并清零。 + useEffect(() => { + if (!open) { + setLevel(0); + return; + } + let unlisten: (() => void) | undefined; + let cancelled = false; + let timer: number | undefined; + + async function start() { + await enqueueMonitorTask(async () => { + try { + if (isTauri) { + const { listen } = await import('@tauri-apps/api/event'); + if (cancelled) return; + const stopListening = await listen<{ level: number }>('microphone:level', event => { + setLevel(Math.max(0, Math.min(1, event.payload.level ?? 0))); + }); + if (cancelled) { + stopListening(); + return; + } + unlisten = stopListening; + await startMicrophoneLevelMonitor(selectedName); + if (cancelled) { + unlisten?.(); + unlisten = undefined; + await stopMicrophoneLevelMonitor(); + } + } else { + const tick = window.setInterval(() => { + setLevel(0.25 + Math.random() * 0.55); + }, 120); + if (cancelled) { + window.clearInterval(tick); + return; + } + unlisten = () => window.clearInterval(tick); + } + } catch (err) { + console.warn('[settings] microphone level monitor failed', err); + } + }); + } + + timer = window.setTimeout(() => { + void start(); + }, 140); + return () => { + cancelled = true; + if (timer !== undefined) window.clearTimeout(timer); + void enqueueMonitorTask(async () => { + unlisten?.(); + unlisten = undefined; + await stopMicrophoneLevelMonitor(); + }); + }; + }, [enqueueMonitorTask, open, selectedName]); + + // 选中项(默认麦克风或某条设备)右侧挂音量条,由 SelectLite 在其后再补打勾。 + const options = useMemo(() => { + const meter = ; + return [ + { + value: '', + label: t('settings.recording.microphoneDefault'), + trailing: selectedName === '' ? meter : undefined, + }, + ...devices.map(device => ({ + value: device.name, + label: device.name, + trailing: selectedName === device.name ? meter : undefined, + })), + ]; + }, [devices, level, selectedName, t]); + + return ( + { + setOpen(next); + if (next) onOpen?.(); + }} + style={{ width: 200, maxWidth: 200, minWidth: 0 }} + /> + ); +} + +function LevelMeter({ level }: { level: number }) { + const amplified = Math.min(1, Math.max(0, level * 4.5)); + const bars = [0.4, 0.7, 1, 0.7, 0.4]; + return ( + + {bars.map((weight, index) => { + const intensity = Math.min(1, amplified * (0.85 + weight * 0.35)); + const height = 4 + intensity * (10 * weight); + return ( + 0.08 ? 'var(--ol-blue)' : 'rgba(0,0,0,0.14)', + opacity: 0.4 + intensity * 0.6, + transition: 'height 70ms linear, opacity 90ms ease, background 120ms ease', + }} + /> + ); + })} + + ); +} diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index ad151aa2..951576bb 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -16,13 +16,11 @@ import { } from '../../lib/ipc'; import type { NetworkCheckResult } from '../../lib/ipc'; import type { - HotkeyCapability, HotkeyStatus, PermissionStatus, WindowsImeStatus, } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; -import i18n from '../../i18n'; import { Btn, Card, Pill } from '../_atoms'; import { SettingRow } from './shared'; @@ -103,17 +101,10 @@ export function PermissionsSection() { refreshPermissions(); }; - const desc = capability?.requiresAccessibilityPermission - ? t('settings.permissions.descAcc') - : t('settings.permissions.descNoAcc'); - return ( -
{t('settings.permissions.title')}
-
- {desc} -
- +
{t('settings.permissions.title')}
+
{microphone !== 'granted' && microphone !== 'notApplicable' && microphone !== 'loading' && ( @@ -124,8 +115,8 @@ export function PermissionsSection() {
{capability?.requiresAccessibilityPermission && ( - -
+ +
{accessibility !== 'granted' && accessibility !== 'notApplicable' && ( @@ -135,10 +126,7 @@ export function PermissionsSection() {
)} - +
{hotkey?.message && ( {windowsIme?.state !== 'notWindows' && ( - +
{windowsIme && ( )} - +
{network && network.latencyMs != null && ( @@ -242,9 +227,3 @@ function NetworkStatusPill({ status }: { status: NetworkCheckResult | null }) { } return {t('settings.permissions.networkOffline') ?? '不可用'}; } - -function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) { - if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap'); - if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel'); - return i18n.t('hotkey.adapter.fcitx5'); -} diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx new file mode 100644 index 00000000..e9cda5d0 --- /dev/null +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -0,0 +1,791 @@ +// 服务 → AI 提供商:LLM 润色模型 + ASR 语音转写两张卡片。 +// 自 Settings.tsx 整体迁出,逻辑零改动;i18n key 全部保持 `settings.providers.*`。 + +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from '../../components/Icon'; +import { detectOS } from '../../components/WindowChrome'; +import { + listProviderModels, + readCredential, + setActiveAsrProvider, + setActiveLlmProvider, + setCredential, + validateProviderCredentials, +} from '../../lib/ipc'; +import { emitSaved } from '../../lib/savedEvent'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { SelectLite } from '../../components/ui/SelectLite'; +import { Card } from '../_atoms'; +import { SettingRow, SectionTitle, Toggle, inputStyle, type AsrPresetId } from './shared'; + +function LlmThinkingToggle({ enabled, onToggle }: { enabled: boolean; onToggle: (next: boolean) => void }) { + const { t } = useTranslation(); + return ( +
+ + {t('settings.providers.thinkingModeLabel')} + + + + {enabled ? t('settings.providers.thinkingModeOn') : t('settings.providers.thinkingModeOff')} + +
+ ); +} + +const LLM_PRESETS = [ + { + id: 'ark', + nameKey: 'ark', + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + modelPlaceholder: 'deepseek-v3-2', + }, + { + id: 'deepseek', + nameKey: 'deepseek', + baseUrl: 'https://api.deepseek.com/v1', + modelPlaceholder: 'deepseek-v4-flash', + }, + { + id: 'siliconflow', + nameKey: 'siliconflow', + baseUrl: 'https://api.siliconflow.cn/v1', + modelPlaceholder: 'Qwen/Qwen2.5-7B-Instruct', + }, + { + id: 'openai', + nameKey: 'openai', + baseUrl: 'https://api.openai.com/v1', + modelPlaceholder: 'gpt-4o', + }, + { + // 谷歌官方 Gemini API(原生 generateContent,不走 OpenAI 兼容 shim)。 + // baseUrl 末尾 /v1beta 是当前 Generally Available 的 path(ai.google.dev/api)。 + // 后端 llm_gemini.rs 会拼成 `{baseUrl}/models/{model}:generateContent`, + // 并按 Gemini 原生通道级 thinkingConfig 关闭或压低思考,不在前端维护模型适配表。 + // 模型列表用 ProviderTools「拉取模型」按钮取, + // 由 commands.rs::fetch_provider_models 识别 generativelanguage 域名后按 Gemini shape 解析。 + id: 'gemini', + nameKey: 'gemini', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + modelPlaceholder: 'gemini-2.5-flash', + }, + { + id: 'codex_oauth', + nameKey: 'codexOAuth', + baseUrl: '', + modelPlaceholder: 'gpt-5.3-codex-spark', + }, + { + id: 'mimo', + nameKey: 'mimo', + baseUrl: 'https://api.xiaomimimo.com/v1', + modelPlaceholder: 'xiaomi/mimo-v2-flash', + }, + { + id: 'cometapi', + nameKey: 'cometapi', + baseUrl: 'https://api.cometapi.com/v1', + modelPlaceholder: 'gpt-4o', + }, + { + id: 'openrouterFree', + nameKey: 'openrouterFree', + baseUrl: 'https://openrouter.ai/api/v1', + modelPlaceholder: 'qwen/qwen3-coder:free', + }, + { + id: 'alibabaCoding', + nameKey: 'alibabaCoding', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + modelPlaceholder: 'qwen3-coder-plus', + }, + { + id: 'codingPlanX', + nameKey: 'codingPlanX', + baseUrl: 'https://api.codingplanx.ai/v1', + modelPlaceholder: 'gpt-5-mini', + }, + { + id: 'custom', + nameKey: 'custom', + baseUrl: '', + modelPlaceholder: '', + }, +] as const; + +type LlmPresetId = typeof LLM_PRESETS[number]['id']; + +const ASR_DEFAULT_RESOURCE_ID = 'volc.bigasr.sauc.duration'; + +// `volcengine` / `bailian` 走自建流式客户端;其余走 OpenAI 兼容 +// `/audio/transcriptions`(`coordinator.rs::is_whisper_compatible_provider`)。 +// 新增兼容厂商: +// 1. 在这里加一项 `{ id, nameKey, baseUrl, model }`; +// 2. `coordinator.rs::is_whisper_compatible_provider` 加同名 id; +// 3. 在 i18n 的 `settings.providers.presets.` 加文案。 +// `AsrPresetId` 定义在 settings/shared.tsx,LocalModelSection / ProvidersSection 共用同一份。 +const ASR_PRESETS: ReadonlyArray<{ id: AsrPresetId; nameKey: string; baseUrl: string; model: string }> = [ + { id: 'volcengine', nameKey: 'asrVolcengine', baseUrl: '', model: '' }, + { id: 'bailian', nameKey: 'asrBailian', baseUrl: 'wss://dashscope.aliyuncs.com/api-ws/v1/inference/', model: 'fun-asr-realtime' }, + { id: 'siliconflow', nameKey: 'asrSiliconflow', baseUrl: 'https://api.siliconflow.cn/v1', model: 'FunAudioLLM/SenseVoiceSmall' }, + { id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' }, + { id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' }, + { id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' }, + { id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' }, + // 本地引擎(Foundry / sherpa-onnx / Qwen3):无 baseUrl/model 配置, + // 模型在「高级 → 本地模型」里下载与切换。 + { id: 'sherpa-onnx-local', nameKey: 'asrSherpaOnnxLocal', baseUrl: '', model: '' }, + { id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' }, +]; + +export function ProvidersSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + // `*Provider` 立即跟随 立刻显示用户的选择(issue #220 P2:codex 指出受控选不应等 await) + // - CredentialField 不要在后端 active 切完前 remount(issue #219:避免读到旧 entry) + // `*SwitchSeq` 是 stale-write 守卫:用户 100ms 内连点两次时,先发的请求晚到不 + // 会覆盖后发的 commit。 + const [llmProvider, setLlmProvider] = useState('ark'); + const [asrProvider, setAsrProvider] = useState('volcengine'); + const [committedLlmProvider, setCommittedLlmProvider] = useState('ark'); + const [committedAsrProvider, setCommittedAsrProvider] = useState('volcengine'); + const llmSwitchSeqRef = useRef(0); + const asrSwitchSeqRef = useRef(0); + const [llmModelRevision, setLlmModelRevision] = useState(0); + const [asrModelRevision, setAsrModelRevision] = useState(0); + const os = detectOS(); + // 主 ASR 下拉只列云端选项;本地推理(local-qwen3 / foundry-local-whisper / + // sherpa-onnx-local)移到「高级 → 本地模型」,防止新手误开 CPU 推理。 + const visibleAsrPresets = ASR_PRESETS.filter( + p => p.id !== 'foundry-local-whisper' + && p.id !== 'local-qwen3' + && p.id !== 'sherpa-onnx-local', + ); + + useEffect(() => { + if (!prefs) return; + const knownLlm = LLM_PRESETS.find(x => x.id === prefs.activeLlmProvider); + const llmId = knownLlm ? knownLlm.id : 'custom'; + setLlmProvider(llmId); + setCommittedLlmProvider(llmId); + // ASR 在 ALL ASR_PRESETS 里查(不是 visibleAsrPresets)——本地选项虽然 + // 从下拉里藏起来了,但若用户曾在「高级」里启用过 local-qwen3,主 Card + // 仍要识别出 active 是本地,并切到「正在使用本地 ASR」的 notice 渲染。 + const knownAsr = ASR_PRESETS.find(x => x.id === prefs.activeAsrProvider); + const asrId = knownAsr ? knownAsr.id : 'volcengine'; + setAsrProvider(asrId); + setCommittedAsrProvider(asrId); + }, [prefs, os]); + + // issue #219 / #220 P2: + // 1. 立刻 setLlmProvider —— 受控 立刻切到新厂商,但凭据字段还在显示旧 entry,placeholder + // 会先于实际数据切换、视觉上对不上。 + const preset = LLM_PRESETS.find(p => p.id === committedLlmProvider) ?? LLM_PRESETS[LLM_PRESETS.length - 1]; + const codexOAuthSelected = committedLlmProvider === 'codex_oauth'; + const asrPreset = visibleAsrPresets.find(p => p.id === committedAsrProvider); + return ( + <> +
+ {t('settings.providers.credentialStorageNotice')} +
+ +
+ {t('settings.providers.llmTitle')} +
+ {/* desc 已去掉——'选择后将自动填入 Base URL 默认值' 在 180px label 列必换行成两行, + 视觉上 label 区出现"字体单独占一行"。下拉自身已经表达了"切换"含义,desc 冗余。 */} + + onLlmProviderChange(next as LlmPresetId)} + options={LLM_PRESETS.map(p => ({ + value: p.id, + label: t(`settings.providers.presets.${p.nameKey}`), + }))} + ariaLabel={t('settings.providers.providerLabel')} + style={{ ...inputStyle, width: '100%', maxWidth: 200 }} + /> + + {codexOAuthSelected ? ( +
+ {t('settings.providers.codexOAuthNotice')} +
+ ) : ( + <> + + + + )} + + )} + /> + setLlmModelRevision(v => v + 1)} /> +
+ + +
+ {t('settings.providers.asrTitle')} +
+ {/* 下拉只放云端选项;本地引擎激活时锁住 + 在下方放一行"ASR 提供商已被接管"提示, + 未激活时不显示提示。 */} + + {(() => { + const isLocked = + committedAsrProvider === 'local-qwen3' || + committedAsrProvider === 'foundry-local-whisper' || + committedAsrProvider === 'sherpa-onnx-local'; + const selectedValue: AsrPresetId = isLocked ? committedAsrProvider : asrProvider; + // 跨机器同步异常兜底:committed 是本地但不在 visibleAsrPresets 里时,受控 + // select 会回退到首项造成假象 —— 补一个 disabled option 让 select 找到当前值。 + const anomalousLocal: AsrPresetId | null = + isLocked && !visibleAsrPresets.some(p => p.id === committedAsrProvider) + ? committedAsrProvider + : null; + const anomalousNameKey = anomalousLocal === 'local-qwen3' + ? 'asrLocalQwen3' + : anomalousLocal === 'foundry-local-whisper' + ? 'asrFoundryLocalWhisper' + : anomalousLocal === 'sherpa-onnx-local' + ? 'asrSherpaOnnxLocal' + : null; + return ( +
+ onAsrProviderChange(next as AsrPresetId)} + options={[ + ...visibleAsrPresets.map(p => ({ + value: p.id, + label: t(`settings.providers.presets.${p.nameKey}`), + })), + ...(anomalousLocal && anomalousNameKey + ? [{ + value: anomalousLocal, + label: t(`settings.providers.presets.${anomalousNameKey}`), + disabled: true, + }] + : []), + ]} + ariaLabel={t('settings.providers.providerLabel')} + style={{ ...inputStyle, width: '100%', maxWidth: 200 }} + /> + {isLocked && ( +
+ {t('settings.providers.asrProviderTakenOver')} +
+ )} +
+ ); + })()} +
+ {committedAsrProvider === 'volcengine' ? ( + <> + + + +
+ {t('settings.providers.volcengineMappingNote')} +
+ + ) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' || committedAsrProvider === 'sherpa-onnx-local' ? ( + // 用户已经在用本地 ASR——dropdown 行的 asrProviderTakenOver 已经把 + // "在高级中切换或禁用"讲清楚了,body 不再重复。 + // 模型管理 UI 唯一入口在「高级 → 本地模型」里的 。 + null + ) : ( + <> + + + + {committedAsrProvider === 'bailian' && ( + <> + +
+ {t('settings.providers.bailianVocabularyIdNote')} +
+ + )} + setAsrModelRevision(v => v + 1)} /> + + )} +
+ + ); +} + +type ProviderToolStatus = 'idle' | 'loading' | 'success' | 'empty' | 'error'; + +function ProviderTools({ kind, modelAccount, onModelSelected }: { kind: 'llm' | 'asr'; modelAccount: string; onModelSelected: () => void }) { + const { t } = useTranslation(); + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(''); + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState(''); + + const setResult = (next: ProviderToolStatus, nextMessage: string) => { + setStatus(next); + setMessage(nextMessage); + }; + + const validate = async () => { + setModels([]); + setSelectedModel(''); + setResult('loading', t('settings.providers.validating')); + try { + const result = await validateProviderCredentials(kind); + setResult( + result.ok ? 'success' : 'error', + t(result.ok ? 'settings.providers.validateSuccess' : 'settings.providers.validateFailed'), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ((kind === 'llm' && message === 'llmModelMissing') || (kind === 'asr' && message === 'asrModelMissing')) { + setResult('empty', t('settings.providers.modelMissing')); + return; + } + if (message === 'modelsEmpty') { + setResult('empty', t('settings.providers.modelsEmpty')); + return; + } + setResult('error', providerErrorMessage(error, t)); + } + }; + + const loadModels = async () => { + setResult('loading', t('settings.providers.loadingModels')); + try { + const result = await listProviderModels(kind); + setModels(result.models); + if (result.models.length === 0) { + setResult('empty', t('settings.providers.modelsEmpty')); + } else { + setSelectedModel(''); + setResult('success', t('settings.providers.modelsLoaded', { count: result.models.length })); + } + } catch (error) { + setModels([]); + setResult('error', providerErrorMessage(error, t)); + } + }; + + const applyModel = async (model: string) => { + setResult('loading', t('common.saving')); + try { + await setCredential(modelAccount, model); + setSelectedModel(model); + onModelSelected(); + setResult('success', t('settings.providers.modelSaved', { model })); + } catch (error) { + setResult('error', providerErrorMessage(error, t)); + } + }; + + return ( + +
+
+ + + {models.length > 0 && ( + ({ value: model, label: model }))} + placeholder={t('settings.providers.selectModel')} + ariaLabel={t('settings.providers.selectModel')} + style={{ ...inputStyle, maxWidth: 220 }} + /> + )} +
+ {message && ( + + {message} + + )} +
+
+ ); +} + +function providerErrorMessage(error: unknown, t: ReturnType['t']): string { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith('providerHttpStatus:')) { + return t('settings.providers.providerHttpStatus', { status: message.split(':')[1] || '?' }); + } + if (message === 'endpointMustUseHttps') return t('settings.providers.endpointMustUseHttps'); + if (message === 'endpointInvalid') return t('settings.providers.endpointInvalid'); + if (message === 'providerResponseTooLarge') return t('settings.providers.responseTooLarge'); + if (message === 'asrInvalidJson') return t('settings.providers.asrInvalidJson'); + if (message === 'asrMissingTextField') return t('settings.providers.asrMissingTextField'); + if (message === 'providerNetworkError') return t('common.networkError'); + if (message === 'providerReadResponseFailed' || message === 'providerClientInitFailed') return t('common.operationFailed'); + if (message === 'providerRequestTimeout') return t('settings.providers.requestTimeout'); + if (message.includes('API Key')) return t('settings.providers.apiKeyMissing'); + if (message.includes('Endpoint')) return t('settings.providers.endpointMissing'); + if (message.includes('timeout') || message.includes('超时')) return t('settings.providers.requestTimeout'); + return t('common.operationFailed'); +} + +type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError'; + +interface CredentialFieldProps { + label: string; + account: string; + placeholder?: string; + mono?: boolean; + mask?: boolean; + defaultValue?: string; + trailing?: ReactNode; +} + +function CredentialField({ label, account, placeholder, mono, mask, defaultValue, trailing }: CredentialFieldProps) { + const { t } = useTranslation(); + const [value, setValue] = useState(''); + const [revealed, setRevealed] = useState(false); + const [loaded, setLoaded] = useState(false); + const [dirty, setDirty] = useState(false); + const [status, setStatus] = useState('idle'); + const debounceRef = useRef(null); + const statusRef = useRef(null); + + useEffect(() => { + let cancelled = false; + setLoaded(false); + setDirty(false); + setStatus('idle'); + setValue(''); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + readCredential(account) + .then(v => { + if (cancelled) return; + setValue(v ?? ''); + setLoaded(true); + }) + .catch(error => { + if (cancelled) return; + console.error('[settings] failed to read credential', account, error); + setLoaded(true); + setStatus('readError'); + }); + return () => { + cancelled = true; + }; + }, [account]); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + if (statusRef.current) clearTimeout(statusRef.current); + }; + }, []); + + // 改造:除 readError(持续错误,留在输入旁标识字段不可用)外,所有 saving / saved / + // saveError / copied / copyError 一律发到右上角 SavedToast。原内联文案太挤、跟其它 + // 页面 toast 风格不统一。 + const showTemporaryStatus = (next: CredentialFieldStatus) => { + if (next === 'saving') { + emitSaved('saving', t('common.saving')); + } else if (next === 'saved') { + emitSaved('saved', t('common.saved')); + } else if (next === 'saveError') { + emitSaved('failed', t('common.operationFailed')); + } else if (next === 'copied') { + emitSaved('saved', t('common.copied')); + } else if (next === 'copyError') { + emitSaved('failed', t('common.operationFailed')); + } + setStatus(next); + if (statusRef.current) clearTimeout(statusRef.current); + statusRef.current = window.setTimeout(() => setStatus('idle'), 1600); + }; + + const save = async (v: string, force = false) => { + if (!loaded || (!dirty && !force)) return; + setStatus('saving'); + emitSaved('saving', t('common.saving')); + try { + await setCredential(account, v); + setDirty(false); + showTemporaryStatus('saved'); + } catch (error) { + console.error('[settings] failed to save credential', account, error); + showTemporaryStatus('saveError'); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const v = e.target.value; + setValue(v); + if (!loaded) return; + setDirty(true); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout(() => save(v, true), 300); + }; + + const onBlur = () => { + if (!loaded || !dirty) return; + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + save(value, true); + }; + + const fillDefault = async () => { + if (!loaded || !defaultValue) return; + setValue(defaultValue); + setDirty(true); + await save(defaultValue, true); + }; + + const onCopy = async () => { + if (!value || !loaded) return; + try { + if (!navigator.clipboard?.writeText) { + throw new Error('Clipboard API unavailable'); + } + await navigator.clipboard.writeText(value); + showTemporaryStatus('copied'); + } catch (error) { + console.error('[settings] failed to copy credential', account, error); + showTemporaryStatus('copyError'); + } + }; + + const inputType = mask && !revealed ? 'password' : 'text'; + const disabled = !loaded; + + return ( + +
+ + {defaultValue && !value && loaded && ( + + )} + {trailing} + {mask && ( + + )} + + {/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用; + 其它瞬态状态(saving / saved / saveError / copied / copyError)都通过 + emitSaved 发到右上角统一 toast,不再内联占位。 */} + {status === 'readError' && ( + + {t('settings.providers.readFailed')} + + )} +
+
+ ); +} + +const miniBtnStyle: CSSProperties = { + height: 32, padding: '0 12px', + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 8, background: 'var(--ol-surface)', + boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset', + color: 'var(--ol-ink-2)', cursor: 'default', flexShrink: 0, + fontSize: 12.5, fontWeight: 500, letterSpacing: '0.01em', + transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.16s var(--ol-motion-quick)', +}; + +const iconBtnStyle: CSSProperties = { + width: 32, height: 32, + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 8, background: 'var(--ol-surface)', + boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 0 0 0.5px rgba(255,255,255,0.2) inset', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + color: 'var(--ol-ink-3)', cursor: 'default', flexShrink: 0, + transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)', +}; diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx new file mode 100644 index 00000000..6a1fc11c --- /dev/null +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -0,0 +1,336 @@ +// 通用 → 录音与输入:录音快捷键 / 方式 / 麦克风 / 胶囊 / 静音, +// 外加「插入与剪贴板」「启动」两个折叠组。流式输入也并到这里(属于插入行为)。 +// 自 Settings.tsx 的 RecordingSection 拆出,录音相关逻辑零改动。 + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ShortcutRecorder } from '../../components/ShortcutRecorder'; +import { isHotkeyModeMigrationNoticeActive } from '../../lib/hotkeyMigration'; +import { + isTauri, + listMicrophoneDevices, + setDictationHotkey, +} from '../../lib/ipc'; +import type { HotkeyMode, MicrophoneDevice, PasteShortcut } from '../../lib/types'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { SelectLite } from '../../components/ui/SelectLite'; +import { Card, Collapsible } from '../_atoms'; +import { SettingRow, Toggle, inputStyle } from './shared'; +import { MicrophoneSelect } from './MicrophoneSelect'; + +async function autostartIsEnabled(): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + return invoke('plugin:autostart|is_enabled'); +} + +async function autostartEnable(): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('plugin:autostart|enable'); +} + +async function autostartDisable(): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('plugin:autostart|disable'); +} + +export function RecordingInputSection() { + const { t } = useTranslation(); + const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); + const [microphoneDevices, setMicrophoneDevices] = useState([]); + const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false); + const [microphoneDevicesError, setMicrophoneDevicesError] = useState(null); + + const loadMicrophoneDevices = useCallback(async ( + signal?: { cancelled: boolean }, + options: { showLoading?: boolean } = {}, + ) => { + if (options.showLoading ?? true) { + setMicrophoneDevicesLoaded(false); + } + setMicrophoneDevicesError(null); + try { + const devices = await listMicrophoneDevices(); + if (signal?.cancelled) return; + setMicrophoneDevices(devices); + setMicrophoneDevicesLoaded(true); + } catch (err) { + console.error('[settings] list microphone devices failed', err); + if (signal?.cancelled) return; + setMicrophoneDevices([]); + setMicrophoneDevicesError(err instanceof Error ? err.message : String(err)); + setMicrophoneDevicesLoaded(true); + } + }, []); + + useEffect(() => { + const signal = { cancelled: false }; + void loadMicrophoneDevices(signal); + return () => { + signal.cancelled = true; + }; + }, [loadMicrophoneDevices]); + + useEffect(() => { + if (!isTauri) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + async function listenForDeviceChanges() { + const { listen } = await import('@tauri-apps/api/event'); + if (cancelled) return; + const stopListening = await listen('microphone:devices-changed', () => { + void loadMicrophoneDevices(undefined, { showLoading: false }); + }); + if (cancelled) { + stopListening(); + return; + } + unlisten = stopListening; + } + void listenForDeviceChanges(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [loadMicrophoneDevices]); + + if (!prefs || !capability) { + return ( + +
{t('common.loading')}
+
+ ); + } + + const onModeChange = (mode: HotkeyMode) => + savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); + const onShowCapsuleChange = (showCapsule: boolean) => + savePrefs({ ...prefs, showCapsule }); + const onMuteDuringRecordingChange = (muteDuringRecording: boolean) => + savePrefs({ ...prefs, muteDuringRecording }); + const onMicrophoneDeviceChange = (microphoneDeviceName: string) => + savePrefs({ ...prefs, microphoneDeviceName }); + const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => + savePrefs({ ...prefs, restoreClipboardAfterPaste }); + const onPasteShortcutChange = (pasteShortcut: PasteShortcut) => + savePrefs({ ...prefs, pasteShortcut }); + const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => + savePrefs({ ...prefs, allowNonTsfInsertionFallback }); + const onStartMinimizedChange = (startMinimized: boolean) => + savePrefs({ ...prefs, startMinimized }); + const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) => + savePrefs({ ...prefs, autoUpdateCheck }); + + const choices: Array<[HotkeyMode, string]> = [ + ['toggle', t('settings.recording.modeToggle')], + ['hold', t('settings.recording.modeHold')], + ]; + const preferredMicrophoneAvailable = Boolean( + prefs.microphoneDeviceName + && microphoneDevices.some(device => device.name === prefs.microphoneDeviceName), + ); + const effectiveMicrophoneDeviceName = prefs.microphoneDeviceName + && (!microphoneDevicesLoaded || preferredMicrophoneAvailable) + ? prefs.microphoneDeviceName + : ''; + + return ( + <> + +
+
+ {t('settings.recording.title')} +
+
+ {isHotkeyModeMigrationNoticeActive() && ( +
+
+ {t('settings.recording.migrationNoticeTitle')} +
+
+ {t('settings.recording.migrationNoticeDesc')} +
+
+ )} + + { + await setDictationHotkey(binding); + await savePrefs({ ...prefs, dictationHotkey: binding }); + }} + /> + + +
+ {choices.map(([v, l]) => ( + + ))} +
+
+ +
+ { void loadMicrophoneDevices(undefined, { showLoading: false }); }} + /> + {microphoneDevicesError && ( +
+ {t('settings.recording.microphoneLoadError', { message: microphoneDevicesError })} +
+ )} +
+
+ + + + + + +
+ + {/* ─── 插入与剪贴板(折叠) ──────────────────────────────────── */} + + + + + {capability.adapter !== 'macEventTap' && ( + + onPasteShortcutChange(next as PasteShortcut)} + options={[ + { value: 'ctrlV', label: t('settings.recording.pasteShortcutCtrlV') }, + { value: 'ctrlShiftV', label: t('settings.recording.pasteShortcutCtrlShiftV') }, + { value: 'shiftInsert', label: t('settings.recording.pasteShortcutShiftInsert') }, + ]} + ariaLabel={t('settings.recording.pasteShortcutLabel')} + style={{ ...inputStyle, maxWidth: 220 }} + /> + + )} + {capability.adapter === 'windowsLowLevel' && ( + + + + )} + {/* 流式输入:润色 SSE 一边到达一边模拟键盘逐字落到光标,降低感知延迟。 + 不满足条件时自动回落一次性插入。属于「插入行为」,故归到本组。 */} + + void savePrefs({ ...prefs, streamingInsert: next })} + /> + + + void savePrefs({ ...prefs, streamingInsertSaveClipboard: next })} + /> + + + + {/* ─── 启动(折叠) ──────────────────────────────────────────── */} + + + + + + + + + {capability.statusHint && ( +
+ {capability.statusHint} +
+ )} +
+ + ); +} + +// 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop / +// windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。 +function AutostartRow() { + const { t } = useTranslation(); + const [enabled, setEnabled] = useState(false); + const [loaded, setLoaded] = useState(false); + // 切 plist / 注册表失败时给用户看的错误。null = 没有失败/上次操作已成功。 + const [error, setError] = useState(null); + + useEffect(() => { + if (!isTauri) { + setLoaded(true); + return; + } + let cancelled = false; + autostartIsEnabled() + .then((v: boolean) => { + if (!cancelled) { + setEnabled(v); + setLoaded(true); + } + }) + .catch((err: unknown) => { + console.error('[autostart] isEnabled failed', err); + if (!cancelled) setLoaded(true); + }); + return () => { + cancelled = true; + }; + }, []); + + const onToggle = async (next: boolean) => { + setEnabled(next); + setError(null); + try { + if (!isTauri) return; + if (next) await autostartEnable(); + else await autostartDisable(); + } catch (err) { + console.error('[autostart] toggle failed', err); + setEnabled(!next); + setError(err instanceof Error ? err.message : String(err)); + } + }; + + return ( + +
+ {loaded ? : null} + {error && ( +
+ {t('settings.recording.startupAtBootError', { message: error })} +
+ )} +
+
+ ); +} diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index a6fb7eb6..1e0cab69 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -26,17 +26,13 @@ export function ShortcutsSection() { ); } - const desc = capability.requiresAccessibilityPermission - ? t('settings.shortcuts.descAcc') - : t('settings.shortcuts.descNoAcc'); const readonlyRows: Array<[string, string]> = [ [t('settings.shortcuts.cancel'), 'Esc'], [t('settings.shortcuts.confirm'), t('settings.shortcuts.confirmHint')], ]; return ( -
{t('settings.shortcuts.title')}
-
{desc}
+
{t('settings.shortcuts.title')}
+ + + + + ); +} + +// 服务:AI 提供商 · 扩展市场。 +export function ServicesTab() { + return ( + <> + + + + ); +} + +// 隐私:本地优先说明 + 权限管理 · 数据存储。 +export function PrivacyTab() { + const { t } = useTranslation(); + return ( + <> +
+ + {t('modal.about.localFirst')} + + + {t('modal.about.privacyDesc')} + +
+ + + + ); +} + +// 高级:本地模型 · 调试工具 · 加入 Beta 渠道(固定在最下面)。 +export function AdvancedTab() { + return ( + <> + + + + + ); +} diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 17fee90b..55346b60 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -77,14 +77,33 @@ a { color: inherit; text-decoration: none; } } } +/* 弹窗动画统一入口 —— 纯 opacity / transform,合成器友好。 + 背景不再逐帧动画 backdrop-filter(会卡顿、闪烁);模糊由宿主元素静态设定, + 动画只负责淡入淡出。设置弹窗 / 各市场弹窗 / GitHub 登录弹窗共用这套。 */ @keyframes ol-modal-backdrop-in { - from { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); } - to { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); } + from { opacity: 0; } + to { opacity: 1; } } @keyframes ol-modal-backdrop-out { - from { opacity: 1; backdrop-filter: blur(8px) saturate(140%); -webkit-backdrop-filter: blur(8px) saturate(140%); } - to { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); } + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes ol-modal-card-in { + from { opacity: 0; transform: translateY(8px) scale(0.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* 设置弹窗切换 tab 时右侧内容的轻微淡入。 */ +@keyframes ol-tab-fade { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* 通用旋转 —— 「检查更新」按钮检查中的转圈动画等。 */ +@keyframes ol-spin { + to { transform: rotate(360deg); } } @keyframes ol-modal-drawer-in {