feat(settings): 设置界面重构 — 分节化 / 精简 / Beta 渠道 / 麦克风 / 市场登录
将 2200 行的 Settings.tsx 单体拆为 pages/settings/ 下的按节文件(通用 / 服务 / 隐私 / 高级 / 关于),并对设置界面做整体精简与功能调整。 界面精简 - 移除各设置项冗长 / 多行描述(在 180px 标签列普遍折成多行),只留标签与控件 - 删除「个性化」独立 tab —— 仅剩字体大小一项、整页太空,并入「关于」 - 移除失效的「毛玻璃强度」滑块(其 CSS 变量无可见元素消费) 关于 / 更新 - 版本卡片图标右上方放「检查更新」按钮,固定查正式版(channel='stable') - 「加入 Beta 渠道」迁到「高级」页底部、单独成节;开启后出现查测试版的检查按钮 - 检查中按钮内转圈;结果(已是最新 / 失败)只在按钮内呈现,卡片高度恒定, 不再因状态文字 reflow 抽搐 - Rust app_check_update_with_channel 新增可选 channel 参数,两个按钮各查各渠道; 后台 AutoUpdateGate 仍按 prefs.update_channel 走 麦克风选择 - 由全屏弹窗改为 SelectLite 下拉(与语言选择一致的「官方框」) - SelectLite 增加 trailing / onOpenChange;新增 MicrophoneSelect —— 选中项右侧 打勾、勾左侧实时音量条 市场登录 - 抽出共用 GithubLoginModal 与 ui/Modal,扩展市场与风格市场登录统一走弹窗 - 修复风格市场登录弹窗「先小后大」尺寸跳动(各阶段固定 minHeight) 弹窗动画 - 去掉开启动画里的 blur 滤镜(卡顿 / 闪烁),改纯 opacity + transform - 设置弹窗切 tab 内容轻微淡入;各处弹窗动画时长 / 缓动统一到 global.css 网络 - 新增 net.rs:进程级共享 reqwest 客户端(连接池复用)+ 传输层失败指数退避重试
This commit is contained in:
1 parent
82ab2439fb
commit
52c49fcfad
40 files changed
+2809
-3462
No files matched your search
@@ -13,6 +13,7 @@ use crate::asr::local::foundry::{
|
||||
};
|
||||
use crate::asr::local::FoundryLocalRuntime;
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::net;
|
||||
use crate::permissions::{self, PermissionStatus};
|
||||
use crate::persistence::{
|
||||
sync_style_pack_preferences, CredentialAccount, CredentialsSnapshot, CredentialsVault,
|
||||
@@ -319,16 +320,13 @@ pub struct LatestBetaRelease {
|
||||
/// 返回 `Ok(None)` = 当前没发过 Beta 版;`Err(String)` = 网络/解析故障。
|
||||
#[tauri::command]
|
||||
pub async fn fetch_latest_beta_release() -> Result<Option<LatestBetaRelease>, 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()));
|
||||
}
|
||||
@@ -408,17 +406,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<R: tauri::Runtime>(
|
||||
coord: CoordinatorState<'_>,
|
||||
webview: tauri::Webview<R>,
|
||||
timeout_ms: Option<u64>,
|
||||
channel: Option<UpdateChannel>,
|
||||
) -> Result<Option<AppUpdateMetadata>, 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));
|
||||
@@ -483,27 +484,27 @@ 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 响应就说明链路通;连不上才算离线。
|
||||
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::send_with_retry(|| {
|
||||
net::http()
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
})
|
||||
.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]
|
||||
@@ -976,31 +977,41 @@ fn active_foundry_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()));
|
||||
@@ -2492,13 +2503,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();
|
||||
@@ -2519,13 +2530,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}"));
|
||||
@@ -2548,38 +2560,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"));
|
||||
@@ -2634,7 +2648,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")
|
||||
@@ -2643,7 +2658,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))
|
||||
@@ -2694,14 +2709,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()));
|
||||
}
|
||||
@@ -2726,14 +2742,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();
|
||||
@@ -2751,14 +2768,15 @@ pub async fn marketplace_my_likes(coord: CoordinatorState<'_>) -> Result<Vec<Str
|
||||
if dev_user.is_empty() {
|
||||
return Ok(Vec::new()); // 未登录就空集合,UI 渲染无红心
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(format!("{base}/me/likes"))
|
||||
.header("X-Dev-User", dev_user)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("my-likes request failed: {e}"))?;
|
||||
let likes_url = format!("{base}/me/likes");
|
||||
let resp = net::send_with_retry(|| {
|
||||
net::http()
|
||||
.get(&likes_url)
|
||||
.header("X-Dev-User", dev_user.as_str())
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("my-likes request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("my-likes HTTP {}", resp.status()));
|
||||
}
|
||||
@@ -2778,14 +2796,15 @@ pub async fn marketplace_my_packs(
|
||||
if dev_user.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(format!("{base}/me/packs"))
|
||||
.header("X-Dev-User", dev_user)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("my-packs request failed: {e}"))?;
|
||||
let packs_url = format!("{base}/me/packs");
|
||||
let resp = net::send_with_retry(|| {
|
||||
net::http()
|
||||
.get(&packs_url)
|
||||
.header("X-Dev-User", dev_user.as_str())
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("my-packs request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("my-packs HTTP {}", resp.status()));
|
||||
}
|
||||
@@ -2844,18 +2863,16 @@ pub struct GithubDeviceStartResponse {
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<GithubDeviceStartResponse, String> {
|
||||
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()
|
||||
@@ -2892,36 +2909,36 @@ pub async fn github_device_flow_poll(
|
||||
device_code: String,
|
||||
) -> Result<GithubDevicePollResult, String> {
|
||||
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
|
||||
|
||||
@@ -24,6 +24,7 @@ mod insertion;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_fcitx;
|
||||
mod llm_gemini;
|
||||
mod net;
|
||||
mod permissions;
|
||||
mod persistence;
|
||||
mod polish;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! 共享 HTTP 客户端 + 带重试的请求发送。
|
||||
//!
|
||||
//! 背景:原先每个网络命令各自 `reqwest::Client::new()`,连接池互不复用 —— 一次
|
||||
//! 成功的 TLS 连接用完即弃,下一个命令又得重新握手。在握手不稳定的网络下(代理
|
||||
//! 分流等)首次握手经常被重置,用户得反复重试才能用。
|
||||
//!
|
||||
//! 这里提供两件东西:
|
||||
//! - `http()`:进程级共享客户端。一次握手成功后的连接进连接池,后续命令直接复用,
|
||||
//! 不再付握手成本。
|
||||
//! - `send_with_retry`:对「连接 / 超时 / 请求未送出」这三类传输层失败做指数退避
|
||||
//! 重试。这类失败发生在请求送达服务端之前,重试是幂等安全的。HTTP 4xx/5xx 不在
|
||||
//! 重试范围 —— 服务端已经应答,状态码交给调用方判断。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
static HTTP: Lazy<reqwest::Client> = 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;
|
||||
|
||||
/// 发送请求,对传输层失败(连接重置 / 超时 / 请求未送出)做指数退避重试。
|
||||
///
|
||||
/// `make` 每次尝试都重新构造 `RequestBuilder`(`send()` 会消耗它)。只重试
|
||||
/// `is_connect()` / `is_timeout()` / `is_request()` —— 这三类失败时请求尚未送达
|
||||
/// 服务端,重试幂等安全。拿到任意 HTTP 响应(含 4xx/5xx)即返回,状态码由调用方
|
||||
/// 自行判断。
|
||||
pub async fn send_with_retry<F>(make: F) -> reqwest::Result<reqwest::Response>
|
||||
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() || err.is_timeout() || err.is_request();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1080,11 +1080,14 @@ const STRUCTURED_BUILTIN_PROMPT: &str = r#"# 角色
|
||||
|
||||
按可识别的事项数决定输出形态:
|
||||
|
||||
- **事项 ≤ 2 条** → 输出连贯段落,不硬塞层级。
|
||||
- **事项仅 1 条** → 输出连贯段落。
|
||||
- **事项 = 2 条** → **必须**用 1./2. 编号平列输出,每条一句完整陈述。不强制分主题子项,但仍需整理表达。
|
||||
- **事项 ≥ 3 条** → **必须**按语义归类为 2–4 个主题,使用下文双层格式。**照抄原结构 = 失败。**
|
||||
|
||||
即使原文已经写成「1. 做 X 2. 做 Y 3. 做 Z」,也要按主题重新归类,把同主题事项收到同一组下做 (a)(b) 子项。
|
||||
|
||||
**重要:只要存在 2 条及以上可区分事项,就必须编号。不编号 = 失败。**
|
||||
|
||||
常见主题组合(按内容自动选取):
|
||||
|
||||
- 工程类:「代码与功能 / 文档与配置 / 界面与交互 / 项目清理」「后端 / 前端 / 部署 / 提示词」
|
||||
|
||||
@@ -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 <Capsule />;
|
||||
}
|
||||
@@ -34,7 +35,7 @@ export function App({ isCapsule, isQa }: AppProps) {
|
||||
return <QaPanel />;
|
||||
}
|
||||
|
||||
const os = detectOS();
|
||||
const os = forcedOs ?? detectOS();
|
||||
// Windows 启动不应被权限探测阻塞首屏。
|
||||
const [gate, setGate] = useState<Gate>(isTauri ? 'checking' : 'ready');
|
||||
|
||||
@@ -173,7 +174,7 @@ export function App({ isCapsule, isQa }: AppProps) {
|
||||
}
|
||||
return (
|
||||
<HotkeySettingsProvider>
|
||||
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell />}
|
||||
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell os={os} />}
|
||||
{gate === 'ready' && <AutoUpdateGate />}
|
||||
</HotkeySettingsProvider>
|
||||
);
|
||||
|
||||
@@ -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<void>;
|
||||
/** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。
|
||||
* `channel` 显式指定查哪个渠道;省略时由 Rust 端回落到 prefs.update_channel。 */
|
||||
checkForUpdates: (channel?: UpdateChannel) => Promise<void>;
|
||||
/** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */
|
||||
installUpdate: () => Promise<void>;
|
||||
/** 关闭对话框(仅在非 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<AppUpdateMetadata | null>('app_check_update_with_channel', {
|
||||
timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
|
||||
channel: channel ?? null,
|
||||
});
|
||||
if (!metadata) {
|
||||
setStatus('none');
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<Phase>({ 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 (
|
||||
<Modal onClose={close} zIndex={60} width="min(440px, 100%)">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 12 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>{t('marketplace.oauth.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
title={t('common.close')}
|
||||
onClick={close}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: 8,
|
||||
display: 'inline-grid', placeItems: 'center',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink-2)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 16, lineHeight: 1,
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
{/* 固定最小高度 —— 各阶段共用,窗口尺寸恒定。 */}
|
||||
<div style={{ minHeight: 220, display: 'flex', flexDirection: 'column' }}>
|
||||
{phase.kind === 'starting' && (
|
||||
<div style={{ flex: 1, display: 'grid', placeItems: 'center', color: 'var(--ol-ink-3)', fontSize: 13 }}>
|
||||
{t('marketplace.oauth.generating')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === 'pending' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6 }}>
|
||||
{t('marketplace.oauth.browserHint', { uri: phase.verificationUri })}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12,
|
||||
padding: 18, borderRadius: 12,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--ol-font-mono)',
|
||||
fontSize: 22, fontWeight: 700,
|
||||
letterSpacing: 2,
|
||||
color: 'var(--ol-blue)',
|
||||
}}>{phase.userCode}</span>
|
||||
<Btn variant="ghost" size="sm" onClick={() => void copyCode()}>
|
||||
{copied ? t('marketplace.oauth.copied') : t('marketplace.oauth.copyBtn')}
|
||||
</Btn>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<Btn variant="ghost" size="sm" onClick={() => void openExternal(phase.verificationUri)}>
|
||||
{t('marketplace.oauth.openBrowserBtn')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" size="sm" onClick={close}>
|
||||
{t('marketplace.oauth.cancelBtn')}
|
||||
</Btn>
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 8, height: 8, borderRadius: 999,
|
||||
background: 'var(--ol-blue)', animation: 'ol-pulse 1.4s ease-in-out infinite',
|
||||
}} />
|
||||
{t('marketplace.oauth.waiting')}
|
||||
</div>
|
||||
<style>{`@keyframes ol-pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === 'success' && (
|
||||
<div style={{ flex: 1, display: 'grid', placeItems: 'center', textAlign: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 24, color: 'var(--ol-blue)', marginBottom: 8 }}>✓</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 650, color: 'var(--ol-ink)' }}>
|
||||
{t('marketplace.oauth.successAs', { login: phase.login })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.kind === 'error' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{
|
||||
padding: 12, borderRadius: 10,
|
||||
border: '0.5px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.06)',
|
||||
color: '#b91c1c',
|
||||
fontSize: 12, lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{phase.message}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Btn variant="ghost" size="sm" onClick={close}>{t('marketplace.oauth.closeBtn')}</Btn>
|
||||
<Btn variant="blue" size="sm" onClick={() => void begin()}>{t('marketplace.oauth.retryBtn')}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export const ICONS: Record<string, string> = {
|
||||
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
|
||||
|
||||
@@ -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)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -55,7 +55,7 @@ export function MarketplaceModal({ onClose }: MarketplaceModalProps) {
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
animation: 'ol-modal-pop .28s var(--ol-motion-spring)',
|
||||
animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,57 +1,29 @@
|
||||
// SettingsModal.tsx — centered sheet with sub-nav on the left.
|
||||
// SettingsModal.tsx — 居中弹窗,左侧单层侧栏。
|
||||
//
|
||||
// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 / 启动项 /
|
||||
// 开机自启)已从此弹窗移除,避免 "看似可点实际无效" 的负面体感。
|
||||
// 待 backend 就位后再补回(参见 issue #69)。
|
||||
// 重构(2026-05):原本是「外层弹窗侧栏 + 设置页内层侧栏」双层嵌套,用户点
|
||||
// 「设置」还要再面对第二个侧栏。现在拍平成单层 —— 通用 / 服务 / 隐私 / 高级 /
|
||||
// 个性化 / 关于 六个 tab + 帮助外链组。每个 tab 的内容见 pages/settings/。
|
||||
//
|
||||
// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 等)
|
||||
// 不在此弹窗出现。
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from './Icon';
|
||||
import { AboutUpdateControl, Settings as SettingsContent, Toggle, type SettingsSectionId } from '../pages/Settings';
|
||||
import { Row } from './ui/Row';
|
||||
import { SavedToast } from './SavedToast';
|
||||
import { useSavedToastListener } from '../lib/savedEvent';
|
||||
import { readFontScale, setFontScale, type FontScaleId } from '../lib/fontScale';
|
||||
import {
|
||||
exportErrorLog,
|
||||
fetchLatestBetaRelease,
|
||||
getUpdateChannel,
|
||||
openExternal,
|
||||
setUpdateChannel,
|
||||
type LatestBetaRelease,
|
||||
type UpdateChannel,
|
||||
} from '../lib/ipc';
|
||||
import { APP_VERSION } from '../lib/appVersion';
|
||||
import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate';
|
||||
import { openExternal } from '../lib/ipc';
|
||||
import type { OS } from './WindowChrome';
|
||||
import { GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
|
||||
import { AboutSection } from '../pages/settings/AboutSection';
|
||||
|
||||
// 把 Beta tag 名(如 "v1.3.4-1-beta-tauri" / "v1.3.2-3-beta-tauri")解析回 semver 版本号
|
||||
// "1.3.4-1" / "1.3.2-3"。失败返回 null(让 UI fallback 到字符串相等比对)。
|
||||
function parseVersionFromBetaTag(tag: string): string | null {
|
||||
const trimmed = tag.replace(/^v/, '').replace(/-beta-tauri$/, '');
|
||||
return /^\d+\.\d+\.\d+(-\d+)?$/.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
// 简化版 semver 比较 —— 只覆盖本仓库实际用到的两种形态:X.Y.Z 和 X.Y.Z-N。
|
||||
// 返回 true 表示 a > 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<ModalSectionId>('settings');
|
||||
const [section, setSection] = useState<SettingsSectionId>(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<Array<HTMLButtonElement | null>>([]);
|
||||
// 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill。
|
||||
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
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)',
|
||||
}}>
|
||||
|
||||
<div
|
||||
@@ -129,11 +92,11 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
border: '0.5px solid rgba(0,0,0,.08)',
|
||||
boxShadow: '0 30px 80px -20px rgba(15,17,22,.35), 0 0 0 0.5px rgba(0,0,0,.06)',
|
||||
display: 'flex', overflow: 'hidden',
|
||||
animation: 'ol-modal-pop .28s var(--ol-motion-spring)',
|
||||
animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
|
||||
position: 'relative',
|
||||
}}>
|
||||
|
||||
{/* sub-sidebar */}
|
||||
{/* ─── 单层侧栏 ────────────────────────────────────────────── */}
|
||||
<aside
|
||||
style={{
|
||||
width: 200, flexShrink: 0,
|
||||
@@ -143,70 +106,63 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}}>
|
||||
|
||||
{groups.map((g, gi) => (
|
||||
<div key={gi} style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1, paddingTop: gi === 1 ? 8 : 0, borderTop: gi === 1 ? '0.5px solid var(--ol-line-soft)' : 'none' }}>
|
||||
{gi === 0 && pillRect && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: pillRect.top,
|
||||
height: pillRect.height,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
|
||||
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{g.items.map((it, idx) => {
|
||||
const active = section === it.id && !it.external;
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
ref={gi === 0 ? (el => { firstGroupRefs.current[idx] = el; }) : undefined}
|
||||
onClick={() => {
|
||||
if (it.external && it.href) {
|
||||
void openExternal(it.href);
|
||||
} else {
|
||||
setSection(it.id as ModalSectionId);
|
||||
}
|
||||
}}
|
||||
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8, border: 0,
|
||||
background: 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 13,
|
||||
cursor: 'default', textAlign: 'left',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
|
||||
}}>
|
||||
{/* tab 组 */}
|
||||
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{pillRect && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: pillRect.top,
|
||||
height: pillRect.height,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
|
||||
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{TAB_ITEMS.map((it, idx) => {
|
||||
const active = section === it.id;
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
ref={el => { tabRefs.current[idx] = el; }}
|
||||
onClick={() => setSection(it.id as SettingsSectionId)}
|
||||
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
||||
style={navBtnStyle}>
|
||||
<Icon name={it.icon} size={14} />
|
||||
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Icon name={it.icon} size={14} />
|
||||
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
|
||||
{it.external && <Icon name="external" size={11} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{/* 外链组 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: 8, borderTop: '0.5px solid var(--ol-line-soft)' }}>
|
||||
{LINK_ITEMS.map(it => (
|
||||
<button
|
||||
key={it.id}
|
||||
onClick={() => { if (it.href) void openExternal(it.href); }}
|
||||
className="ol-nav-btn"
|
||||
style={navBtnStyle}>
|
||||
<Icon name={it.icon} size={14} />
|
||||
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
|
||||
<Icon name="external" size={11} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* content — 父容器 overflow:hidden + 列向 flex;X 和 h2 固定在头部,
|
||||
只有最里层的 scroll wrapper 真正滚动。这样模态左 sidebar、关闭按钮、
|
||||
section 标题都不会跟着内容一起飘。 */}
|
||||
{/* ─── 内容区 ──────────────────────────────────────────────
|
||||
父容器 overflow:hidden + 列向 flex;关闭按钮、section 标题固定在头部,
|
||||
只有最里层的 scroll wrapper 真正滚动。 */}
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* "已保存"toast 在内容区右上角;right:54 避开 28×28 关闭按钮 + 12px gap。
|
||||
弹窗内用 absolute 锚内容区、从上方滑入 / 滑回 —— 外层 overflow:hidden
|
||||
会把它裁在面板顶边,读感即"从屏幕外上方来、回上方去"。
|
||||
CredentialField 等通过 emitSaved 发事件,useSavedToastListener 接收。 */}
|
||||
{/* "已保存" toast:right:54 避开 28×28 关闭按钮 + 12px gap。 */}
|
||||
<SavedToast
|
||||
saveState={savedToast.state}
|
||||
message={savedToast.message}
|
||||
@@ -226,373 +182,41 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.05)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
title={t('common.close')}>
|
||||
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
|
||||
<h2 style={{ margin: 0, padding: '22px 28px 8px', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', flexShrink: 0 }}>{t(`modal.sections.${section}`)}</h2>
|
||||
<h2 style={{ margin: 0, padding: '22px 28px 8px', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', flexShrink: 0 }}>
|
||||
{t(`modal.sections.${section}`)}
|
||||
</h2>
|
||||
|
||||
{section === 'settings' ? (
|
||||
// SettingsContent 自己接管 flex:1 + 内部右栏 scroll,外层不能再加 overflow:auto。
|
||||
<div style={{ flex: 1, minHeight: 0, padding: '10px 28px 28px', display: 'flex', flexDirection: 'column' }}>
|
||||
<SettingsContent embedded initialSection={initialSettingsSection} />
|
||||
<div
|
||||
className="ol-thinscroll"
|
||||
style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '10px 28px 28px' }}>
|
||||
{/* key=section 让切 tab 时整块重挂载,ol-tab-fade 轻微淡入。 */}
|
||||
<div
|
||||
key={section}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 12, animation: 'ol-tab-fade 0.2s var(--ol-motion-soft)' }}>
|
||||
{section === 'general' && <GeneralTab />}
|
||||
{section === 'services' && <ServicesTab />}
|
||||
{section === 'privacy' && <PrivacyTab />}
|
||||
{section === 'advanced' && <AdvancedTab />}
|
||||
{section === 'about' && <AboutSection />}
|
||||
</div>
|
||||
) : (
|
||||
// personalize / about 短内容:单一 scroll wrapper,超出时本块滚动。
|
||||
<div className="ol-thinscroll" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '10px 28px 28px' }}>
|
||||
{section === 'personalize' && <PersonalizeSection />}
|
||||
{section === 'about' && <AboutMini />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes ol-modal-fade {
|
||||
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%); }
|
||||
}
|
||||
@keyframes ol-modal-pop {
|
||||
from { opacity: 0; transform: translateY(8px) scale(.98); filter: blur(8px); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalizeSection() {
|
||||
const { t } = useTranslation();
|
||||
// 玻璃强度持久化到 localStorage,并实时写入 CSS var --ol-glass-blur。
|
||||
// 这是 CSS-only 的层(影响 backdrop-filter 的内层强度);macOS NSVisualEffectView
|
||||
// 是另一层,由 Tauri 在窗口创建时一次性配置,运行时改动需要重启 App。
|
||||
const [blur, setBlur] = useState<number>(() => {
|
||||
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<FontScaleId>(() => 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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Row label={t('modal.personalize.font')} desc={t('modal.personalize.fontDesc')}>
|
||||
<div style={{ display: 'flex', gap: 4, padding: 2, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
|
||||
{fontOptions.map(([id, label]) => {
|
||||
const selected = fontScale === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => applyFontScaleChoice(id)}
|
||||
style={{
|
||||
minWidth: 64,
|
||||
height: 28,
|
||||
border: 0,
|
||||
borderRadius: 6,
|
||||
background: selected ? '#fff' : 'transparent',
|
||||
color: selected ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? 600 : 500,
|
||||
cursor: 'default',
|
||||
boxShadow: selected ? '0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
|
||||
padding: '0 12px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label={t('modal.personalize.blur')} desc={t('modal.personalize.blurDesc')}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="48"
|
||||
value={blur}
|
||||
onChange={e => setBlur(Number(e.target.value))}
|
||||
style={{ width: 200, accentColor: 'var(--ol-blue)' }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, fontFamily: 'var(--ol-font-mono)', color: 'var(--ol-ink-3)', minWidth: 36 }}>
|
||||
{blur}px
|
||||
</span>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutMini() {
|
||||
const { t } = useTranslation();
|
||||
const [qqCopied, setQqCopied] = useState(false);
|
||||
const qqCopiedRef = useRef<number | null>(null);
|
||||
const [exportStatus, setExportStatus] = useState<'idle' | 'busy' | 'ok' | 'err'>('idle');
|
||||
const [exportMessage, setExportMessage] = useState<string>('');
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
|
||||
<img src="AppIcon.png" alt="" style={{ width: 56, height: 56, borderRadius: 13, boxShadow: '0 4px 10px rgba(0,0,0,.10), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 17, fontWeight: 600 }}>OpenLess</div>
|
||||
<AboutUpdateControl tagline={t('modal.about.tagline')} />
|
||||
</div>
|
||||
</div>
|
||||
<Row label={t('modal.about.source')}>
|
||||
<button
|
||||
style={btnGhost}
|
||||
onClick={() => openExternal('https://github.com/appergb/openless')}
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
</Row>
|
||||
<Row label={t('modal.about.docs')}>
|
||||
<button
|
||||
style={btnGhost}
|
||||
onClick={() => openExternal('https://github.com/appergb/openless#readme')}
|
||||
>
|
||||
{t('modal.about.docsBtn')}
|
||||
</button>
|
||||
</Row>
|
||||
<Row label={t('modal.about.feedback')}>
|
||||
<button
|
||||
style={btnGhost}
|
||||
onClick={() => openExternal('https://github.com/appergb/openless/issues')}
|
||||
>
|
||||
{t('modal.about.feedbackBtn')}
|
||||
</button>
|
||||
</Row>
|
||||
<Row label={t('modal.about.qq')} desc={t('modal.about.qqDesc')}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<kbd style={{
|
||||
padding: '4px 10px', fontSize: 12, fontFamily: 'var(--ol-font-mono)',
|
||||
borderRadius: 6, background: 'var(--ol-surface-2)',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
boxShadow: '0 1px 0 rgba(0,0,0,0.04)',
|
||||
color: 'var(--ol-ink-2)',
|
||||
}}>1078960553</kbd>
|
||||
<button onClick={copyQq} title={t('modal.about.copyQq')} style={btnGhost}>
|
||||
<Icon name="copy" size={14} />
|
||||
</button>
|
||||
{qqCopied && <span style={{ fontSize: 11, color: 'var(--ol-ok)', whiteSpace: 'nowrap' }}>{t('common.copied')}</span>}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label={t('modal.about.exportErrorLog')} desc={t('modal.about.exportErrorLogDesc')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button style={btnGhost} onClick={onExportLog} disabled={exportStatus === 'busy'}>
|
||||
{exportStatus === 'busy' ? t('modal.about.exporting') : t('modal.about.exportErrorLogBtn')}
|
||||
</button>
|
||||
{exportStatus === 'ok' && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ok)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 220 }}
|
||||
title={exportMessage}>
|
||||
{t('modal.about.exportSuccess')}
|
||||
</span>
|
||||
)}
|
||||
{exportStatus === 'err' && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-err)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 220 }}
|
||||
title={exportMessage}>
|
||||
{t('modal.about.exportFailed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label={t('modal.about.privacy')} desc={t('modal.about.privacyDesc')}>
|
||||
<span style={{ fontSize: 11, padding: '3px 8px', borderRadius: 999, background: 'var(--ol-blue-soft)', color: 'var(--ol-blue)', fontWeight: 500 }}>{t('modal.about.localFirst')}</span>
|
||||
</Row>
|
||||
<BetaChannelControl />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<UpdateChannel>('stable');
|
||||
const [latest, setLatest] = useState<LatestBetaRelease | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'fetching' | 'empty' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
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 (
|
||||
<>
|
||||
<Row label={t('settings.about.betaChannelLabel')} desc={t('settings.about.betaChannelDesc')}>
|
||||
<Toggle on={channel === 'beta'} onToggle={onToggle} />
|
||||
</Row>
|
||||
{channel === 'beta' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-3)', marginTop: -4, marginBottom: 12, lineHeight: 1.6 }}>
|
||||
{status === 'fetching' && <span>{t('settings.about.betaChannelFetching')}</span>}
|
||||
{status === 'empty' && <span>{t('settings.about.betaChannelNoBeta')}</span>}
|
||||
{status === 'error' && (
|
||||
<span style={{ color: 'var(--ol-err)' }} title={errorMessage}>
|
||||
{t('settings.about.betaChannelFetchError')}
|
||||
</span>
|
||||
)}
|
||||
{status === 'idle' && latest && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>
|
||||
{t('settings.about.betaChannelLatestPrefix')} <code style={{ fontFamily: 'var(--ol-font-mono)' }}>{latest.tagName}</code>
|
||||
</span>
|
||||
{comparison === 'up-to-date' && (
|
||||
<span style={{
|
||||
fontSize: 11, padding: '2px 8px', borderRadius: 999,
|
||||
background: 'var(--ol-blue-soft)', color: 'var(--ol-blue)', fontWeight: 500,
|
||||
}}>{t('settings.about.betaChannelUpToDate')}</span>
|
||||
)}
|
||||
{comparison === 'newer' && (
|
||||
<button
|
||||
style={{ ...btnGhost, borderColor: 'var(--ol-blue)', color: 'var(--ol-blue)' }}
|
||||
onClick={() => void updater.checkForUpdates()}
|
||||
disabled={updater.checking || updater.busy}
|
||||
title={t('settings.about.betaChannelUpdateNowTitle')}
|
||||
>
|
||||
{updater.checking
|
||||
? t('settings.about.betaChannelChecking')
|
||||
: t('settings.about.betaChannelUpdateNow')}
|
||||
</button>
|
||||
)}
|
||||
<button style={btnGhost} onClick={fetchBeta} title={t('settings.about.betaChannelRefresh')}>
|
||||
<Icon name="refresh" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'idle' && !latest && (
|
||||
<button style={btnGhost} onClick={fetchBeta}>
|
||||
{t('settings.about.betaChannelFetchBtn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isDialogStatus(updater.status) && (
|
||||
<UpdateDialog
|
||||
status={updater.status}
|
||||
version={updater.version}
|
||||
progress={updater.progress}
|
||||
downloaded={updater.downloaded}
|
||||
contentLength={updater.contentLength}
|
||||
onInstall={() => 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)',
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.22)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
zIndex,
|
||||
padding: 20,
|
||||
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => 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}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{option.label}
|
||||
</span>
|
||||
{option.trailing}
|
||||
{isSelected && <Icon name="check" size={12} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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).',
|
||||
@@ -765,8 +779,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:',
|
||||
@@ -807,25 +822,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',
|
||||
@@ -833,10 +839,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',
|
||||
|
||||
@@ -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: '押すと音声キャプチャを開始(グローバル)。アクセシビリティ権限が必要です。',
|
||||
@@ -767,8 +781,9 @@ export const ja: typeof zhCN = {
|
||||
privacy: 'プライバシー',
|
||||
privacyDesc: 'すべてのデータはローカルに保存。クラウド API は録音を保持しません。',
|
||||
localFirst: 'ローカル優先',
|
||||
linksTitle: 'ドキュメント',
|
||||
betaChannelLabel: 'Beta チャンネルに参加',
|
||||
betaChannelDesc: '有効にすると Beta 更新を受信。不安定な場合があります。',
|
||||
betaChannelDesc: '新機能をいち早く体験',
|
||||
betaChannelFetching: '最新 Beta 版を取得中…',
|
||||
betaChannelFetchBtn: '最新 Beta を確認',
|
||||
betaChannelLatestPrefix: '最新 Beta:',
|
||||
@@ -809,25 +824,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: '小',
|
||||
@@ -835,10 +841,6 @@ export const ja: typeof zhCN = {
|
||||
fontLarge: '大',
|
||||
blur: 'すりガラス強度',
|
||||
blurDesc: 'ウィンドウ内側の backdrop-filter 強度に影響(macOS のシステムフロスト層が動かない場合に調整)。',
|
||||
startupOpen: '起動時に開く',
|
||||
startupOverview: '概要',
|
||||
startupLast: '前回の位置',
|
||||
startupAtBoot: '起動時に自動起動',
|
||||
},
|
||||
about: {
|
||||
tagline: '自然に話し、きれいに書く',
|
||||
|
||||
@@ -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: '누르면 음성 캡처 시작(전역). 접근성 권한이 필요합니다.',
|
||||
@@ -767,8 +781,9 @@ export const ko: typeof zhCN = {
|
||||
privacy: '프라이버시',
|
||||
privacyDesc: '모든 데이터는 로컬에 저장됩니다. 클라우드 API 는 녹음을 보관하지 않습니다.',
|
||||
localFirst: '로컬 우선',
|
||||
linksTitle: '문서 링크',
|
||||
betaChannelLabel: 'Beta 채널 참여',
|
||||
betaChannelDesc: '활성화 시 Beta 업데이트 수신. 불안정할 수 있으며 얼리 어답터만 권장.',
|
||||
betaChannelDesc: '새 기능 미리 체험',
|
||||
betaChannelFetching: '최신 Beta 버전을 가져오는 중…',
|
||||
betaChannelFetchBtn: '최신 Beta 확인',
|
||||
betaChannelLatestPrefix: '최신 Beta:',
|
||||
@@ -809,25 +824,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: '소',
|
||||
@@ -835,10 +841,6 @@ export const ko: typeof zhCN = {
|
||||
fontLarge: '대',
|
||||
blur: '서리유리 강도',
|
||||
blurDesc: '창 내부 backdrop-filter 강도에 영향(macOS 시스템 서리 레이어가 작동하지 않을 때 조정).',
|
||||
startupOpen: '시작 시 열기',
|
||||
startupOverview: '개요',
|
||||
startupLast: '마지막 위치',
|
||||
startupAtBoot: '부팅 시 자동 시작',
|
||||
},
|
||||
about: {
|
||||
tagline: '자연스럽게 말하고, 정확하게 작성하세요',
|
||||
|
||||
@@ -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: '按下开始捕获语音,全局生效(需辅助功能权限)。',
|
||||
@@ -763,8 +777,9 @@ export const zhCN = {
|
||||
privacy: '隐私',
|
||||
privacyDesc: '所有数据仅保存在本机,云端 API 不保留录音。',
|
||||
localFirst: '本地优先',
|
||||
linksTitle: '文档链接',
|
||||
betaChannelLabel: '加入 Beta 渠道',
|
||||
betaChannelDesc: '开启后自动接收 Beta 版本。可能不稳定,仅推荐愿意尝鲜的用户。',
|
||||
betaChannelDesc: '抢先体验新功能',
|
||||
betaChannelFetching: '正在获取最新 Beta 版本…',
|
||||
betaChannelFetchBtn: '查询最新 Beta',
|
||||
betaChannelLatestPrefix: '最新 Beta:',
|
||||
@@ -805,25 +820,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: '小',
|
||||
@@ -831,10 +837,6 @@ export const zhCN = {
|
||||
fontLarge: '大',
|
||||
blur: '毛玻璃强度',
|
||||
blurDesc: '影响窗口内层 backdrop-filter 强度(macOS 系统磨砂层无法运行时调)。',
|
||||
startupOpen: '启动时打开',
|
||||
startupOverview: '概览',
|
||||
startupLast: '上次位置',
|
||||
startupAtBoot: '开机自启',
|
||||
},
|
||||
about: {
|
||||
tagline: '自然说话,完美书写',
|
||||
|
||||
@@ -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: '按下即開始捕獲語音,全局生效。需要授予輔助功能權限。',
|
||||
@@ -765,8 +779,9 @@ export const zhTW: typeof zhCN = {
|
||||
privacy: '隱私',
|
||||
privacyDesc: '所有資料僅保存在本機,雲端 API 不保留錄音。',
|
||||
localFirst: '本地優先',
|
||||
linksTitle: '文件連結',
|
||||
betaChannelLabel: '加入 Beta 渠道',
|
||||
betaChannelDesc: '開啟後自動接收 Beta 版本。可能不穩定,僅推薦願意嘗鮮的用戶。',
|
||||
betaChannelDesc: '搶先體驗新功能',
|
||||
betaChannelFetching: '正在獲取最新 Beta 版本…',
|
||||
betaChannelFetchBtn: '查詢最新 Beta',
|
||||
betaChannelLatestPrefix: '最新 Beta:',
|
||||
@@ -807,25 +822,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: '小',
|
||||
@@ -833,10 +839,6 @@ export const zhTW: typeof zhCN = {
|
||||
fontLarge: '大',
|
||||
blur: '毛玻璃強度',
|
||||
blurDesc: '影響窗口內層 backdrop-filter 強度(macOS 系統磨砂層無法運行時調)。',
|
||||
startupOpen: '啓動時打開',
|
||||
startupOverview: '概覽',
|
||||
startupLast: '上次位置',
|
||||
startupAtBoot: '開機自啓',
|
||||
},
|
||||
about: {
|
||||
tagline: '自然說話,完美書寫',
|
||||
|
||||
@@ -141,7 +141,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 和模型版本号。
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App isCapsule={isCapsule} isQa={isQa} />
|
||||
<App isCapsule={isCapsule} isQa={isQa} forcedOs={osQuery} />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string | null>(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<OAuthPhase>({ phase: 'idle' });
|
||||
// GitHub 登录弹窗开关 —— 登录流程交给共用的 <GithubLoginModal />。
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
// 当前用户赞过的 pack id 集合 —— 用于红心渲染 + 「我赞过的」过滤。
|
||||
// 进入 marketplace 时拉一次;点星后本地 mutate。
|
||||
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
|
||||
@@ -402,67 +394,11 @@ 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) => {
|
||||
void updatePrefs(current => ({ ...current, marketplaceDevLogin: nextLogin }));
|
||||
setActionMsg({ kind: 'ok', text: t('marketplace.oauth.successAs', { login: nextLogin }) });
|
||||
}, [updatePrefs, t]);
|
||||
|
||||
const sortPills = useMemo<Array<{ id: SortMode; label: string }>>(
|
||||
() => [
|
||||
@@ -901,8 +837,7 @@ export function Marketplace() {
|
||||
<button
|
||||
type="button"
|
||||
title={currentLogin ? t('marketplace.oauth.reloginTooltip', { login: currentLogin }) : t('marketplace.oauth.loginTooltip')}
|
||||
onClick={() => void beginGithubLogin()}
|
||||
disabled={oauth.phase === 'starting' || oauth.phase === 'pending'}
|
||||
onClick={() => setShowLogin(true)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '5px 10px', borderRadius: 9,
|
||||
@@ -910,9 +845,8 @@ export function Marketplace() {
|
||||
background: currentLogin ? 'var(--ol-blue-soft)' : 'var(--ol-surface)',
|
||||
color: currentLogin ? 'var(--ol-blue)' : 'var(--ol-ink-3)',
|
||||
fontSize: 12, fontWeight: 650,
|
||||
cursor: (oauth.phase === 'starting' || oauth.phase === 'pending') ? 'wait' : 'pointer',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: (oauth.phase === 'starting' || oauth.phase === 'pending') ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
@@ -1070,195 +1004,17 @@ export function Marketplace() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* GitHub OAuth Device Flow 弹框(叠在「我的发布」之上)*/}
|
||||
{oauth.phase !== 'idle' && (
|
||||
<Modal onClose={() => {
|
||||
// 关闭即放弃;正在 pending 也允许取消
|
||||
setOauth({ phase: 'idle' });
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 12 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>{t('marketplace.oauth.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
title={t('common.close')}
|
||||
onClick={() => setOauth({ phase: 'idle' })}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: 8,
|
||||
display: 'inline-grid', placeItems: 'center',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-surface)',
|
||||
color: 'var(--ol-ink-2)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 16, lineHeight: 1,
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
{oauth.phase === 'starting' && (
|
||||
<div style={{ padding: '24px 8px', textAlign: 'center', color: 'var(--ol-ink-3)', fontSize: 13 }}>
|
||||
{t('marketplace.oauth.generating')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauth.phase === 'pending' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6 }}>
|
||||
{(() => {
|
||||
const parts = t('marketplace.oauth.browserHint', { uri: ' URI ' }).split(' URI ');
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<span style={{ fontFamily: 'var(--ol-font-mono)', fontSize: 12, padding: '1px 5px', background: 'var(--ol-surface-2)', borderRadius: 4 }}>{oauth.verificationUri}</span>
|
||||
{parts[1] ?? ''}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12,
|
||||
padding: 18, borderRadius: 12,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
background: 'var(--ol-surface-2)',
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--ol-font-mono)',
|
||||
fontSize: 22, fontWeight: 700,
|
||||
letterSpacing: 2,
|
||||
color: 'var(--ol-blue)',
|
||||
}}>{oauth.userCode}</span>
|
||||
<Btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
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')}
|
||||
</Btn>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<Btn variant="ghost" size="sm" onClick={() => void openExternal(oauth.verificationUri)}>
|
||||
{t('marketplace.oauth.openBrowserBtn')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" size="sm" onClick={() => setOauth({ phase: 'idle' })}>
|
||||
{t('marketplace.oauth.cancelBtn')}
|
||||
</Btn>
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 8, height: 8, borderRadius: 999,
|
||||
background: 'var(--ol-blue)', animation: 'ol-pulse 1.4s ease-in-out infinite',
|
||||
}} />
|
||||
{t('marketplace.oauth.waiting')}
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes ol-pulse {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauth.phase === 'success' && (
|
||||
<div style={{ padding: '24px 8px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 24, color: 'var(--ol-blue)', marginBottom: 8 }}>✓</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 650, color: 'var(--ol-ink)' }}>{t('marketplace.oauth.successAs', { login: oauth.login })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oauth.phase === 'error' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{
|
||||
padding: 12, borderRadius: 10,
|
||||
border: '0.5px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.06)',
|
||||
color: '#b91c1c',
|
||||
fontSize: 12, lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{oauth.message}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Btn variant="ghost" size="sm" onClick={() => setOauth({ phase: 'idle' })}>{t('marketplace.oauth.closeBtn')}</Btn>
|
||||
<Btn variant="blue" size="sm" onClick={() => void beginGithubLogin()}>{t('marketplace.oauth.retryBtn')}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{/* GitHub 登录弹窗 */}
|
||||
{showLogin && (
|
||||
<GithubLoginModal
|
||||
onClose={() => setShowLogin(false)}
|
||||
onSuccess={onLoginSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({
|
||||
children,
|
||||
onClose,
|
||||
zIndex = 50,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
/** 默认 50;多层叠加时(如上传 picker 在「我的发布」之上)传更大的值。*/
|
||||
zIndex?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="ol-modal-backdrop"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.22)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
zIndex,
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="ol-modal-card"
|
||||
onClick={e => 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}
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes ol-modal-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes ol-modal-card-in {
|
||||
from { opacity: 0; transform: scale(.96) translateY(4px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
.ol-modal-backdrop {
|
||||
animation: ol-modal-backdrop-in .14s ease-out;
|
||||
will-change: opacity;
|
||||
}
|
||||
.ol-modal-card {
|
||||
animation: ol-modal-card-in .18s cubic-bezier(.34,1.56,.64,1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(state: string, t: (key: string) => string): string {
|
||||
switch (state) {
|
||||
case 'pending': return t('marketplace.state.pending');
|
||||
|
||||
@@ -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)',
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<number | null>(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 (
|
||||
<>
|
||||
{/* ─── 版本信息 + 检查更新(正式版)─────────────────────────────── */}
|
||||
<Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<img
|
||||
src="AppIcon.png"
|
||||
alt=""
|
||||
style={{ width: 56, height: 56, borderRadius: 13, boxShadow: '0 4px 10px rgba(0,0,0,.10), 0 0 0 0.5px rgba(0,0,0,.06)' }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 600 }}>OpenLess</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', marginTop: 2 }}>
|
||||
{t('modal.about.tagline')} · {APP_VERSION_LABEL}
|
||||
</div>
|
||||
</div>
|
||||
{/* 图标右上方:查正式版的检查更新按钮。Beta 渠道在「高级」页。 */}
|
||||
<CheckUpdateButton channel="stable" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ─── 个性化(字体大小)—— 原 personalize tab 并入此处 ──────────── */}
|
||||
<Card>
|
||||
<SectionTitle>{t('modal.sections.personalize')}</SectionTitle>
|
||||
<FontSizeRow />
|
||||
</Card>
|
||||
|
||||
{/* ─── 文档链接 ─────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<SectionTitle>{t('settings.about.linksTitle')}</SectionTitle>
|
||||
<Row label={t('modal.about.source')}>
|
||||
<button style={btnGhost} onClick={() => openExternal('https://github.com/appergb/openless')}>
|
||||
GitHub
|
||||
</button>
|
||||
</Row>
|
||||
<Row label={t('modal.about.docs')}>
|
||||
<button style={btnGhost} onClick={() => openExternal('https://github.com/appergb/openless#readme')}>
|
||||
{t('modal.about.docsBtn')}
|
||||
</button>
|
||||
</Row>
|
||||
<Row label={t('modal.about.feedback')}>
|
||||
<button style={btnGhost} onClick={() => openExternal('https://github.com/appergb/openless/issues')}>
|
||||
{t('modal.about.feedbackBtn')}
|
||||
</button>
|
||||
</Row>
|
||||
<Row label={t('modal.about.qq')}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<kbd style={{
|
||||
padding: '4px 10px', fontSize: 12, fontFamily: 'var(--ol-font-mono)',
|
||||
borderRadius: 6, background: 'var(--ol-surface-2)',
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
boxShadow: '0 1px 0 rgba(0,0,0,0.04)',
|
||||
color: 'var(--ol-ink-2)',
|
||||
}}>1078960553</kbd>
|
||||
<button onClick={copyQq} title={t('modal.about.copyQq')} style={btnGhost}>
|
||||
<Icon name="copy" size={14} />
|
||||
</button>
|
||||
{qqCopied && <span style={{ fontSize: 11, color: 'var(--ol-ok)', whiteSpace: 'nowrap' }}>{t('common.copied')}</span>}
|
||||
</div>
|
||||
</Row>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 字体大小 —— 整体缩放界面字号,立即生效(fontScale.ts 走 html.style.zoom)。
|
||||
function FontSizeRow() {
|
||||
const { t } = useTranslation();
|
||||
const [fontScale, setFontScaleState] = useState<FontScaleId>(() => 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 (
|
||||
<Row label={t('modal.personalize.font')}>
|
||||
<div style={{ display: 'flex', gap: 4, padding: 2, background: 'rgba(0,0,0,0.04)', borderRadius: 8 }}>
|
||||
{fontOptions.map(([id, label]) => {
|
||||
const selected = fontScale === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => applyFontScaleChoice(id)}
|
||||
style={{
|
||||
minWidth: 64,
|
||||
height: 28,
|
||||
border: 0,
|
||||
borderRadius: 6,
|
||||
background: selected ? '#fff' : 'transparent',
|
||||
color: selected ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 12,
|
||||
fontWeight: selected ? 600 : 500,
|
||||
cursor: 'default',
|
||||
boxShadow: selected ? '0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
|
||||
padding: '0 12px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
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)',
|
||||
};
|
||||
@@ -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 (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>{tagline} 路 {APP_VERSION_LABEL}</span>
|
||||
<Btn variant="ghost" size="sm" onClick={u.checkForUpdates} disabled={u.checking || u.busy}>
|
||||
{u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
|
||||
</Btn>
|
||||
</div>
|
||||
{u.status === 'none' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 4 }}>
|
||||
{t('settings.about.upToDate')}
|
||||
</div>
|
||||
)}
|
||||
{u.status === 'error' && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-err)' }}>
|
||||
{t('settings.about.updateError')}
|
||||
</div>
|
||||
{u.errorMessage && (
|
||||
<div style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', marginTop: 2, wordBreak: 'break-word' }}>
|
||||
{u.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<Btn variant="ghost" size="sm" onClick={u.checkForUpdates} style={{ marginTop: 4 }}>
|
||||
{t('settings.about.retryBtn') ?? t('common.retry') ?? '重试'}
|
||||
</Btn>
|
||||
</div>
|
||||
)}
|
||||
{isDialogStatus(u.status) && (
|
||||
<UpdateDialog
|
||||
status={u.status}
|
||||
version={u.version}
|
||||
progress={u.progress}
|
||||
downloaded={u.downloaded}
|
||||
contentLength={u.contentLength}
|
||||
onInstall={u.installUpdate}
|
||||
onClose={u.dismissDialog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<UpdateChannel>('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 (
|
||||
<Card>
|
||||
<SectionTitle>{t('settings.about.betaChannelLabel')}</SectionTitle>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, paddingTop: 2 }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--ol-ink-3)' }}>
|
||||
{t('settings.about.betaChannelDesc')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{channel === 'beta' && <CheckUpdateButton channel="beta" />}
|
||||
<Toggle on={channel === 'beta'} onToggle={onToggle} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 检查更新按钮 —— 关于页查正式版(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, updater]);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
onClick={() => void updater.checkForUpdates(channel)}
|
||||
disabled={checking || busy}
|
||||
title={
|
||||
failed
|
||||
? (updater.errorMessage ?? t('settings.about.updateError'))
|
||||
: upToDate
|
||||
? t('settings.about.upToDate')
|
||||
: undefined
|
||||
}
|
||||
style={{ ...checkBtnStyle, color, opacity: checking || busy ? 0.7 : 1 }}
|
||||
>
|
||||
<Icon
|
||||
name={iconName}
|
||||
size={12}
|
||||
style={checking ? { animation: 'ol-spin 0.8s linear infinite' } : undefined}
|
||||
/>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</button>
|
||||
{isDialogStatus(status) && (
|
||||
<UpdateDialog
|
||||
status={status}
|
||||
version={updater.version}
|
||||
progress={updater.progress}
|
||||
downloaded={updater.downloaded}
|
||||
contentLength={updater.contentLength}
|
||||
onInstall={() => 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)',
|
||||
};
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 空字符串时回滚到默认值。
|
||||
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 (
|
||||
<Card>
|
||||
<SectionTitle>{t('settings.dataStorage.title')}</SectionTitle>
|
||||
<SettingRow label={t('settings.recording.historyRetentionLabel')}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
value={prefs.historyRetentionDays}
|
||||
onChange={e => onHistoryRetentionChange(e.target.value)}
|
||||
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.recording.historyMaxEntriesLabel')}>
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
max={200}
|
||||
placeholder="200"
|
||||
value={prefs.historyMaxEntries ?? ''}
|
||||
onChange={e => onHistoryMaxEntriesChange(e.target.value)}
|
||||
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.recording.polishContextWindowLabel')}>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={60}
|
||||
value={prefs.polishContextWindowMinutes}
|
||||
onChange={e => onPolishContextWindowChange(e.target.value)}
|
||||
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<string>('');
|
||||
const exportTimerRef = useRef<number | null>(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 (
|
||||
<Card>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<SectionTitle>{t('settings.debug.title')}</SectionTitle>
|
||||
<SettingRow label={t('settings.recording.recordAudioForDebugLabel')}>
|
||||
<Toggle on={prefs.recordAudioForDebug} onToggle={onRecordAudioForDebugChange} />
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.recording.audioRecordingMaxEntriesLabel')}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
placeholder="200"
|
||||
value={prefs.audioRecordingMaxEntries ?? ''}
|
||||
onChange={e => onAudioRecordingMaxEntriesChange(e.target.value)}
|
||||
style={{ ...inputStyle, width: 80, textAlign: 'right' }}
|
||||
disabled={!prefs.recordAudioForDebug}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('modal.about.exportErrorLog')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Btn variant="ghost" size="sm" disabled={exportStatus === 'busy'} onClick={onExportLog}>
|
||||
{exportStatus === 'busy' ? t('modal.about.exporting') : t('modal.about.exportErrorLogBtn')}
|
||||
</Btn>
|
||||
{exportStatus === 'ok' && (
|
||||
<span
|
||||
style={{ fontSize: 11, color: 'var(--ol-ok)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 220 }}
|
||||
title={exportMessage}
|
||||
>
|
||||
{t('modal.about.exportSuccess')}
|
||||
</span>
|
||||
)}
|
||||
{exportStatus === 'err' && (
|
||||
<span
|
||||
style={{ fontSize: 11, color: 'var(--ol-err)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 220 }}
|
||||
title={exportMessage}
|
||||
>
|
||||
{t('modal.about.exportFailed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -46,9 +46,8 @@ export function LanguageSection() {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.language.title')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>{t('settings.language.desc')}</div>
|
||||
<SettingRow label={t('settings.language.label')} desc={t('settings.language.labelDesc')}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>{t('settings.language.title')}</div>
|
||||
<SettingRow label={t('settings.language.label')}>
|
||||
<SelectLite
|
||||
value={pref}
|
||||
onChange={next => apply(next as SupportedLocale | typeof FOLLOW_SYSTEM)}
|
||||
@@ -57,9 +56,6 @@ export function LanguageSection() {
|
||||
style={{ maxWidth: 220, minWidth: 200 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 8, lineHeight: 1.6 }}>
|
||||
{t('settings.language.restartHint')}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+23
-82
@@ -1,5 +1,5 @@
|
||||
// 高级设置:流式输入开关 / 同步剪贴板 / 本地 ASR 模型启用与禁用。
|
||||
// 拆出自 Settings.tsx,逻辑零改动;i18n key 全部保持 `settings.advanced.*`。
|
||||
// 高级 → 本地模型:本地 ASR 推理引擎的启用 / 禁用 + 模型下载管理。
|
||||
// 自 Settings.tsx 的 AdvancedSection 拆出(流式输入已挪到「录音与输入」),逻辑零改动。
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -10,13 +10,12 @@ import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||
import { Btn, Card } from '../_atoms';
|
||||
import { SettingRow, Toggle, type AsrPresetId } from './shared';
|
||||
|
||||
export function AdvancedSection() {
|
||||
export function LocalModelSection() {
|
||||
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);
|
||||
@@ -109,64 +108,15 @@ export function AdvancedSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 流式输入(全平台 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,互相不显示对方平台的细节。 */}
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
{t(isLinux
|
||||
? 'settings.advanced.streamingInsertTitleLinux'
|
||||
: 'settings.advanced.streamingInsertTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 10 }}>
|
||||
{t('settings.advanced.streamingInsertDesc')}
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('settings.advanced.streamingInsertLabel')}
|
||||
desc={t(
|
||||
isMac
|
||||
? 'settings.advanced.streamingInsertHintMac'
|
||||
: isWin
|
||||
? 'settings.advanced.streamingInsertHintWindows'
|
||||
: 'settings.advanced.streamingInsertHintLinux'
|
||||
)}>
|
||||
<Toggle
|
||||
on={!!prefs?.streamingInsert}
|
||||
onToggle={(next) => {
|
||||
if (prefs) void updatePrefs({ ...prefs, streamingInsert: next });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.advanced.streamingInsertSaveClipboardLabel')}
|
||||
desc={t('settings.advanced.streamingInsertSaveClipboardHint')}>
|
||||
<Toggle
|
||||
on={!!prefs?.streamingInsertSaveClipboard}
|
||||
onToggle={(next) => {
|
||||
if (prefs) void updatePrefs({ ...prefs, streamingInsertSaveClipboard: next });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。
|
||||
Windows:标题区整体灰显 —— "本地 ASR 模型(实验性)" 在 Win 上几乎只有
|
||||
Qwen3 占位、本平台暂不支持;Foundry 走的是另一条独立路径,不属于"实验性"
|
||||
框架。灰显视觉让用户知道这条"实验性"主线在 Win 不可用,关注点转到下方
|
||||
{/* 标题 + 右上角 inline 警告小字。
|
||||
Windows:标题区整体灰显 —— 「本地 ASR 模型(实验性)」在 Win 上几乎只有
|
||||
Qwen3 占位、本平台暂不支持;Foundry 走的是另一条独立路径,不属于「实验性」
|
||||
框架。灰显视觉让用户知道这条「实验性」主线在 Win 不可用,关注点转到下方
|
||||
Foundry 行。 */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||
<div style={{ minWidth: 0, opacity: isWin ? 0.45 : 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.advanced.localAsrTitle')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 2 }}>
|
||||
{t('settings.advanced.localAsrDesc')}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em' }}>{t('settings.advanced.localAsrTitle')}</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
@@ -190,16 +140,9 @@ export function AdvancedSection() {
|
||||
) : (
|
||||
<>
|
||||
{/* 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 行。 */}
|
||||
+ 不可点 + desc=notSupportedHere。整行灰显,跟「实验性」标题区对齐。 */}
|
||||
<div style={{ opacity: isWin ? 0.45 : 1 }}>
|
||||
<SettingRow
|
||||
label={t('settings.providers.presets.asrLocalQwen3')}
|
||||
desc={isMac ? t('settings.advanced.qwen3Desc') : t('settings.advanced.notSupportedHere')}>
|
||||
<SettingRow label={t('settings.providers.presets.asrLocalQwen3')}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Toggle
|
||||
on={isMac && isOnLocalQwen3}
|
||||
@@ -214,9 +157,7 @@ export function AdvancedSection() {
|
||||
|
||||
{/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */}
|
||||
{isWin && (
|
||||
<SettingRow
|
||||
label={t('settings.providers.presets.asrFoundryLocalWhisper')}
|
||||
desc={t('settings.advanced.foundryDesc')}>
|
||||
<SettingRow label={t('settings.providers.presets.asrFoundryLocalWhisper')}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Toggle
|
||||
on={isOnFoundry}
|
||||
@@ -231,15 +172,10 @@ export function AdvancedSection() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出:
|
||||
- Linux / 不支持平台:根本没有任何引擎行
|
||||
- 跨平台异常(macOS profile 同步到 foundry / Windows profile 同步到 qwen3):
|
||||
本机引擎 Toggle 是 off,关不动异常 active 的对方引擎
|
||||
否则平台本机 Toggle 自身就能 off → 关停,重复 disable 行徒增视觉。 */}
|
||||
{/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出(Linux / 跨平台
|
||||
异常 profile 同步)。否则平台本机 Toggle 自身就能 off。 */}
|
||||
{isOnAnyLocal && !((isMac && isOnLocalQwen3) || (isWin && isOnFoundry)) && (
|
||||
<SettingRow
|
||||
label={t('settings.advanced.disableLocalLabel')}
|
||||
desc={t('settings.advanced.disableLocalDesc')}>
|
||||
<SettingRow label={t('settings.advanced.disableLocalLabel')}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Btn
|
||||
variant="primary"
|
||||
@@ -251,11 +187,16 @@ export function AdvancedSection() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local)
|
||||
inline 渲染——「模型设置」独立页已删,这里是唯一入口。 */}
|
||||
{platformSupported && <LocalAsr embedded />}
|
||||
{/* 模型下载 / 加载(镜像源 · 模型列表 · 下载 · 删除 · 设为默认 · Foundry Local)
|
||||
—— 跟上面的启动开关收进同一个框:「实验性千问 ASR」是一个整体。
|
||||
一条分隔线把「启动开关」区与「模型管理」区分开。 */}
|
||||
{platformSupported && (
|
||||
<div style={{ marginTop: 16, borderTop: '0.5px solid var(--ol-line)', paddingTop: 16 }}>
|
||||
<LocalAsr embedded />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 服务 → 扩展市场:通过 GitHub 登录获取上传 / 点赞身份。
|
||||
// 浏览与安装风格在「风格」页内完成,设置页只管登录身份。
|
||||
//
|
||||
// 登录走共用的 <GithubLoginModal />(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 (
|
||||
<Card>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const login = prefs.marketplaceDevLogin.trim();
|
||||
const signedIn = login.length > 0;
|
||||
|
||||
const signOut = () => {
|
||||
void savePrefs(current => ({ ...current, marketplaceDevLogin: '' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<SectionTitle>{t('settings.marketplace.title')}</SectionTitle>
|
||||
|
||||
{signedIn ? (
|
||||
/* ── 已登录 ──────────────────────────────────────────────── */
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
background: 'var(--ol-surface-2)',
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 999,
|
||||
background: 'var(--ol-ink)',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon name="user" size={17} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
|
||||
{t('settings.marketplace.github.signedIn')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13.5,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ol-ink)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
@{login}
|
||||
</div>
|
||||
</div>
|
||||
<Btn variant="ghost" size="sm" onClick={signOut}>
|
||||
{t('settings.marketplace.github.signOut')}
|
||||
</Btn>
|
||||
</div>
|
||||
) : (
|
||||
/* ── 未登录 ──────────────────────────────────────────────── */
|
||||
<div>
|
||||
<Btn variant="primary" size="sm" icon="user" onClick={() => setShowLogin(true)}>
|
||||
{t('settings.marketplace.github.signIn')}
|
||||
</Btn>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLogin && (
|
||||
<GithubLoginModal
|
||||
onClose={() => setShowLogin(false)}
|
||||
onSuccess={nextLogin => {
|
||||
void savePrefs(current => ({ ...current, marketplaceDevLogin: nextLogin }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<void>>(Promise.resolve());
|
||||
|
||||
const enqueueMonitorTask = useCallback((task: () => Promise<void>) => {
|
||||
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<SelectOption[]>(() => {
|
||||
const meter = <LevelMeter level={level} />;
|
||||
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 (
|
||||
<SelectLite
|
||||
value={selectedName}
|
||||
onChange={onSelect}
|
||||
options={options}
|
||||
ariaLabel={t('settings.recording.microphoneLabel')}
|
||||
onOpenChange={next => {
|
||||
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 (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, height: 14, flexShrink: 0 }}>
|
||||
{bars.map((weight, index) => {
|
||||
const intensity = Math.min(1, amplified * (0.85 + weight * 0.35));
|
||||
const height = 4 + intensity * (10 * weight);
|
||||
return (
|
||||
<span
|
||||
key={`${weight}-${index}`}
|
||||
style={{
|
||||
width: 3,
|
||||
height,
|
||||
borderRadius: 999,
|
||||
background: intensity > 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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.permissions.title')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>
|
||||
{desc}
|
||||
</div>
|
||||
<SettingRow label={t('settings.permissions.micLabel')} desc={t('settings.permissions.micDesc')}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>{t('settings.permissions.title')}</div>
|
||||
<SettingRow label={t('settings.permissions.micLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<PermissionPill status={microphone} />
|
||||
{microphone !== 'granted' && microphone !== 'notApplicable' && microphone !== 'loading' && (
|
||||
@@ -124,8 +115,8 @@ export function PermissionsSection() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
{capability?.requiresAccessibilityPermission && (
|
||||
<SettingRow label={t('settings.permissions.accLabel')} desc={t('settings.permissions.accDesc')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<SettingRow label={t('settings.permissions.accLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<PermissionPill status={accessibility} />
|
||||
{accessibility !== 'granted' && accessibility !== 'notApplicable' && (
|
||||
<Btn variant="ghost" size="sm" onClick={reRequestAccessibility}>
|
||||
@@ -135,10 +126,7 @@ export function PermissionsSection() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t('settings.permissions.hotkeyLabel')}
|
||||
desc={capability ? t('settings.permissions.hotkeyDescWithAdapter', { adapter: adapterDisplayName(capability.adapter) }) : t('settings.permissions.hotkeyDescPlain')}
|
||||
>
|
||||
<SettingRow label={t('settings.permissions.hotkeyLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, justifyContent: 'flex-end', width: '100%' }}>
|
||||
{hotkey?.message && (
|
||||
<span style={{
|
||||
@@ -153,10 +141,7 @@ export function PermissionsSection() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
{windowsIme?.state !== 'notWindows' && (
|
||||
<SettingRow
|
||||
label={t('settings.permissions.windowsImeLabel')}
|
||||
desc={t('settings.permissions.windowsImeDesc')}
|
||||
>
|
||||
<SettingRow label={t('settings.permissions.windowsImeLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, justifyContent: 'flex-end', width: '100%' }}>
|
||||
{windowsIme && (
|
||||
<span style={{
|
||||
@@ -171,7 +156,7 @@ export function PermissionsSection() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow label={t('settings.permissions.networkLabel')} desc={t('settings.permissions.networkDesc')}>
|
||||
<SettingRow label={t('settings.permissions.networkLabel')}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%' }}>
|
||||
{network && network.latencyMs != null && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
|
||||
@@ -242,9 +227,3 @@ function NetworkStatusPill({ status }: { status: NetworkCheckResult | null }) {
|
||||
}
|
||||
return <Pill tone="outline">{t('settings.permissions.networkOffline') ?? '不可用'}</Pill>;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
// 服务 → 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 (
|
||||
<div
|
||||
title={t('settings.providers.thinkingModeHint')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingLeft: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)' }}>
|
||||
{t('settings.providers.thinkingModeLabel')}
|
||||
</span>
|
||||
<Toggle on={enabled} onToggle={onToggle} />
|
||||
<span style={{ fontSize: 11.5, color: enabled ? 'var(--ol-blue)' : 'var(--ol-ink-4)' }}>
|
||||
{enabled ? t('settings.providers.thinkingModeOn') : t('settings.providers.thinkingModeOff')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.<nameKey>` 加文案。
|
||||
// `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: '' },
|
||||
// 本地 Qwen3-ASR:无 baseUrl/model 配置,模型在「高级 → 本地模型」里下载与切换。
|
||||
{ id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' },
|
||||
];
|
||||
|
||||
export function ProvidersSection() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs } = useHotkeySettings();
|
||||
// `*Provider` 立即跟随 <select> 改动(受控组件必须实时反映用户输入);
|
||||
// `committed*Provider` 才决定 CredentialField 的 key,仅在后端 active
|
||||
// 切换 + 默认值写完后再 commit。两者拆开是为了同时满足:
|
||||
// - <select> 立刻显示用户的选择(issue #220 P2:codex 指出受控选不应等 await)
|
||||
// - CredentialField 不要在后端 active 切完前 remount(issue #219:避免读到旧 entry)
|
||||
// `*SwitchSeq` 是 stale-write 守卫:用户 100ms 内连点两次时,先发的请求晚到不
|
||||
// 会覆盖后发的 commit。
|
||||
const [llmProvider, setLlmProvider] = useState<LlmPresetId>('ark');
|
||||
const [asrProvider, setAsrProvider] = useState<AsrPresetId>('volcengine');
|
||||
const [committedLlmProvider, setCommittedLlmProvider] = useState<LlmPresetId>('ark');
|
||||
const [committedAsrProvider, setCommittedAsrProvider] = useState<AsrPresetId>('volcengine');
|
||||
const llmSwitchSeqRef = useRef(0);
|
||||
const asrSwitchSeqRef = useRef(0);
|
||||
const [llmModelRevision, setLlmModelRevision] = useState(0);
|
||||
const [asrModelRevision, setAsrModelRevision] = useState(0);
|
||||
const os = detectOS();
|
||||
// 主 ASR 下拉只列云端选项;本地推理(local-qwen3 / foundry-local-whisper)
|
||||
// 移到「高级 → 本地模型」,防止新手误开 CPU 推理。
|
||||
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 —— 受控 <select> 必须反映用户最新选择。
|
||||
// 2. 用 seq 守卫每个 await:用户连点两次时旧请求晚到也不会盖掉新选择。
|
||||
// 3. 仅 setCommittedLlmProvider 之后 CredentialField 才 remount 读新 entry,
|
||||
// 此时后端 root.active.llm 已经是 id,lookup_account 落到正确 entry。
|
||||
// 4. endpoint/model 默认值仅在该 provider entry 该字段为空时才填,不覆盖用户自定义。
|
||||
const onLlmProviderChange = async (id: LlmPresetId) => {
|
||||
setLlmProvider(id);
|
||||
const seq = ++llmSwitchSeqRef.current;
|
||||
emitSaved('saving', t('common.saving'));
|
||||
try {
|
||||
await setActiveLlmProvider(id);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
if (prefs) {
|
||||
const next = { ...prefs, activeLlmProvider: id };
|
||||
await updatePrefs(next);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
}
|
||||
const preset = LLM_PRESETS.find(p => p.id === id);
|
||||
// 修 bug:所有 LLM provider 共用 `ark.endpoint` / `ark.model_id` 一对凭据槽
|
||||
// (persistence.rs 没做 per-provider 隔离)。旧逻辑只在槽空时填默认值,
|
||||
// 老用户切换 preset 时槽里早有旧值——dropdown 看着切了,polish 实际还是
|
||||
// 打老 endpoint。改成:切到任何非 custom 预设都强制覆盖 endpoint 与 model
|
||||
// 到该预设的默认值,让"切换"真切到位。custom 预设没有默认值,跳过。
|
||||
if (preset && preset.id !== 'custom') {
|
||||
if (preset.baseUrl) {
|
||||
await setCredential('ark.endpoint', preset.baseUrl);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
}
|
||||
if (preset.modelPlaceholder) {
|
||||
await setCredential('ark.model_id', preset.modelPlaceholder);
|
||||
if (seq !== llmSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
setCommittedLlmProvider(id);
|
||||
emitSaved('saved', t('common.saved'));
|
||||
} catch (err) {
|
||||
// seq 守卫:只有当前 call 还是最新时才把 saving 翻成 failed;
|
||||
// 旧 call 早被 newer call 的 emitSaved('saving') 覆盖,再叠 failed 会
|
||||
// 把 newer 正在跑的 saving 假伪成失败。
|
||||
if (seq === llmSwitchSeqRef.current) {
|
||||
emitSaved('failed', t('common.operationFailed'));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const onLlmThinkingToggle = (enabled: boolean) => {
|
||||
if (!prefs) return;
|
||||
void updatePrefs(current => ({ ...current, llmThinkingEnabled: enabled })).catch(error => {
|
||||
console.error('[settings] failed to update LLM thinking mode', error);
|
||||
emitSaved('failed', t('common.operationFailed'));
|
||||
});
|
||||
};
|
||||
|
||||
const onAsrProviderChange = async (id: AsrPresetId) => {
|
||||
setAsrProvider(id);
|
||||
const seq = ++asrSwitchSeqRef.current;
|
||||
emitSaved('saving', t('common.saving'));
|
||||
try {
|
||||
await setActiveAsrProvider(id);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (prefs) {
|
||||
const next = { ...prefs, activeAsrProvider: id };
|
||||
await updatePrefs(next);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
}
|
||||
// OpenAI 兼容厂商首次切换时预填 baseUrl / model 默认值,省得用户必踩
|
||||
// 「跨厂商 model 名根本不一样」的坑;但用户已自定义后就不再覆盖。
|
||||
// volcengine 走另一套凭据,跳过。
|
||||
const preset = ASR_PRESETS.find(p => p.id === id);
|
||||
if (preset && preset.baseUrl) {
|
||||
const existing = await readCredential('asr.endpoint');
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('asr.endpoint', preset.baseUrl);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
if (preset && preset.model) {
|
||||
const existing = await readCredential('asr.model');
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
if (!existing) {
|
||||
await setCredential('asr.model', preset.model);
|
||||
if (seq !== asrSwitchSeqRef.current) return;
|
||||
}
|
||||
}
|
||||
setCommittedAsrProvider(id);
|
||||
emitSaved('saved', t('common.saved'));
|
||||
} catch (err) {
|
||||
// seq 守卫同上 onLlmProviderChange:旧 call 不要把 newer call 的 saving
|
||||
// 伪造成 failed。
|
||||
if (seq === asrSwitchSeqRef.current) {
|
||||
emitSaved('failed', t('common.operationFailed'));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// preset 决定 placeholder 与 default —— 必须跟着 committed*Provider 走,
|
||||
// 否则受控 <select> 立刻切到新厂商,但凭据字段还在显示旧 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 (
|
||||
<>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, marginBottom: 10 }}>
|
||||
{t('settings.providers.credentialStorageNotice')}
|
||||
</div>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<SectionTitle>{t('settings.providers.llmTitle')}</SectionTitle>
|
||||
</div>
|
||||
{/* desc 已去掉——'选择后将自动填入 Base URL 默认值' 在 180px label 列必换行成两行,
|
||||
视觉上 label 区出现"字体单独占一行"。下拉自身已经表达了"切换"含义,desc 冗余。 */}
|
||||
<SettingRow label={t('settings.providers.providerLabel')}>
|
||||
<SelectLite
|
||||
value={llmProvider}
|
||||
onChange={next => 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 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
{codexOAuthSelected ? (
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6, margin: '2px 0 10px' }}>
|
||||
{t('settings.providers.codexOAuthNotice')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CredentialField key={`${committedLlmProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="ark.api_key" mono mask />
|
||||
<CredentialField key={`${committedLlmProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="ark.endpoint"
|
||||
placeholder={preset.baseUrl || 'https://your-endpoint/v1'} />
|
||||
</>
|
||||
)}
|
||||
<CredentialField key={`${committedLlmProvider}:model:${llmModelRevision}`} label={t('settings.providers.modelLabel')} account="ark.model_id"
|
||||
placeholder={preset.modelPlaceholder || 'model-name'} mono
|
||||
trailing={(
|
||||
<LlmThinkingToggle
|
||||
enabled={prefs?.llmThinkingEnabled ?? false}
|
||||
onToggle={onLlmThinkingToggle}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ProviderTools key={committedLlmProvider} kind="llm" modelAccount="ark.model_id" onModelSelected={() => setLlmModelRevision(v => v + 1)} />
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<SectionTitle>{t('settings.providers.asrTitle')}</SectionTitle>
|
||||
</div>
|
||||
{/* 下拉只放云端选项;本地引擎激活时锁住 + 在下方放一行"ASR 提供商已被接管"提示,
|
||||
未激活时不显示提示。 */}
|
||||
<SettingRow label={t('settings.providers.providerLabel')}>
|
||||
{(() => {
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start', minWidth: 0 }}>
|
||||
<SelectLite
|
||||
value={selectedValue}
|
||||
disabled={isLocked}
|
||||
onChange={next => 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 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
|
||||
{t('settings.providers.asrProviderTakenOver')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</SettingRow>
|
||||
{committedAsrProvider === 'volcengine' ? (
|
||||
<>
|
||||
<CredentialField
|
||||
key={`${committedAsrProvider}:app_key`}
|
||||
label={t('settings.providers.volcengineAppKeyLabel')}
|
||||
account="volcengine.app_key"
|
||||
mono
|
||||
mask
|
||||
/>
|
||||
<CredentialField
|
||||
key={`${committedAsrProvider}:access_key`}
|
||||
label={t('settings.providers.volcengineAccessKeyLabel')}
|
||||
account="volcengine.access_key"
|
||||
mono
|
||||
mask
|
||||
/>
|
||||
<CredentialField
|
||||
key={`${committedAsrProvider}:resource_id`}
|
||||
label={t('settings.providers.volcengineResourceIdLabel')}
|
||||
account="volcengine.resource_id"
|
||||
mono
|
||||
placeholder={ASR_DEFAULT_RESOURCE_ID} defaultValue={ASR_DEFAULT_RESOURCE_ID} />
|
||||
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
|
||||
{t('settings.providers.volcengineMappingNote')}
|
||||
</div>
|
||||
</>
|
||||
) : committedAsrProvider === 'local-qwen3' || committedAsrProvider === 'foundry-local-whisper' ? (
|
||||
// 用户已经在用本地 ASR——dropdown 行的 asrProviderTakenOver 已经把
|
||||
// "在高级中切换或禁用"讲清楚了,body 不再重复。
|
||||
// 模型管理 UI 唯一入口在「高级 → 本地模型」里的 <LocalAsr embedded />。
|
||||
null
|
||||
) : (
|
||||
<>
|
||||
<CredentialField key={`${committedAsrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" mono mask />
|
||||
<CredentialField key={`${committedAsrProvider}:endpoint`} label={t('settings.providers.baseUrlLabel')} account="asr.endpoint"
|
||||
placeholder={asrPreset?.baseUrl || 'https://api.openai.com/v1'}
|
||||
defaultValue={asrPreset?.baseUrl || undefined} />
|
||||
<CredentialField key={`${committedAsrProvider}:model:${asrModelRevision}`} label={t('settings.providers.modelLabel')} account="asr.model"
|
||||
placeholder={asrPreset?.model || 'whisper-1'} />
|
||||
{committedAsrProvider === 'bailian' && (
|
||||
<>
|
||||
<CredentialField
|
||||
key={`${committedAsrProvider}:vocabulary_id`}
|
||||
label={t('settings.providers.bailianVocabularyIdLabel')}
|
||||
account="asr.vocabulary_id"
|
||||
mono
|
||||
placeholder="vocab-..."
|
||||
/>
|
||||
<div style={{ marginTop: 2, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.6 }}>
|
||||
{t('settings.providers.bailianVocabularyIdNote')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ProviderTools kind="asr" modelAccount="asr.model" onModelSelected={() => setAsrModelRevision(v => v + 1)} />
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<string[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [status, setStatus] = useState<ProviderToolStatus>('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 (
|
||||
<SettingRow label={t('settings.providers.toolsLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: 420 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={validate} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.validate')}</button>
|
||||
<button onClick={loadModels} style={miniBtnStyle} disabled={status === 'loading'}>{t('settings.providers.fetchModels')}</button>
|
||||
{models.length > 0 && (
|
||||
<SelectLite
|
||||
value={selectedModel}
|
||||
onChange={applyModel}
|
||||
disabled={status === 'loading'}
|
||||
options={models.map(model => ({ value: model, label: model }))}
|
||||
placeholder={t('settings.providers.selectModel')}
|
||||
ariaLabel={t('settings.providers.selectModel')}
|
||||
style={{ ...inputStyle, maxWidth: 220 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{message && (
|
||||
<span style={{ fontSize: 11, color: status === 'error' ? 'var(--ol-warn)' : status === 'empty' ? 'var(--ol-ink-4)' : 'var(--ol-ok)', lineHeight: 1.4 }}>
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
function providerErrorMessage(error: unknown, t: ReturnType<typeof useTranslation>['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<CredentialFieldStatus>('idle');
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
const statusRef = useRef<number | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<SettingRow label={label}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', maxWidth: 420 }}>
|
||||
<input
|
||||
type={inputType}
|
||||
value={value}
|
||||
placeholder={loaded ? placeholder : t('common.loading')}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
style={{ ...inputStyle, fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
|
||||
/>
|
||||
{defaultValue && !value && loaded && (
|
||||
<button onClick={fillDefault} title={t('settings.providers.fillDefault')} style={iconBtnStyle} disabled={!loaded}>
|
||||
<Icon name="check" size={13} />
|
||||
</button>
|
||||
)}
|
||||
{trailing}
|
||||
{mask && (
|
||||
<button
|
||||
onClick={() => setRevealed(r => !r)}
|
||||
title={revealed ? t('common.hide') : t('common.show')}
|
||||
style={iconBtnStyle}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon name="eye" size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onCopy}
|
||||
title={t('common.copy')}
|
||||
style={iconBtnStyle}
|
||||
disabled={!value || disabled}
|
||||
>
|
||||
<Icon name="copy" size={14} />
|
||||
</button>
|
||||
{/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用;
|
||||
其它瞬态状态(saving / saved / saveError / copied / copyError)都通过
|
||||
emitSaved 发到右上角统一 toast,不再内联占位。 */}
|
||||
{status === 'readError' && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--ol-warn)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.readFailed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
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)',
|
||||
};
|
||||
@@ -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<boolean> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
return invoke<boolean>('plugin:autostart|is_enabled');
|
||||
}
|
||||
|
||||
async function autostartEnable(): Promise<void> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('plugin:autostart|enable');
|
||||
}
|
||||
|
||||
async function autostartDisable(): Promise<void> {
|
||||
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<MicrophoneDevice[]>([]);
|
||||
const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false);
|
||||
const [microphoneDevicesError, setMicrophoneDevicesError] = useState<string | null>(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 (
|
||||
<Card>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)', letterSpacing: '-0.01em' }}>
|
||||
{t('settings.recording.title')}
|
||||
</div>
|
||||
</div>
|
||||
{isHotkeyModeMigrationNoticeActive() && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
marginBottom: 8,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(37,99,235,0.08)',
|
||||
border: '0.5px solid rgba(37,99,235,0.18)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>
|
||||
{t('settings.recording.migrationNoticeTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
|
||||
{t('settings.recording.migrationNoticeDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SettingRow label={t('settings.recording.hotkeyLabel')}>
|
||||
<ShortcutRecorder
|
||||
value={prefs.dictationHotkey}
|
||||
onSave={async binding => {
|
||||
await setDictationHotkey(binding);
|
||||
await savePrefs({ ...prefs, dictationHotkey: binding });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.recording.modeLabel')}>
|
||||
<div style={{ display: 'inline-flex', padding: 2, borderRadius: 8, background: 'rgba(0,0,0,0.05)' }}>
|
||||
{choices.map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onModeChange(v)}
|
||||
style={{
|
||||
padding: '5px 14px', fontSize: 12, fontWeight: 500,
|
||||
border: 0, borderRadius: 6, fontFamily: 'inherit',
|
||||
background: prefs.hotkey.mode === v ? '#fff' : 'transparent',
|
||||
color: prefs.hotkey.mode === v ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
|
||||
boxShadow: prefs.hotkey.mode === v ? '0 1px 2px rgba(0,0,0,.08)' : 'none',
|
||||
cursor: 'default',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.recording.microphoneLabel')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<MicrophoneSelect
|
||||
devices={microphoneDevices}
|
||||
selectedName={effectiveMicrophoneDeviceName}
|
||||
onSelect={onMicrophoneDeviceChange}
|
||||
onOpen={() => { void loadMicrophoneDevices(undefined, { showLoading: false }); }}
|
||||
/>
|
||||
{microphoneDevicesError && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-err)', lineHeight: 1.5 }}>
|
||||
{t('settings.recording.microphoneLoadError', { message: microphoneDevicesError })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.recording.capsuleLabel')}>
|
||||
<Toggle on={prefs.showCapsule} onToggle={onShowCapsuleChange} />
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.recording.muteDuringRecordingLabel')}>
|
||||
<Toggle on={prefs.muteDuringRecording} onToggle={onMuteDuringRecordingChange} />
|
||||
</SettingRow>
|
||||
</Card>
|
||||
|
||||
{/* ─── 插入与剪贴板(折叠) ──────────────────────────────────── */}
|
||||
<Collapsible title={t('settings.recording.insertGroupTitle')}>
|
||||
<SettingRow label={t('settings.recording.restoreClipboardLabel')}>
|
||||
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
|
||||
</SettingRow>
|
||||
{capability.adapter !== 'macEventTap' && (
|
||||
<SettingRow label={t('settings.recording.pasteShortcutLabel')}>
|
||||
<SelectLite
|
||||
value={prefs.pasteShortcut}
|
||||
onChange={next => 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 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{capability.adapter === 'windowsLowLevel' && (
|
||||
<SettingRow label={t('settings.recording.allowNonTsfFallbackLabel')}>
|
||||
<Toggle
|
||||
on={prefs.allowNonTsfInsertionFallback}
|
||||
onToggle={onAllowNonTsfFallbackChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{/* 流式输入:润色 SSE 一边到达一边模拟键盘逐字落到光标,降低感知延迟。
|
||||
不满足条件时自动回落一次性插入。属于「插入行为」,故归到本组。 */}
|
||||
<SettingRow label={t('settings.advanced.streamingInsertLabel')}>
|
||||
<Toggle
|
||||
on={!!prefs.streamingInsert}
|
||||
onToggle={(next) => void savePrefs({ ...prefs, streamingInsert: next })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.advanced.streamingInsertSaveClipboardLabel')}>
|
||||
<Toggle
|
||||
on={!!prefs.streamingInsertSaveClipboard}
|
||||
onToggle={(next) => void savePrefs({ ...prefs, streamingInsertSaveClipboard: next })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Collapsible>
|
||||
|
||||
{/* ─── 启动(折叠) ──────────────────────────────────────────── */}
|
||||
<Collapsible title={t('settings.recording.startupGroupTitle')}>
|
||||
<AutostartRow />
|
||||
<SettingRow label={t('settings.recording.startMinimizedLabel')}>
|
||||
<Toggle on={prefs.startMinimized} onToggle={onStartMinimizedChange} />
|
||||
</SettingRow>
|
||||
<SettingRow label={t('settings.recording.autoUpdateCheckLabel')}>
|
||||
<Toggle on={prefs.autoUpdateCheck} onToggle={onAutoUpdateCheckChange} />
|
||||
</SettingRow>
|
||||
{capability.statusHint && (
|
||||
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
|
||||
{capability.statusHint}
|
||||
</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 不存进 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<string | null>(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 (
|
||||
<SettingRow label={t('settings.recording.startupAtBoot')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{loaded ? <Toggle on={enabled} onToggle={onToggle} /> : null}
|
||||
{error && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-err)', marginTop: 4, lineHeight: 1.5 }}>
|
||||
{t('settings.recording.startupAtBootError', { message: error })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('settings.shortcuts.title')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 6 }}>{desc}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>{t('settings.shortcuts.title')}</div>
|
||||
<SettingRow label={t('settings.shortcuts.startStop')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
|
||||
<ShortcutRecorder
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 共享在 Settings 各 section 间的原子(SettingRow / Toggle / inputStyle)。
|
||||
// AsrPresetId 也放在这里,让 AdvancedSection 与 Settings.tsx 都从一处来源拿。
|
||||
// 共享在设置各 section 间的原子(SettingRow / Toggle / inputStyle)。
|
||||
// AsrPresetId 也放在这里,让 settings/ 下各 section 都从同一处来源拿。
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
@@ -63,8 +63,8 @@ export const inputStyle: CSSProperties = {
|
||||
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
|
||||
};
|
||||
|
||||
// ASR provider id 集合,跟 Settings.tsx::ASR_PRESETS 一一对应。
|
||||
// 拆成独立类型让 AdvancedSection / ProvidersSection 都能用同一份不互相依赖。
|
||||
// ASR provider id 集合,跟 ProvidersSection.tsx::ASR_PRESETS 一一对应。
|
||||
// 拆成独立类型让 LocalModelSection / ProvidersSection 都能用同一份不互相依赖。
|
||||
export type AsrPresetId =
|
||||
| 'volcengine'
|
||||
| 'bailian'
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// 设置弹窗里每个侧栏 tab 对应的内容页。每个 tab 就是若干 section 卡片的纵向堆叠;
|
||||
// 真正的逻辑都在各 *Section 文件里,这里只负责"哪些 section 归到哪个 tab"。
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RecordingInputSection } from './RecordingInputSection';
|
||||
import { ShortcutsSection } from './ShortcutsSection';
|
||||
import { LanguageSection } from './LanguageSection';
|
||||
import { ProvidersSection } from './ProvidersSection';
|
||||
import { MarketplaceSection } from './MarketplaceSection';
|
||||
import { PermissionsSection } from './PermissionsSection';
|
||||
import { DataStorageSection } from './DataStorageSection';
|
||||
import { LocalModelSection } from './LocalModelSection';
|
||||
import { DebugToolsSection } from './DebugToolsSection';
|
||||
import { BetaChannelSection } from './BetaChannelSection';
|
||||
|
||||
// 通用:录音与输入 · 快捷键 · 语言。
|
||||
export function GeneralTab() {
|
||||
return (
|
||||
<>
|
||||
<RecordingInputSection />
|
||||
<ShortcutsSection />
|
||||
<LanguageSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 服务:AI 提供商 · 扩展市场。
|
||||
export function ServicesTab() {
|
||||
return (
|
||||
<>
|
||||
<ProvidersSection />
|
||||
<MarketplaceSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 隐私:本地优先说明 + 权限管理 · 数据存储。
|
||||
export function PrivacyTab() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
background: 'var(--ol-blue-soft)',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: 11, padding: '3px 8px', borderRadius: 999,
|
||||
background: '#fff', color: 'var(--ol-blue)', fontWeight: 600, flexShrink: 0,
|
||||
}}>
|
||||
{t('modal.about.localFirst')}
|
||||
</span>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
|
||||
{t('modal.about.privacyDesc')}
|
||||
</span>
|
||||
</div>
|
||||
<PermissionsSection />
|
||||
<DataStorageSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 高级:本地模型 · 调试工具 · 加入 Beta 渠道(固定在最下面)。
|
||||
export function AdvancedTab() {
|
||||
return (
|
||||
<>
|
||||
<LocalModelSection />
|
||||
<DebugToolsSection />
|
||||
<BetaChannelSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user